From b2927a68e02181f43db2ef5c87bdcb3879e417dc Mon Sep 17 00:00:00 2001 From: Dolicraft Date: Fri, 19 Jun 2026 09:42:53 +0200 Subject: [PATCH 001/307] Fix #37661 require price_base_type on product CSV import The import_fields_array entry for p.price_base_type accepted a missing/empty value because the CSV importer skips the regex check when newval is empty (import_csv.modules.php:682). Marking the field as mandatory with the trailing star forces the user to map a column and provide HT or TTC, so the row no longer ends up with an empty/NULL value that breaks price displays. Signed-off-by: Dolicraft --- htdocs/core/modules/modProduct.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/modules/modProduct.class.php b/htdocs/core/modules/modProduct.class.php index e344fc54b98..9909b2f4811 100644 --- a/htdocs/core/modules/modProduct.class.php +++ b/htdocs/core/modules/modProduct.class.php @@ -514,7 +514,7 @@ class modProduct extends DolibarrModules 'p.price_min' => "MinPrice", 'p.price_ttc' => "SellingPriceTTC", //with tax 'p.price_min_ttc' => "SellingMinPriceTTC", - 'p.price_base_type' => "PriceBaseType", //price base: with-tax (TTC) or without (HT) tax. Displays accordingly in Product card + 'p.price_base_type' => "PriceBaseType*", //price base: with-tax (TTC) or without (HT) tax. Displays accordingly in Product card 'p.tva_tx' => 'VATRate', 'p.datec' => 'DateCreation', 'p.cost_price' => "CostPrice" From b726e3b853e1a11a03bac9ee75ea685cc1e19685 Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Tue, 23 Jun 2026 20:45:50 +0200 Subject: [PATCH 002/307] SEC: Sanitize SQL query parameters (#38942) (#38955) Sanitize SQL query parameters. Replace urlencode with explicit type casting to int for fk_statut and signed_status parameters. Co-authored-by: MDW --- htdocs/fichinter/list.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/fichinter/list.php b/htdocs/fichinter/list.php index ebe4239dbde..639d5c13a2c 100644 --- a/htdocs/fichinter/list.php +++ b/htdocs/fichinter/list.php @@ -8,6 +8,7 @@ * Copyright (C) 2018 Ferran Marcet * Copyright (C) 2021-2023 Frédéric France * Copyright (C) 2022 Charlène Benke + * Copyright (C) 2024-2026 MDW * * 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 @@ -304,7 +305,7 @@ if ($search_desc) { } } if ($search_status != '' && $search_status >= 0) { - $sql .= ' AND f.fk_statut = '.urlencode($search_status); + $sql .= ' AND f.fk_statut = '.((int) ($search_status)); } if (empty($user->rights->societe->client->voir) && empty($socid)) { $sql .= " AND s.rowid = sc.fk_soc AND sc.fk_user = ".((int) $user->id); From c8ebd5ce530d97dca4623a21d465c99bd0854c12 Mon Sep 17 00:00:00 2001 From: Zakaria Boushaba <48571684+Boushabazakaria@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:51:23 +0100 Subject: [PATCH 003/307] Fix date/datetime extrafield shown empty after update_extras due to stale POST data (#38984) Co-authored-by: Zakaria Boushaba --- htdocs/core/tpl/extrafields_view.tpl.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/core/tpl/extrafields_view.tpl.php b/htdocs/core/tpl/extrafields_view.tpl.php index 8dc99923682..98629728a28 100644 --- a/htdocs/core/tpl/extrafields_view.tpl.php +++ b/htdocs/core/tpl/extrafields_view.tpl.php @@ -281,7 +281,7 @@ if (empty($reshook) && !empty($object->table_element) && isset($extrafields->att $datenotinstring = $db->jdate($datenotinstring); } //print 'x'.$object->array_options['options_' . $tmpkeyextra].'-'.$datenotinstring.' - '.dol_print_date($datenotinstring, 'dayhour'); - $value = GETPOSTISSET("options_".$tmpkeyextra) ? dol_mktime(12, 0, 0, GETPOSTINT("options_".$tmpkeyextra."month"), GETPOSTINT("options_".$tmpkeyextra."day"), GETPOSTINT("options_".$tmpkeyextra."year")) : $datenotinstring; + $value = ($action == 'edit_extras' && GETPOSTISSET("options_".$tmpkeyextra)) ? dol_mktime(12, 0, 0, GETPOSTINT("options_".$tmpkeyextra."month"), GETPOSTINT("options_".$tmpkeyextra."day"), GETPOSTINT("options_".$tmpkeyextra."year")) : $datenotinstring; } if (in_array($extrafields->attributes[$object->table_element]['type'][$tmpkeyextra], array('datetime'))) { $datenotinstring = empty($object->array_options['options_'.$tmpkeyextra]) ? '' : $object->array_options['options_'.$tmpkeyextra]; @@ -290,7 +290,7 @@ if (empty($reshook) && !empty($object->table_element) && isset($extrafields->att $datenotinstring = $db->jdate($datenotinstring); } //print 'x'.$object->array_options['options_' . $tmpkeyextra].'-'.$datenotinstring.' - '.dol_print_date($datenotinstring, 'dayhour'); - $value = GETPOSTISSET("options_".$tmpkeyextra) ? dol_mktime(GETPOSTINT("options_".$tmpkeyextra."hour"), GETPOSTINT("options_".$tmpkeyextra."min"), GETPOSTINT("options_".$tmpkeyextra."sec"), GETPOSTINT("options_".$tmpkeyextra."month"), GETPOSTINT("options_".$tmpkeyextra."day"), GETPOSTINT("options_".$tmpkeyextra."year"), 'tzuserrel') : $datenotinstring; + $value = ($action == 'edit_extras' && GETPOSTISSET("options_".$tmpkeyextra)) ? dol_mktime(GETPOSTINT("options_".$tmpkeyextra."hour"), GETPOSTINT("options_".$tmpkeyextra."min"), GETPOSTINT("options_".$tmpkeyextra."sec"), GETPOSTINT("options_".$tmpkeyextra."month"), GETPOSTINT("options_".$tmpkeyextra."day"), GETPOSTINT("options_".$tmpkeyextra."year"), 'tzuserrel') : $datenotinstring; } // TODO Improve element and rights detection From 9570b49024021909b1baabd34c36409815db872d Mon Sep 17 00:00:00 2001 From: kkhelifa-opendsi Date: Thu, 25 Jun 2026 21:28:08 +0200 Subject: [PATCH 004/307] FIX: Fix rang of added lines when create a contract from another element (#38856) --- htdocs/contrat/card.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/contrat/card.php b/htdocs/contrat/card.php index 524722f10ad..036efff36f5 100644 --- a/htdocs/contrat/card.php +++ b/htdocs/contrat/card.php @@ -360,6 +360,7 @@ if (empty($reshook)) { $fk_parent_line = 0; $num = count($lines); + $rang = 0; for ($i = 0; $i < $num; $i++) { $product_type = ($lines[$i]->product_type ? $lines[$i]->product_type : 0); @@ -429,7 +430,7 @@ if (empty($reshook)) { $lines[$i]->pa_ht, $array_options, (int) $lines[$i]->fk_unit, - $num + 1 + $rang++ ); if ($result < 0) { From 93602a7246a63007a9a9a5949d89afc427fda338 Mon Sep 17 00:00:00 2001 From: Zakaria Boushaba Date: Fri, 26 Jun 2026 09:23:25 +0100 Subject: [PATCH 005/307] Fix: reset fk_code_ventilation on cloned customer invoice lines --- htdocs/compta/facture/class/facture.class.php | 1 + 1 file changed, 1 insertion(+) diff --git a/htdocs/compta/facture/class/facture.class.php b/htdocs/compta/facture/class/facture.class.php index 78c454fed8e..cd146bc69b4 100644 --- a/htdocs/compta/facture/class/facture.class.php +++ b/htdocs/compta/facture/class/facture.class.php @@ -1354,6 +1354,7 @@ class Facture extends CommonInvoice } $object->lines[$i]->ref_ext = ''; // Do not clone ref_ext + $object->lines[$i]->fk_code_ventilation = 0; // Do not clone accounting binding } // Create clone From 0a810507bb5852c1fd0a2cd715795b1fa7306e29 Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Fri, 26 Jun 2026 13:26:11 +0200 Subject: [PATCH 006/307] FIX : when cloning a supplier order, the date would be set to 01/01/1970 (#38270) --- htdocs/fourn/class/fournisseur.commande.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/fourn/class/fournisseur.commande.class.php b/htdocs/fourn/class/fournisseur.commande.class.php index 086897566fc..e011371eacc 100644 --- a/htdocs/fourn/class/fournisseur.commande.class.php +++ b/htdocs/fourn/class/fournisseur.commande.class.php @@ -163,7 +163,7 @@ class CommandeFournisseur extends CommonOrder public $date_approve2; /** - * @var int Date of the purchase order ordering + * @var null|int Date of the purchase order ordering */ public $date_commande; @@ -1944,7 +1944,7 @@ class CommandeFournisseur extends CommonOrder $this->date = dol_now(); $this->date_creation = 0; $this->date_validation = 0; - $this->date_commande = 0; + $this->date_commande = null; $this->ref_supplier = ''; $this->user_approve_id = 0; $this->user_approve_id2 = 0; From d9102959fda7e9b57d18fedc931525096be32c84 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 27 Jun 2026 16:49:45 +0200 Subject: [PATCH 007/307] Fix showunused option in sanity_check_trans_missing_unused.sh --- dev/translation/sanity_check_trans_missing_unused.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dev/translation/sanity_check_trans_missing_unused.sh b/dev/translation/sanity_check_trans_missing_unused.sh index a04a11208fe..d671d1dd164 100755 --- a/dev/translation/sanity_check_trans_missing_unused.sh +++ b/dev/translation/sanity_check_trans_missing_unused.sh @@ -130,9 +130,11 @@ diff "${AVAILABLE_FILE}" "${EXPECTED_FILE}" \ > "${MISSING_AND_UNUSED_FILE}" rm -f "${UNUSED_FILE}.grep" >/dev/null 2>&1 -sed -n 's@< \(.*\)@^\1\\s*=@p' \ - < "${MISSING_AND_UNUSED_FILE}" \ - > "${UNUSED_FILE}.grep" +if [ "$1" == "--showunused" ]; then + sed -n 's@< \(.*\)@^\1\\s*=@p' \ + < "${MISSING_AND_UNUSED_FILE}" \ + > "${UNUSED_FILE}.grep" +fi # Prepare file with exact matches for use with `git grep`, supposing " quotes From 6e101143e02aeada6c760f1cdb5f68ceaece15bc Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 27 Jun 2026 16:49:45 +0200 Subject: [PATCH 008/307] Fix showunused option in sanity_check_trans_missing_unused.sh --- dev/translation/sanity_check_trans_missing_unused.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dev/translation/sanity_check_trans_missing_unused.sh b/dev/translation/sanity_check_trans_missing_unused.sh index a04a11208fe..d671d1dd164 100755 --- a/dev/translation/sanity_check_trans_missing_unused.sh +++ b/dev/translation/sanity_check_trans_missing_unused.sh @@ -130,9 +130,11 @@ diff "${AVAILABLE_FILE}" "${EXPECTED_FILE}" \ > "${MISSING_AND_UNUSED_FILE}" rm -f "${UNUSED_FILE}.grep" >/dev/null 2>&1 -sed -n 's@< \(.*\)@^\1\\s*=@p' \ - < "${MISSING_AND_UNUSED_FILE}" \ - > "${UNUSED_FILE}.grep" +if [ "$1" == "--showunused" ]; then + sed -n 's@< \(.*\)@^\1\\s*=@p' \ + < "${MISSING_AND_UNUSED_FILE}" \ + > "${UNUSED_FILE}.grep" +fi # Prepare file with exact matches for use with `git grep`, supposing " quotes From 5ade4d0567765ae8fbc008d7638f256f9771f24e Mon Sep 17 00:00:00 2001 From: nicolasVr <46503289+nicolasVr@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:57:03 +0200 Subject: [PATCH 009/307] FIX Syntax error (#38976) This prevented display of reminder fields --- htdocs/comm/action/card.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/comm/action/card.php b/htdocs/comm/action/card.php index a59bbda3588..ea2ec7498db 100644 --- a/htdocs/comm/action/card.php +++ b/htdocs/comm/action/card.php @@ -1925,7 +1925,7 @@ if ($action == 'create') { $("#select_offsetunittype_duration").select2("destroy"); $("#select_offsetunittype_duration").select2(); selectremindertype(); - }); + } toggle_reminder_part(); $("#addreminder").click(toggle_reminder_part); From 429d3354e2569964daa5dadf09e8da8b3482b5b4 Mon Sep 17 00:00:00 2001 From: w3houde Date: Sat, 27 Jun 2026 18:24:32 +0200 Subject: [PATCH 010/307] Fix #31471 translate CostPrice and PriceBaseType on import field list (#38922) import.php loadLangs only loaded exports/compta/errors, so labels coming from modProduct / modService import_fields_array that resolve to keys in products.lang (PriceBaseType) and margins.lang (CostPrice) stayed in English on a French install. Add 'products' and 'margins' to the loaded set so the import field picker shows the translated label everywhere else the user sees the product card. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/imports/import.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/imports/import.php b/htdocs/imports/import.php index 77346fd195e..f4c77bd44b6 100644 --- a/htdocs/imports/import.php +++ b/htdocs/imports/import.php @@ -34,7 +34,7 @@ require_once DOL_DOCUMENT_ROOT.'/core/lib/images.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/import.lib.php'; // Load translation files required by the page -$langs->loadLangs(array('exports', 'compta', 'errors')); +$langs->loadLangs(array('exports', 'compta', 'errors', 'products', 'margins')); // Security check $result = restrictedArea($user, 'import'); From 60db2cbfe4698ab07b560f97b05dc17cedc80078 Mon Sep 17 00:00:00 2001 From: w3houde Date: Sat, 27 Jun 2026 18:28:39 +0200 Subject: [PATCH 011/307] Fix #34720 do not crash on duplicate bank_url link for same target (#38916) * Fix #36238 keep supplier filter when sorting product list print_liste_field_titre signature is (name, file, field, begin, moreparam, ...) but the seven calls in fourn/product/list.php passed $param at position 4 (begin) instead of 5 (moreparam), so the URL was rebuilt without the fourn_id filter and the list reset when the user clicked a column header. Swap to the correct slot, matching the fix already in the 22.0 line. Signed-off-by: Dolicraft * Fix #34720 do not crash on duplicate bank_url link for same target llx_bank_url has a UNIQUE key on (fk_bank, url_id, type) so dispatching two distinct credit transfers for the same employee on the same bank line raised a Duplicate entry crash that left the operation in an unfinished state. Pre-check the row and reuse the existing rowid silently, keeping the historical link without breaking the calling transaction. Signed-off-by: Dolicraft --------- Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/compta/bank/class/account.class.php | 16 ++++++++++++++++ htdocs/fourn/product/list.php | 14 +++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/htdocs/compta/bank/class/account.class.php b/htdocs/compta/bank/class/account.class.php index 26ce53400f9..375ed0c6d46 100644 --- a/htdocs/compta/bank/class/account.class.php +++ b/htdocs/compta/bank/class/account.class.php @@ -463,6 +463,22 @@ class Account extends CommonObject public function add_url_line($line_id, $url_id, $url, $label, $type) { // phpcs:enable + // Avoid uk_bank_url collision when the same target (line_id, url_id, type) is linked twice + // e.g. two distinct credit transfers for the same employee dispatched on the same bank line. + $sqlcheck = "SELECT rowid FROM ".MAIN_DB_PREFIX."bank_url"; + $sqlcheck .= " WHERE fk_bank = ".((int) $line_id); + $sqlcheck .= " AND url_id = ".((int) $url_id); + $sqlcheck .= " AND type = '".$this->db->escape($type)."'"; + $resqlcheck = $this->db->query($sqlcheck); + if ($resqlcheck) { + $obj = $this->db->fetch_object($resqlcheck); + if ($obj) { + $this->db->free($resqlcheck); + return (int) $obj->rowid; + } + $this->db->free($resqlcheck); + } + $sql = "INSERT INTO ".MAIN_DB_PREFIX."bank_url ("; $sql .= "fk_bank"; $sql .= ", url_id"; diff --git a/htdocs/fourn/product/list.php b/htdocs/fourn/product/list.php index 851c1d13ea5..fc03b31b45b 100644 --- a/htdocs/fourn/product/list.php +++ b/htdocs/fourn/product/list.php @@ -315,13 +315,13 @@ if ($resql) { // Line for title print ''; - print_liste_field_titre("Ref", $_SERVER["PHP_SELF"], "p.ref", $param, "", "", $sortfield, $sortorder); - print_liste_field_titre("RefSupplierShort", $_SERVER["PHP_SELF"], "ppf.ref_fourn", $param, "", "", $sortfield, $sortorder); - print_liste_field_titre("Label", $_SERVER["PHP_SELF"], "p.label", $param, "", "", $sortfield, $sortorder); - print_liste_field_titre("Supplier", $_SERVER["PHP_SELF"], "ppf.fk_soc", $param, "", "", $sortfield, $sortorder); - print_liste_field_titre("BuyingPrice", $_SERVER["PHP_SELF"], "ppf.price", $param, "", '', $sortfield, $sortorder, 'right '); - print_liste_field_titre("QtyMin", $_SERVER["PHP_SELF"], "ppf.quantity", $param, "", '', $sortfield, $sortorder, 'right '); - print_liste_field_titre("UnitPrice", $_SERVER["PHP_SELF"], "ppf.unitprice", $param, "", '', $sortfield, $sortorder, 'right '); + print_liste_field_titre("Ref", $_SERVER["PHP_SELF"], "p.ref", "", $param, "", $sortfield, $sortorder); + print_liste_field_titre("RefSupplierShort", $_SERVER["PHP_SELF"], "ppf.ref_fourn", "", $param, "", $sortfield, $sortorder); + print_liste_field_titre("Label", $_SERVER["PHP_SELF"], "p.label", "", $param, "", $sortfield, $sortorder); + print_liste_field_titre("Supplier", $_SERVER["PHP_SELF"], "ppf.fk_soc", "", $param, "", $sortfield, $sortorder); + print_liste_field_titre("BuyingPrice", $_SERVER["PHP_SELF"], "ppf.price", "", $param, '', $sortfield, $sortorder, 'right '); + print_liste_field_titre("QtyMin", $_SERVER["PHP_SELF"], "ppf.quantity", "", $param, '', $sortfield, $sortorder, 'right '); + print_liste_field_titre("UnitPrice", $_SERVER["PHP_SELF"], "ppf.unitprice", "", $param, '', $sortfield, $sortorder, 'right '); // add header cells from hooks $parameters = array(); $reshook = $hookmanager->executeHooks('printFieldListTitle', $parameters, $object, $action); From 9425b899f2eb1f5addad3d78574b2ea3fc01ff24 Mon Sep 17 00:00:00 2001 From: w3houde Date: Sat, 27 Jun 2026 18:57:06 +0200 Subject: [PATCH 012/307] Fix #36238 keep supplier filter when sorting product list (#38915) print_liste_field_titre signature is (name, file, field, begin, moreparam, ...) but the seven calls in fourn/product/list.php passed $param at position 4 (begin) instead of 5 (moreparam), so the URL was rebuilt without the fourn_id filter and the list reset when the user clicked a column header. Swap to the correct slot, matching the fix already in the 22.0 line. Signed-off-by: Dolicraft Co-authored-by: Dolicraft From 92ee0f2303d975a03f3d8d2b44afdb69971f34b5 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 29 Jun 2026 13:31:23 +0200 Subject: [PATCH 013/307] Fix - bad test permission on deleting documents api - credit Seonghyeon Lee --- htdocs/api/class/api_documents.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/api/class/api_documents.class.php b/htdocs/api/class/api_documents.class.php index b2ed45030d2..78ed957b15a 100644 --- a/htdocs/api/class/api_documents.class.php +++ b/htdocs/api/class/api_documents.class.php @@ -1272,7 +1272,7 @@ class Documents extends DolibarrApi $relativefile = $tmpreldir.dol_sanitizeFileName($object->ref); */ $relativefile = $original_file; - $check_access = dol_check_secure_access_document($modulepart, $relativefile, $entity, DolibarrApiAccess::$user, '', 'read'); + $check_access = dol_check_secure_access_document($modulepart, $relativefile, $entity, DolibarrApiAccess::$user, '', 'write'); $accessallowed = $check_access['accessallowed']; $sqlprotectagainstexternals = $check_access['sqlprotectagainstexternals']; $original_file = $check_access['original_file']; From 9f50160528f972eeaf7198c48c2773859f45d21e Mon Sep 17 00:00:00 2001 From: tcesar Date: Mon, 29 Jun 2026 20:26:29 +0200 Subject: [PATCH 014/307] =?UTF-8?q?=20Fix:=20mass=20createbills=20success?= =?UTF-8?q?=20message=20showed=20draft=20PROV=20ref=20instead=20=E2=80=A6?= =?UTF-8?q?=20(#39013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: mass createbills success message showed draft PROV ref instead of final ref Extended description (corps de la PR) When creating an invoice from several orders at once via the order list mass action ("Create invoice for this customer"), with the "Validate generated invoices" option set to "yes", the success message and its link displayed the temporary draft reference (PROVxxxx) instead of the final validated invoice reference. Root cause: In htdocs/commande/list.php, during the confirm_createbills action, $lastref is captured right after $objecttmp->create($user), at which point the invoice is still a draft (PROVxxxx). When the "validate invoices" option is enabled, each invoice is then validated through $objecttmp->validate($user), which assigns the definitive reference to the object. However $lastref was never refreshed afterwards, so the "BillXCreated" message (single-invoice case) kept showing the draft ref. Fix: Refresh $lastref from the object inside the validation loop, after a successful validate(), so the success message links to and displays the final reference. Only the single-invoice success message exposes a reference, so this is the only case affected; the multi-invoice message only shows a counter. Steps to reproduce: 1. Go to the orders list. 2. Select several orders (or one), and choose the "Create invoice for this customer" mass action. 3. Set "Validate generated invoices" to "Yes". 4. Confirm. => Before: the success message links to the draft ref PROVxxxx. => After: the success message links to the final validated invoice ref. * Fix indent --- htdocs/commande/list.php | 1 + 1 file changed, 1 insertion(+) diff --git a/htdocs/commande/list.php b/htdocs/commande/list.php index 953a717011b..ee344f22097 100644 --- a/htdocs/commande/list.php +++ b/htdocs/commande/list.php @@ -693,6 +693,7 @@ if (empty($reshook)) { } $id = $objecttmp->id; // For builddoc action + $lastref = $objecttmp->ref; // Refresh ref after validation (was the draft PROVxxxx ref set at creation) // Builddoc $donotredirect = 1; From 2d7a3d2a66c57f06a9cb499b0c3ac32791640dae Mon Sep 17 00:00:00 2001 From: tcesar Date: Mon, 29 Jun 2026 20:27:37 +0200 Subject: [PATCH 015/307] Fix: mass createbills could invoice canceled or already-billed orders (#39015) * Fix: mass createbills could invoice canceled or already-billed orders * Update orders.lang * fix indent --- htdocs/commande/list.php | 9 +++++++++ htdocs/langs/en_US/orders.lang | 1 + 2 files changed, 10 insertions(+) diff --git a/htdocs/commande/list.php b/htdocs/commande/list.php index ee344f22097..b312edb3f8c 100644 --- a/htdocs/commande/list.php +++ b/htdocs/commande/list.php @@ -434,6 +434,15 @@ if (empty($reshook)) { if ($cmd->fetch($id_order) <= 0) { continue; } + + // Skip orders that cannot be billed, to mirror the "CreateBill" button availability on the order card + // (card.php only shows it when status > STATUS_DRAFT and the order is not already billed): this excludes + // draft and canceled orders, as well as orders already classified as billed. + if ($cmd->status <= Commande::STATUS_DRAFT || !empty($cmd->billed)) { + setEventMessages($langs->trans('WarningOrderNotEligibleForInvoicing', $cmd->ref), null, 'warnings'); + continue; + } + $cmd->fetch_thirdparty(); $objecttmp = new Facture($db); diff --git a/htdocs/langs/en_US/orders.lang b/htdocs/langs/en_US/orders.lang index b5d66b9279b..7006f2ecda7 100644 --- a/htdocs/langs/en_US/orders.lang +++ b/htdocs/langs/en_US/orders.lang @@ -166,6 +166,7 @@ PDFEdisonDescription=A simple order model PDFProformaDescription=A complete Proforma invoice template CreateInvoiceForThisCustomer=Bill orders CreateInvoiceForThisSupplier=Bill orders +WarningOrderNotEligibleForInvoicing=Order %s has been skipped: its status does not allow creating an invoice CreateInvoiceForThisReceptions=Bill receptions NoOrdersToInvoice=No orders billable CloseProcessedOrdersAutomatically=Classify "Processed" all selected orders. From d39bb3a3bede9f271ecd1c5b4554b9f55bb730d3 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 27 Jun 2026 17:09:22 +0200 Subject: [PATCH 016/307] Test disable consumming task --- .pre-commit-config.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f417f86677e..76bb55b7b45 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -256,7 +256,8 @@ repos: rev: 3.3.1 hooks: - id: sqlfluff-lint - stages: [pre-commit, manual] # manual needed for ci + #stages: [pre-commit, manual] # manual needed for ci + stages: [manual] # manual needed for ci exclude: (?x)^ (dev/initdemo/mysqldump_.*\.sql |htdocs/core/menus/init_menu_auguria\.sql From f8d674f324077551c7ff17a469db869f558ec15d Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 27 Jun 2026 17:09:22 +0200 Subject: [PATCH 017/307] Test disable consumming task --- .pre-commit-config.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 01fd70ff8cc..9f3b54f34ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -254,7 +254,8 @@ repos: rev: 3.3.1 hooks: - id: sqlfluff-lint - stages: [pre-commit, manual] # manual needed for ci + #stages: [pre-commit, manual] # manual needed for ci + stages: [manual] # manual needed for ci exclude: (?x)^ (dev/initdemo/mysqldump_.*\.sql |htdocs/core/menus/init_menu_auguria\.sql From 0c50fd0223d172ffe80cfe998721a42a57bdc406 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 1 Jul 2026 12:24:59 +0200 Subject: [PATCH 018/307] Fix dir --- htdocs/bom/bom_agenda.php | 2 +- htdocs/bom/bom_card.php | 2 +- htdocs/bom/bom_document.php | 2 +- htdocs/bom/bom_list.php | 2 +- htdocs/bom/bom_net_needs.php | 2 +- htdocs/bom/bom_note.php | 2 +- .../product/stock/stocktransfer/class/stocktransfer.class.php | 2 +- htdocs/product/stock/stocktransfer/stocktransfer_list.php | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/htdocs/bom/bom_agenda.php b/htdocs/bom/bom_agenda.php index 90f8dd53099..2549a448157 100644 --- a/htdocs/bom/bom_agenda.php +++ b/htdocs/bom/bom_agenda.php @@ -84,7 +84,7 @@ if (!$sortorder) { // Initialize a technical objects $object = new BOM($db); -$diroutputmassaction = getMultidirOutput($object) . '/temp/massgeneration/'.$user->id; +$diroutputmassaction = getMultidirOutput($object, '', 0, 'temp') . 'massgeneration/'.$user->id; $hookmanager->initHooks(array('bomagenda', 'globalcard')); // Note that conf->hooks_modules contains array // Fetch optionals attributes and labels diff --git a/htdocs/bom/bom_card.php b/htdocs/bom/bom_card.php index e4cd35e11ad..a172030ea86 100644 --- a/htdocs/bom/bom_card.php +++ b/htdocs/bom/bom_card.php @@ -64,7 +64,7 @@ $hideref = (GETPOSTINT('hideref') ? GETPOSTINT('hideref') : (getDolGlobalString( // Initialize a technical objects $object = new BOM($db); -$diroutputmassaction = getMultidirOutput($object) . '/temp/massgeneration/'.$user->id; +$diroutputmassaction = getMultidirOutput($object, '', 0, 'temp') . 'massgeneration/'.$user->id; $hookmanager->initHooks(array('bomcard', 'globalcard')); // Note that conf->hooks_modules contains array // Fetch optionals attributes and labels diff --git a/htdocs/bom/bom_document.php b/htdocs/bom/bom_document.php index 707873d57fd..01635cbe9ea 100644 --- a/htdocs/bom/bom_document.php +++ b/htdocs/bom/bom_document.php @@ -75,7 +75,7 @@ if (!$sortfield) { // Initialize a technical objects $object = new BOM($db); -$diroutputmassaction = getMultidirOutput($object) . '/temp/massgeneration/'.$user->id; +$diroutputmassaction = getMultidirOutput($object, '', 0, 'temp') . 'massgeneration/'.$user->id; $hookmanager->initHooks(array('bomdocument', 'globalcard')); // Note that conf->hooks_modules contains array // Fetch optionals attributes and labels $extrafields->fetch_name_optionals_label($object->table_element); diff --git a/htdocs/bom/bom_list.php b/htdocs/bom/bom_list.php index 534b77bf0df..8ec1e1113fa 100644 --- a/htdocs/bom/bom_list.php +++ b/htdocs/bom/bom_list.php @@ -73,7 +73,7 @@ $pagenext = $page + 1; // Initialize a technical objects $object = new BOM($db); -$diroutputmassaction = getMultidirOutput($object) . '/temp/massgeneration/'.$user->id; +$diroutputmassaction = getMultidirOutput($object, '', 0, 'temp') . 'massgeneration/'.$user->id; $hookmanager->initHooks(array('bomlist')); // Note that conf->hooks_modules contains array // Fetch optionals attributes and labels diff --git a/htdocs/bom/bom_net_needs.php b/htdocs/bom/bom_net_needs.php index 8ed73e67725..e06e249d624 100644 --- a/htdocs/bom/bom_net_needs.php +++ b/htdocs/bom/bom_net_needs.php @@ -60,7 +60,7 @@ $object = new BOM($db); $hookmanager->initHooks(array('bomnetneeds')); // Note that conf->hooks_modules contains array // Massaction -$diroutputmassaction = getMultidirOutput($object) . '/temp/massgeneration/'.$user->id; +$diroutputmassaction = getMultidirOutput($object, '', 0, 'temp') . 'massgeneration/'.$user->id; // Fetch optionals attributes and labels $extrafields->fetch_name_optionals_label($object->table_element); diff --git a/htdocs/bom/bom_note.php b/htdocs/bom/bom_note.php index c802dabda5b..2a4047b5c29 100644 --- a/htdocs/bom/bom_note.php +++ b/htdocs/bom/bom_note.php @@ -53,7 +53,7 @@ $object = new BOM($db); $hookmanager->initHooks(array('bomnote', 'globalcard')); // Note that conf->hooks_modules contains array // Massactions -$diroutputmassaction = getMultidirOutput($object) . '/temp/massgeneration/'.$user->id; +$diroutputmassaction = getMultidirOutput($object, '', 0, 'temp') . 'massgeneration/'.$user->id; // Fetch optionals attributes and labels $extrafields->fetch_name_optionals_label($object->table_element); diff --git a/htdocs/product/stock/stocktransfer/class/stocktransfer.class.php b/htdocs/product/stock/stocktransfer/class/stocktransfer.class.php index df1bfb7bb7f..a5331d30fcf 100644 --- a/htdocs/product/stock/stocktransfer/class/stocktransfer.class.php +++ b/htdocs/product/stock/stocktransfer/class/stocktransfer.class.php @@ -231,7 +231,7 @@ class StockTransfer extends CommonObject */ public function __construct(DoliDB $db) { - global $conf, $langs; + global $langs; $this->db = $db; diff --git a/htdocs/product/stock/stocktransfer/stocktransfer_list.php b/htdocs/product/stock/stocktransfer/stocktransfer_list.php index be65453d17f..2c2475f1c13 100644 --- a/htdocs/product/stock/stocktransfer/stocktransfer_list.php +++ b/htdocs/product/stock/stocktransfer/stocktransfer_list.php @@ -73,7 +73,7 @@ $pagenext = $page + 1; // Initialize a technical objects $object = new StockTransfer($db); $extrafields = new ExtraFields($db); -$diroutputmassaction = getMultidirOutput($object).'/temp/massgeneration/'.$user->id; +$diroutputmassaction = getMultidirOutput($object, '', 0, 'temp') . 'massgeneration/'.$user->id; $hookmanager->initHooks(array('stocktransferlist')); // Note that conf->hooks_modules contains array // Fetch optionals attributes and labels From ee3875eb4e09654cadd01eb4514eab2ab6af3bc5 Mon Sep 17 00:00:00 2001 From: MDW Date: Wed, 1 Jul 2026 12:26:51 +0200 Subject: [PATCH 019/307] Qual: Fix french comments and improve detector (#39026) * Qual: Generate phan specific changed php files list # Qual: Generate phan specific changed php files list Uses multiple parts of the Phan configuration to select and exclude files for phan analysis. * avoid catching special characters in regexes as indicators of french * Qual: Translate french to english --- .github/scripts/get_changed_php.sh | 37 ++++++++++-- .github/workflows/phan.yml | 5 +- .../CheckIsModEnabledArgumentSniff.php | 5 +- .../Dolibarr/LanguageOfCommentsSniff.php | 6 +- .../rector/src/Renaming/GlobalToFunction.php | 4 +- htdocs/admin/external_rss.php | 10 ++-- htdocs/admin/mails_emailing.php | 6 +- htdocs/admin/mails_passwordreset.php | 4 +- htdocs/admin/mails_ticket.php | 4 +- htdocs/admin/sms.php | 7 ++- htdocs/categories/photos.php | 10 ++-- htdocs/commande/class/commande.class.php | 2 +- htdocs/compta/paiement/rapport.php | 2 +- htdocs/compta/paiement_vat.php | 4 +- htdocs/compta/stats/index.php | 12 ++-- htdocs/core/class/commonobject.class.php | 2 +- htdocs/core/class/html.form.class.php | 4 +- htdocs/core/class/html.formbarcode.class.php | 4 +- htdocs/core/class/html.formcompany.class.php | 10 ++-- htdocs/core/class/html.formsms.class.php | 4 +- htdocs/core/datepicker.php | 8 +-- htdocs/core/db/mysqli.class.php | 35 ++++++------ htdocs/core/db/pgsql.class.php | 56 +++++++++---------- htdocs/core/lib/xcal.lib.php | 6 +- htdocs/core/login/functions_ldap.php | 14 ++--- .../modules/mailings/contacts1.modules.php | 6 +- .../core/modules/mailings/fraise.modules.php | 28 +++++----- .../modules/mailings/modules_mailings.php | 15 +++-- htdocs/exports/export.php | 20 +++---- htdocs/fourn/facture/rapport.php | 2 +- htdocs/ftp/admin/ftpclient.php | 4 +- htdocs/install/repair.php | 6 +- htdocs/intracommreport/card.php | 3 +- .../class/intracommreport.class.php | 2 +- htdocs/opensurvey/lib/opensurvey.lib.php | 6 +- htdocs/product/class/api_products.class.php | 10 ++-- htdocs/product/class/product.class.php | 10 ++-- htdocs/product/price.php | 6 +- .../stock/class/api_productlots.class.php | 8 +-- htdocs/product/stock/productlot_card.php | 4 +- .../stocktransfer/stocktransfer_card.php | 20 +++---- htdocs/projet/class/api_projects.class.php | 6 +- htdocs/public/fichinter/agendaexport.php | 21 +++---- htdocs/public/members/public_list.php | 4 +- htdocs/public/opensurvey/studs.php | 5 +- htdocs/public/ticket/list.php | 13 +++-- htdocs/quickmemo/class/memo.class.php | 4 +- htdocs/quickmemo/memo_list.php | 3 +- htdocs/webservices/server_category.php | 6 +- test/phpunit/PricesTest.php | 5 +- 50 files changed, 257 insertions(+), 221 deletions(-) diff --git a/.github/scripts/get_changed_php.sh b/.github/scripts/get_changed_php.sh index 192de878900..43d64101c74 100755 --- a/.github/scripts/get_changed_php.sh +++ b/.github/scripts/get_changed_php.sh @@ -1,5 +1,7 @@ #!/bin/bash -# Copyright (C) 2025 MDW +# Copyright (C) 2025-2026 MDW + +# shellcheck disable=2129,2128,2034,2016 set -euo pipefail @@ -41,22 +43,33 @@ repo="${GITHUB_REPOSITORY##*/}" # Extract text after the last '/' page=1 per_page=100 changed_php_files=() +changed_phan_files=() changed_lang_files=() +# Get phan path configuration +phan_directory_list=$(php -r '$config = require("dev/tools/phan/config.php"); echo "^".implode("|",$config["directory_list"]);') +phan_exclude_directory=$(php -r '$config = require("dev/tools/phan/config.php"); echo "^".implode("|",$config["exclude_analysis_directory_list"]);') + +phan_exclude_file_regex=$(php -r '$config = require("dev/tools/phan/config.php"); echo $config["exclude_file_regex"];') +phan_exclude_file_regex=${phan_exclude_file_regex#@} +phan_exclude_file_regex=${phan_exclude_file_regex%@} + # Loop through all pages to gather changed files while true; do response=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ "https://api.github.com/repos/${owner}/${repo}/pulls/${pr_number}/files?per_page=${per_page}&page=${page}") - phan_exclude_file_regex=$(php -r '$config = require("dev/tools/phan/config.php"); echo $config["exclude_file_regex"];') # Filter for files ending with .php and add them to the list - mapfile -t files < <(echo "$response" | jq -r '.[] | select((.filename | test("\\.php$")) and (.filename | test("^dev/") | not)) | .filename' | grep -vP "$phan_exclude_file_regex") + mapfile -t files < <(echo "$response" | jq -r '.[] | select((.filename | test("\\.php$")) and (.filename | test("^dev/") | not)) | .filename') changed_php_files+=("${files[@]}") + mapfile -t files < <(echo "$response" | jq -r '.[] | select((.filename | test("\\.php$")) and (.filename | test("'"$phan_directory_list"'"))) | .filename' | grep -vP "$phan_exclude_file_regex") + changed_phan_files+=("${files[@]}") + mapfile -t files < <(echo "$response" | jq -r '.[] | select(.filename | test("\\.lang$")) | .filename') - changed_lang_files+=("${files[@]}") + changed_lang_files+=("${files[@]}") # Check if we have reached the last page (less than per_page results) count=$(echo "$response" | jq 'length') @@ -69,10 +82,12 @@ done # Build a space-separated string of changed PHP and lang files # This does not cope with files that have spaces. + # But such files do not exist in the project (at least not for the # files we are filtering). all_changed_files=$(IFS=" " ; echo "${changed_php_files[*]}") all_changed_lang=$(IFS=" " ; echo "${changed_lang_files[*]}") +phan_changed_files=$(IFS=" " ; echo "${changed_phan_files[*]}") forbidden_files="" @@ -85,21 +100,31 @@ forbidden_files="" #fi -# Determine changed files flag -if [ -z "$all_changed_files" ]; then +# Determine changed files flags +if [ -z "${all_changed_files}" ]; then any_changed="false" else any_changed="true" fi +if [ -z "${phan_changed_files}" ]; then + phan_changed="false" +else + phan_changed="true" +fi + # Set outputs for GitHub Actions if GITHUB_OUTPUT is available if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "any_changed=${any_changed}" >> "$GITHUB_OUTPUT" + echo "phan_changed=${phan_changed}" >> "$GITHUB_OUTPUT" echo "all_changed_files=${all_changed_files}" >> "$GITHUB_OUTPUT" + echo "phan_changed_files=${phan_changed_files[*]}" >> "$GITHUB_OUTPUT" echo "forbidden_files=${forbidden_files}" >> "$GITHUB_OUTPUT" else # Otherwise, print the outputs echo "any_changed=${any_changed}" + echo "phan_changed=${phan_changed}" + echo "phan_changed_files=${phan_changed_files[*]}" echo "all_changed_files=${all_changed_files}" echo "forbidden_files=${forbidden_files}" fi diff --git a/.github/workflows/phan.yml b/.github/workflows/phan.yml index 3547d3e1df5..9acdbfa17b7 100644 --- a/.github/workflows/phan.yml +++ b/.github/workflows/phan.yml @@ -61,9 +61,10 @@ jobs: # - the merge from branch contains 'phan_full', or, # - there are changes in PHP files. # Note: --output-mode=github does not provide file:line, so using checkstyle and cs2pr - if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || endsWith(github.ref_name, '.0') || contains(github.head_ref, 'phan_full') || steps.changed-php.outputs.any_changed == 'true') }} + if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || endsWith(github.ref_name, '.0') || contains(github.head_ref, 'phan_full') || steps.changed-php.outputs.phan_changed == 'true') }} env: ALL_CHANGED_FILES: ${{ steps.changed-php.outputs.all_changed_files }} + PHAN_CHANGED_FILES: ${{ steps.changed-php.outputs.phan_changed_files }} # shellcheck disable=2086 FILE_CHANGED_LIST: /tmp/phan-changed.lst run: | @@ -73,7 +74,7 @@ jobs: phan $PHAN_QUICK -k "$PHAN_CONFIG" -B "$PHAN_BASELINE" --analyze-twice --minimum-target-php-version "$PHAN_MIN_PHP" --output-mode=checkstyle -o _phan.xml else echo -n "" > "$FILE_CHANGED_LIST" - for f in $ALL_CHANGED_FILES; do echo "$f" >> "$FILE_CHANGED_LIST"; done + for f in $PHAN_CHANGED_FILES; do echo "$f" >> "$FILE_CHANGED_LIST"; done # Must exclude same files as in phan configuration grep -v -E '^htdocs/(custom/|.*/canvas/.*/tpl/.*.tpl.php|admin/tools/ui/|includes/(nusoap/|restler/|stripe/)|conf/conf.php)$' "$FILE_CHANGED_LIST"> "$FILE_CHANGED_LIST".tmp || true mv "$FILE_CHANGED_LIST".tmp "$FILE_CHANGED_LIST" diff --git a/dev/setup/codesniffer/Sniffs/Dolibarr/CheckIsModEnabledArgumentSniff.php b/dev/setup/codesniffer/Sniffs/Dolibarr/CheckIsModEnabledArgumentSniff.php index 0f5c435a9a8..28d260f60e2 100644 --- a/dev/setup/codesniffer/Sniffs/Dolibarr/CheckIsModEnabledArgumentSniff.php +++ b/dev/setup/codesniffer/Sniffs/Dolibarr/CheckIsModEnabledArgumentSniff.php @@ -1,5 +1,6 @@ + * Copyright (C) 2026 MDW * * 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 @@ -25,7 +26,7 @@ use PHP_CodeSniffer\Files\File; */ class CheckIsModEnabledArgumentSniff implements Sniff { - // Nom de la fonction cible + // Target function name protected $targetFunction = 'ismodenabled'; protected $deprecatedModulesNames = [ @@ -87,7 +88,7 @@ class CheckIsModEnabledArgumentSniff implements Sniff return; } - // check value of argument + // Check argument value $argContent = str_replace(["'", '"'], '', $tokens[$firstArgTokenPtr]['content']); $argCode = $tokens[$firstArgTokenPtr]['code']; diff --git a/dev/setup/codesniffer/Sniffs/Dolibarr/LanguageOfCommentsSniff.php b/dev/setup/codesniffer/Sniffs/Dolibarr/LanguageOfCommentsSniff.php index 2b656adad0b..fc7fb016d6f 100644 --- a/dev/setup/codesniffer/Sniffs/Dolibarr/LanguageOfCommentsSniff.php +++ b/dev/setup/codesniffer/Sniffs/Dolibarr/LanguageOfCommentsSniff.php @@ -1,5 +1,6 @@ + * Copyright (C) 2026 MDW * * 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 @@ -14,6 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + namespace codesniffer\Sniffs\Dolibarr; use PHP_CodeSniffer\Sniffs\Sniff; @@ -108,7 +110,9 @@ class LanguageOfCommentsSniff implements Sniff return; } - if (strpos($content, 'Copyright') === false && preg_match('/[àâäéèêëîïôùûüçœÀÂÄÉÈÊËÎÏÔÙÛÜÇŒ]/u', $content)) { + // Avoid matching special characters directly preceded or followed with []'" + // Avoid matching special characters in a "word/identifier" like string with '_'. + if (strpos($content, 'Copyright') === false && preg_match('/(?\/\'"\[])[àâäéèêëîïôùûüçœÀÂÄÉÈÊËÎÏÔÙÛÜÇŒ]+(?![\'"\[]\/)(?![<>\/\'"\[])(?!\w*_)/u', $content)) { $error = "The comment appears to be in French (accent detected in: '%s'). Please write in English."; $data = [trim($content)]; $phpcsFile->addWarning($error, $stackPtr, 'FrenchDetected', $data); diff --git a/dev/tools/rector/src/Renaming/GlobalToFunction.php b/dev/tools/rector/src/Renaming/GlobalToFunction.php index 16c4b658c29..cabacc2359c 100644 --- a/dev/tools/rector/src/Renaming/GlobalToFunction.php +++ b/dev/tools/rector/src/Renaming/GlobalToFunction.php @@ -1,4 +1,6 @@ + */ namespace Dolibarr\Rector\Renaming; @@ -230,7 +232,7 @@ class GlobalToFunction extends AbstractRector // If a && b and a or b is conf->global, we replace a or/and b if ($node instanceof BooleanAnd) { - // Transformation sur le nœud gauche + // Transformation on the left node $changedone = 0; if ($this->isGlobalVar($node->left)) { $constName = $this->getConstName($node->left); diff --git a/htdocs/admin/external_rss.php b/htdocs/admin/external_rss.php index 17e8602a06a..0b01aef5a18 100644 --- a/htdocs/admin/external_rss.php +++ b/htdocs/admin/external_rss.php @@ -27,7 +27,7 @@ /** * \file htdocs/admin/external_rss.php * \ingroup external_rss - * \brief Page to setupe module ExternalRss + * \brief Page to setup the ExternalRss module */ // Load Dolibarr environment @@ -89,7 +89,7 @@ if ($action == 'add' || GETPOST("modify")) { $db->begin(); if (GETPOST("modify")) { - // Supprime boite box_external_rss de definition des boites + // Remove box_external_rss from the definition of boxes /* $sql = "UPDATE ".MAIN_DB_PREFIX."boxes_def"; $sql.= " SET name = '".$db->escape($boxlabel)."'"; $sql.= " WHERE file ='box_external_rss.php' AND note LIKE '".$db->escape(GETPOST("norss"))." %'"; @@ -102,7 +102,7 @@ if ($action == 'add' || GETPOST("modify")) { } */ } else { - // Ajoute boite box_external_rss dans definition des boites + // Add box_external_rss to the definition of boxes $sql = "INSERT INTO ".MAIN_DB_PREFIX."boxes_def (file, note)"; $sql .= " VALUES ('box_external_rss.php', '".$db->escape(GETPOSTINT("norss")." (".GETPOST($external_rss_title)).")')"; if (!$db->query($sql)) { @@ -203,7 +203,7 @@ $linkback = ''; -// Formulaire ajout +// From to add print '
'; print ''; @@ -358,7 +358,7 @@ llxFooter(); $db->close(); /** - * Check if the given RSS feed if inside the list of boxes/widgets + * Check if the given RSS feed is inside the list of boxes/widgets * * @param int $idrss The id of the RSS feed * @param ModeleBoxes[] $boxlist A list with boxes/widgets diff --git a/htdocs/admin/mails_emailing.php b/htdocs/admin/mails_emailing.php index 4f914758326..766cc7739bc 100644 --- a/htdocs/admin/mails_emailing.php +++ b/htdocs/admin/mails_emailing.php @@ -4,7 +4,7 @@ * Copyright (C) 2013 Juanjo Menent * Copyright (C) 2016 Jonathan TISSEAU * Copyright (C) 2024-2025 Frédéric France - * Copyright (C) 2025 MDW + * Copyright (C) 2025-2026 MDW * * 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 @@ -22,7 +22,7 @@ /** * \file htdocs/admin/mails.php - * \brief Page to setup emails sending + * \brief Email setup page */ // Load Dolibarr environment @@ -798,7 +798,7 @@ if ($action == 'edit') { print dol_get_fiche_head(array(), '', '', -1); - // Cree l'objet formulaire mail + // Create the mail form object include_once DOL_DOCUMENT_ROOT.'/core/class/html.formmail.class.php'; $formmail = new FormMail($db); $formmail->trackid = (($action == 'testhtml') ? "testhtml" : "test"); diff --git a/htdocs/admin/mails_passwordreset.php b/htdocs/admin/mails_passwordreset.php index 511e64074d5..85105506afb 100644 --- a/htdocs/admin/mails_passwordreset.php +++ b/htdocs/admin/mails_passwordreset.php @@ -4,7 +4,7 @@ * Copyright (C) 2013 Juanjo Menent * Copyright (C) 2016 Jonathan TISSEAU * Copyright (C) 2024-2025 Frédéric France - * Copyright (C) 2025 MDW + * Copyright (C) 2025-2026 MDW * * 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 @@ -849,7 +849,7 @@ if ($action == 'edit') { print dol_get_fiche_head(array(), '', '', -1); - // Cree l'objet formulaire mail + // Create the email form object include_once DOL_DOCUMENT_ROOT.'/core/class/html.formmail.class.php'; $formmail = new FormMail($db); $formmail->fromname = (GETPOSTISSET('fromname') ? GETPOST('fromname', 'restricthtml') : getDolGlobalString('MAIN_MAIL_EMAIL_FROM')); diff --git a/htdocs/admin/mails_ticket.php b/htdocs/admin/mails_ticket.php index 91e57a9475d..5942f3ef25c 100644 --- a/htdocs/admin/mails_ticket.php +++ b/htdocs/admin/mails_ticket.php @@ -4,7 +4,7 @@ * Copyright (C) 2013 Juanjo Menent * Copyright (C) 2016 Jonathan TISSEAU * Copyright (C) 2024-2025 Frédéric France - * Copyright (C) 2025 MDW + * Copyright (C) 2025-2026 MDW * * 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 @@ -794,7 +794,7 @@ if ($action == 'edit') { print dol_get_fiche_head(array(), '', '', -1); - // Cree l'objet formulaire mail + // Create the mail form object include_once DOL_DOCUMENT_ROOT.'/core/class/html.formmail.class.php'; $formmail = new FormMail($db); $formmail->fromname = (GETPOSTISSET('fromname') ? GETPOST('fromname', 'restricthtml') : getDolGlobalString('MAIN_MAIL_EMAIL_FROM')); diff --git a/htdocs/admin/sms.php b/htdocs/admin/sms.php index e4c6d73a0a0..e2c5e2da769 100644 --- a/htdocs/admin/sms.php +++ b/htdocs/admin/sms.php @@ -3,6 +3,7 @@ * Copyright (C) 2009 Regis Houssin * Copyright (C) 2013 Juanjo Menent * Copyright (C) 2020-2025 Frédéric France + * Copyright (C) 2026 MDW * * 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 @@ -20,7 +21,7 @@ /** * \file htdocs/admin/sms.php - * \brief Page to setup emails sending + * \brief Page to set up SMS sending */ // Load Dolibarr environment @@ -267,12 +268,12 @@ if ($action == 'edit') { print ''; - // Affichage formulaire de TEST simple + // Display simple test form if ($action == 'test') { print '
'; print load_fiche_titre($langs->trans("DoTestSend")); - // Cree l'objet formulaire mail + // Create SMS form object include_once DOL_DOCUMENT_ROOT.'/core/class/html.formsms.class.php'; $formsms = new FormSms($db); $formsms->fromtype = 'user'; diff --git a/htdocs/categories/photos.php b/htdocs/categories/photos.php index f4fa2c44dd8..3a82bd2e795 100644 --- a/htdocs/categories/photos.php +++ b/htdocs/categories/photos.php @@ -149,7 +149,7 @@ if ($object->id) { dol_banner_tab($object, 'label', $linkback, ($user->socid ? 0 : 1), 'label', 'label', $morehtmlref, '&type='.$type, 0, '', '', 1); /* - * Confirmation deletion of picture + * Confirm photo deletion */ if ($action == 'delete') { print $form->formconfirm($_SERVER["PHP_SELF"].'?id='.$object->id.'&type='.urlencode($type).'&file='.urlencode(GETPOST("file")), $langs->trans('DeletePicture'), $langs->trans('ConfirmDeletePicture'), 'confirm_delete', '', 0, 1); @@ -204,15 +204,15 @@ if ($object->id) { print ''."\n"; /* - * Ajouter une photo + * Add a photo */ if ($action == 'ajout_photo' && $user->hasRight('categorie', 'creer') && getDolGlobalString('MAIN_UPLOAD_DOC')) { - // Affiche formulaire upload + // Display upload form $formfile = new FormFile($db); $formfile->form_attach_new_file($_SERVER['PHP_SELF'].'?id='.$object->id.'&type='.$type, $langs->trans("AddPhoto"), 1, 0, $user->hasRight('categorie', 'creer'), 50, $object, '', 0, '', 0); } - // Affiche photos + // Display photos if ($action != 'ajout_photo') { $nbphoto = 0; $nbbyrow = 5; @@ -262,7 +262,7 @@ if ($object->id) { print '
'.$viewfilename; print '
'; - // On propose la generation de la vignette si elle n'existe pas et si la taille est superieure aux limites + // Suggest generation of the thumbnail if it doesn't exist and if the size exceeds the limits if (!$obj['photo_vignette'] && preg_match('/(\.bmp|\.gif|\.jpg|\.jpeg|\.png)$/i', $obj['photo']) && ($object->imgWidth > $maxWidth || $object->imgHeight > $maxHeight)) { print '
'.img_picto($langs->trans('GenerateThumb'), 'refresh').'  '; } diff --git a/htdocs/commande/class/commande.class.php b/htdocs/commande/class/commande.class.php index 65f7516e323..f58dbd84533 100644 --- a/htdocs/commande/class/commande.class.php +++ b/htdocs/commande/class/commande.class.php @@ -1910,7 +1910,7 @@ class Commande extends CommonOrder * @param int|'' $date_end End date of the line * @return void * - * TODO Remplacer les appels a cette fonction par generation object Ligne + * TODO Replace calls to this function by generation of the `Ligne` (line) object */ public function add_product($idproduct, $qty, $remise_percent = 0.0, $date_start = '', $date_end = '') { diff --git a/htdocs/compta/paiement/rapport.php b/htdocs/compta/paiement/rapport.php index 293c8c4f668..9bf22d47a3e 100644 --- a/htdocs/compta/paiement/rapport.php +++ b/htdocs/compta/paiement/rapport.php @@ -122,7 +122,7 @@ llxHeader(); $titre = ($year ? $langs->trans("PaymentsReportsForYear", $year) : $langs->trans("PaymentsReports")); print load_fiche_titre($titre, '', 'bill'); -// Formulaire de generation +// Generation form print ''; print ''; print ''; diff --git a/htdocs/compta/paiement_vat.php b/htdocs/compta/paiement_vat.php index 38c4c15a874..b7afbb06ba5 100644 --- a/htdocs/compta/paiement_vat.php +++ b/htdocs/compta/paiement_vat.php @@ -2,7 +2,7 @@ /* Copyright (C) 2004-2014 Laurent Destailleur * Copyright (C) 2016-2025 Frédéric France * Copyright (C) 2021 Gauthier VERDOL - * Copyright (C) 2024 MDW + * Copyright (C) 2024-2026 MDW * * 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 @@ -158,7 +158,7 @@ llxHeader(); $form = new Form($db); -// Formulaire de creation d'un paiement de charge +// Payment charge payment form if ($action == 'create') { $tva = new Tva($db); diff --git a/htdocs/compta/stats/index.php b/htdocs/compta/stats/index.php index 0305f4aabd5..1197cf9646f 100644 --- a/htdocs/compta/stats/index.php +++ b/htdocs/compta/stats/index.php @@ -267,8 +267,8 @@ if ($modecompta == 'CREANCES-DETTES') { } } elseif ($modecompta == "RECETTES-DEPENSES") { /* - * Liste des paiements (les anciens paiements ne sont pas vus par cette requete car, sur les - * vieilles versions, ils n'etaient pas lies via paiement_facture. On les ajoute plus loin) + * List of payments (old payments are not seen by this query because, on old + * versions, they were not linked via paiement_facture. We add them later) */ $sql = "SELECT date_format(p.datep, '%Y-%m') as dm, sum(pf.amount) as amount_ttc"; $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; @@ -733,7 +733,7 @@ print ''; $i++; } - print "Invoice a encaisser : ".price($total_ttc_Rac)."<-- bug ici car n'exclut pas le deja réglé des invoices partiellement réglées"; + print "Invoices to collect: ".price($total_ttc_Rac)."<-- bug here because it does not exclude already paid partially settled invoices"; } $db->free($resql); } @@ -745,7 +745,7 @@ print ''; /* * - * Propales signees, et non facturees + * Signed but not invoiced proposals * */ @@ -782,7 +782,7 @@ print ''; $i++; } - print "Signe et non facture:".price($total_pr)."<-- bug ici, ca devrait exclure le deja facture"; + print "Signe et non facture:".price($total_pr)."<-- g here, it should exclude already invoiced amount>"; } $db->free($resql); } @@ -790,7 +790,7 @@ print ''; { dol_print_error($db); } - print "Total CA previsionnel : ".price($total_CA)."<-- bug ici car bug sur les 2 precedents"; + print "Total CA previsionnel : ".price($total_CA)."<-- bug here because bug on two previous"; } print ""; diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index 6e7fff8de91..2a205108f84 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -6810,7 +6810,7 @@ abstract class CommonObject if (empty($extrafields->attributes[$this->table_element]['type'][$name]) || (!in_array($extrafields->attributes[$this->table_element]['type'][$name], ['separate', 'point', 'multipts', 'linestrg','polygon']))) { $sql .= ", ".$this->db->sanitize($name); } - // use geo sql fonction to read as text + // use geo sql function to read as text if (!empty($extrafields->attributes[$this->table_element]['type'][$name]) && in_array($extrafields->attributes[$this->table_element]['type'][$name], array('point', 'multipts', 'linestrg', 'polygon'))) { // TODO Add an abstraction method in the database driver $sql .= ", ST_AsWKT(".$name.") as ".$this->db->sanitize($name); diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php index 9b23677050a..525e6e1bbd0 100644 --- a/htdocs/core/class/html.form.class.php +++ b/htdocs/core/class/html.form.class.php @@ -4878,11 +4878,11 @@ class Form // Example of var_dump $outarray // array(1) {[0]=>array(6) {[key"]=>string(1) "2" ["value"]=>string(3) "ppp" - // ["label"]=>string(76) "ppp (fff2) - ppp - 20,00 Euros/1unité (20,00 Euros/unité)" + // ["label"]=>string(76) "ppp (fff2) - ppp - 20,00 Euros/unit (20,00 Euros/unit)" // ["qty"]=>string(1) "1" ["discount"]=>string(1) "0" ["disabled"]=>bool(false) //} //var_dump($outval); var_dump(utf8_check($outval)); var_dump(json_encode($outval)); - //$outval=array('label'=>'ppp (fff2) - ppp - 20,00 Euros/ Unité (20,00 Euros/unité)'); + //$outval=array('label'=>'ppp (fff2) - ppp - 20,00 Euros/ Unit (20,00 Euros/unit)'); //var_dump($outval); var_dump(utf8_check($outval)); var_dump(json_encode($outval)); $i++; diff --git a/htdocs/core/class/html.formbarcode.class.php b/htdocs/core/class/html.formbarcode.class.php index 9a6023e6a96..4ad533efa6c 100644 --- a/htdocs/core/class/html.formbarcode.class.php +++ b/htdocs/core/class/html.formbarcode.class.php @@ -185,7 +185,7 @@ class FormBarCode * * @param string $page Page * @param int $selected Id condition preselected - * @param string $htmlname Nom du formulaire select + * @param string $htmlname Name of the select form * @return void * @deprecated */ @@ -200,7 +200,7 @@ class FormBarCode * * @param string $page Page * @param int $selected Id condition preselected - * @param string $htmlname Nom du formulaire select + * @param string $htmlname Name of the select form * @return string */ public function formBarcodeType($page, $selected = 0, $htmlname = 'barcodetype_id') diff --git a/htdocs/core/class/html.formcompany.class.php b/htdocs/core/class/html.formcompany.class.php index 9e2c7c2de0f..d3aeb71c8b8 100644 --- a/htdocs/core/class/html.formcompany.class.php +++ b/htdocs/core/class/html.formcompany.class.php @@ -153,11 +153,11 @@ class FormCompany extends Form // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Affiche formulaire de selection des modes de reglement + * Displays form for selecting settlement methods * * @param string $page Page * @param int $selected Id or code preselected - * @param string $htmlname Nom du formulaire select + * @param string $htmlname Name of the select form * @param int $empty Add empty value in list * @return void */ @@ -414,10 +414,10 @@ class FormCompany extends Form // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Provides the dropdown of the active regions including the actif country. + * Provides the dropdown of the active regions including the active country. * The key of the list is the code (there may be more than one entry for a * code but in that case the fields country and language are different). - * un code donnee mais dans ce cas, le champ pays et lang differe). + * a given code but in this case, the country and language field differs). * This way the links with the regions are made independent of its name. * * @param string $selected Preselected value @@ -1186,7 +1186,7 @@ class FormCompany extends Form * @param Contact|Client|null $prospectstatic Prospect object * @param int $statusprospect status of prospect * @param int $idprospect id of prospect - * @param 'html'|'js' $mode select if we want activate de html part or js + * @param 'html'|'js' $mode select whether to activate the HTML part or JS * @return void */ public function selectProspectStatus($htmlname, $prospectstatic, $statusprospect, $idprospect, $mode = "html") diff --git a/htdocs/core/class/html.formsms.class.php b/htdocs/core/class/html.formsms.class.php index 15336ce4f70..7d6953902d3 100644 --- a/htdocs/core/class/html.formsms.class.php +++ b/htdocs/core/class/html.formsms.class.php @@ -27,10 +27,10 @@ require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; /** - * Class permettant la generation du formulaire d'envoi de Sms + * Class for generating the SMS sending form * Usage: $formsms = new FormSms($db) * $formsms->proprietes=1 or string or table of values - * $formsms->show_form() affiche le formulaire + * $formsms->show_form() displays the form */ class FormSms { diff --git a/htdocs/core/datepicker.php b/htdocs/core/datepicker.php index 13af821dbc6..db7b77d46dc 100644 --- a/htdocs/core/datepicker.php +++ b/htdocs/core/datepicker.php @@ -3,7 +3,7 @@ * Copyright (C) 2005-2010 Laurent Destailleur * Copyright (C) 2005-2007 Regis Houssin * Copyright (C) 2014 Juanjo Menent - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 Frédéric France * * This file is a modified version of datepicker.php from phpBSM to fix some @@ -144,7 +144,7 @@ if ($qualified) { print ''."\n"; /** - * Convert date to timestamp + * Convert date string to a Unix timestamp * * @param string $mysqldate Date YYYMMDD * @return integer Timestamp @@ -160,7 +160,7 @@ function xyzToUnixTimestamp($mysqldate) } /** - * Show box + * Displays the date picker box * * @param string $selectedDate Date YYYYMMDD * @param int $month Month @@ -274,7 +274,7 @@ function displayBox($selectedDate, $month, $year) exit; } - // Sur click dans calendrier, appelle fonction dpClickDay + // Upon a click in the calendar, call dpClickDay function echo "trans("FormatDateShortJavaInput")."')\""; diff --git a/htdocs/core/db/mysqli.class.php b/htdocs/core/db/mysqli.class.php index d89336a14e8..c002fffd332 100644 --- a/htdocs/core/db/mysqli.class.php +++ b/htdocs/core/db/mysqli.class.php @@ -227,7 +227,7 @@ class DoliDBMysqli extends DoliDB * Select a database * * @param string $database Name of database - * @return boolean true if OK, false if KO + * @return boolean true if OK, false if failed */ public function select_db($database) { @@ -365,7 +365,7 @@ class DoliDBMysqli extends DoliDB } if (!preg_match("/^COMMIT/i", $query) && !preg_match("/^ROLLBACK/i", $query)) { - // Si requete utilisateur, on la sauvegarde ainsi que son resultset + // If user query, we save it along with its resultset if (!$ret) { $this->lastqueryerror = $query; $this->lasterror = $this->error(); @@ -412,7 +412,7 @@ class DoliDBMysqli extends DoliDB /** * Returns the current line (as an object) for the resultset cursor * - * @param mysqli_result $resultset Curseur de la requete voulue + * @param mysqli_result $resultset Cursor of the desired query * @return object|null Object result line or null if KO or end of cursor */ public function fetch_object($resultset) @@ -469,8 +469,8 @@ class DoliDBMysqli extends DoliDB /** * Return number of lines for result of a SELECT * - * @param mysqli_result $resultset Resulset of requests - * @return int Nb of lines + * @param mysqli_result $resultset Resultset of requests + * @return int Number of lines * @see affected_rows() */ public function num_rows($resultset) @@ -487,7 +487,7 @@ class DoliDBMysqli extends DoliDB /** * Return the number of lines in the result of a request INSERT, DELETE or UPDATE * - * @param mysqli_result $resultset Curseur de la requete voulue + * @param mysqli_result $resultset Cursor of the desired query * @return int Number of lines * @see num_rows() */ @@ -498,8 +498,7 @@ class DoliDBMysqli extends DoliDB if (!is_object($resultset)) { $resultset = $this->_results; } - // mysql necessite un link de base pour cette fonction contrairement - // a pqsql qui prend un resultset + // mysql require a db link, not like pqsql that takes a resultset return $this->db->affected_rows; } @@ -515,7 +514,7 @@ class DoliDBMysqli extends DoliDB if (!is_object($resultset)) { $resultset = $this->_results; } - // Si resultset en est un, on libere la memoire + // Si resultset is provided, free memory if (is_object($resultset)) { $resultset->free_result(); } @@ -604,7 +603,7 @@ class DoliDBMysqli extends DoliDB public function error() { if (!$this->connected) { - // Si il y a eu echec de connection, $this->db n'est pas valide pour mysqli_error. + // When there is a connection failure, $this->db is invalid for to get mysqli_error. return 'Not connected. Check setup parameters in conf/conf.php file and your mysql client and server versions'; } else { return $this->db->error; @@ -837,12 +836,12 @@ class DoliDBMysqli extends DoliDB * Create a table into database * * @param string $table Name of table - * @param array|string,position?:int,notnull?:int,visible?:int<-2,5>|string,alwayseditable?:int<0,1>,noteditable?:int<0,1>,default?:string,index?:int,foreignkey?:string,searchall?:int<0,1>,isameasure?:int<0,1>,css?:string,csslist?:string,help?:string,showoncombobox?:int<0,2>,disabled?:int<0,1>,arrayofkeyval?:array,autofocusoncreate?:int<0,1>,comment?:string,copytoclipboard?:int<1,2>,validate?:int<0,1>}> $fields Tableau associatif [nom champ][tableau des descriptions] - * @param string $primary_key Nom du champ qui sera la clef primaire - * @param string $type Type de la table - * @param ?array $unique_keys Tableau associatifs Nom de champs qui seront clef unique => valeur - * @param string[] $fulltext_keys Tableau des Nom de champs qui seront indexes en fulltext - * @param array $keys Tableau des champs cles noms => valeur + * @param array|string,position?:int,notnull?:int,visible?:int<-2,5>|string,alwayseditable?:int<0,1>,noteditable?:int<0,1>,default?:string,index?:int,foreignkey?:string,searchall?:int<0,1>,isameasure?:int<0,1>,css?:string,csslist?:string,help?:string,showoncombobox?:int<0,2>,disabled?:int<0,1>,arrayofkeyval?:array,autofocusoncreate?:int<0,1>,comment?:string,copytoclipboard?:int<1,2>,validate?:int<0,1>}> $fields Associative table [field name][table of descriptions] + * @param string $primary_key Name of the field that will be the primary key + * @param string $type Table type + * @param ?array $unique_keys Associative table: key=field, value=field value + * @param string[] $fulltext_keys Table of fields that will be indexed as fulltext + * @param array $keys Table of fields names => value * @return int Return integer <0 if KO, >=0 if OK */ public function DDLCreateTable($table, $fields, $primary_key, $type, $unique_keys = null, $fulltext_keys = null, $keys = null) @@ -955,7 +954,7 @@ class DoliDBMysqli extends DoliDB * Return a pointer of line with description of a table or field * * @param string $table Name of table - * @param string $field Optionnel : Name of field if we want description of field + * @param string $field Optional: Name of field if we want description of field * @return bool|mysqli_result Resultset x (x->Field, x->Type, ...) */ public function DDLDescTable($table, $field = "") @@ -1106,7 +1105,7 @@ class DoliDBMysqli extends DoliDB * Create a user and privileges to connect to database (even if database does not exists yet) * * @param string $dolibarr_main_db_host Ip server or '%' - * @param string $dolibarr_main_db_user Nom new user + * @param string $dolibarr_main_db_user Name of new user * @param string $dolibarr_main_db_pass Password for the new user * @param string $dolibarr_main_db_name Database name where user must be granted * @return int Return integer <0 if KO, >=0 if OK diff --git a/htdocs/core/db/pgsql.class.php b/htdocs/core/db/pgsql.class.php index 4a24bc0b8af..a0b8109dc94 100644 --- a/htdocs/core/db/pgsql.class.php +++ b/htdocs/core/db/pgsql.class.php @@ -27,7 +27,7 @@ /** * \file htdocs/core/db/pgsql.class.php - * \brief Fichier de la class permettant de gerer une base pgsql + * \brief Class file for managing a pgsql database */ require_once DOL_DOCUMENT_ROOT.'/core/db/DoliDB.class.php'; @@ -46,7 +46,7 @@ class DoliDBPgsql extends DoliDB //! Charset public $forcecharset = 'UTF8'; // Can't be static as it may be forced with a dynamic value - //! Collate used to force collate when creating database + //! Collation used to force collate when creating database public $forcecollate = ''; // Can't be static as it may be forced with a dynamic value //! Version min database @@ -71,13 +71,13 @@ class DoliDBPgsql extends DoliDB /** * Constructor. - * This create an opened connection to a database server and eventually to a database + * This creates an opened connection to a database server and eventually to a database * * @param string $type Type of database (mysql, pgsql...). Not used. * @param string $host Address of database server - * @param string $user Nom de l'utilisateur autorise + * @param string $user Authorized user name * @param string $pass Password - * @param string $name Nom de la database + * @param string $name Database name * @param int $port Port of database server */ public function __construct($type, $host, $user, $pass, $name = '', $port = 0) // @phpstan-ignore constructor.unusedParameter @@ -112,11 +112,11 @@ class DoliDBPgsql extends DoliDB $this->connected = false; $this->ok = false; $this->error = $langs->trans("ErrorWrongHostParameter"); - dol_syslog(get_class($this)."::DoliDBPgsql : Erreur Connect, wrong host parameters", LOG_ERR); + dol_syslog(get_class($this)."::DoliDBPgsql : Connection Error, wrong host parameters", LOG_ERR); return; } - // Essai connection serveur + // Try server connection //print "$host, $user, $pass, $name, $port"; $this->db = $this->connect($host, $user, $pass, $name, $port); @@ -128,10 +128,10 @@ class DoliDBPgsql extends DoliDB $this->connected = false; $this->ok = false; $this->error = 'Host, login or password incorrect'; - dol_syslog(get_class($this)."::DoliDBPgsql : Erreur Connect ".$this->error.'. Failed to connect to host='.$host.' port='.$port.' user='.$user, LOG_ERR); + dol_syslog(get_class($this)."::DoliDBPgsql : Connection Error ".$this->error.'. Failed to connect to host='.$host.' port='.$port.' user='.$user, LOG_ERR); } - // If server connection serveur ok and DB connection is requested, try to connect to DB + // If server connection ok and DB connection is requested, try to connect to DB if ($this->connected && $name) { if ($this->select_db($name)) { $this->database_selected = true; @@ -142,10 +142,10 @@ class DoliDBPgsql extends DoliDB $this->database_name = ''; $this->ok = false; $this->error = $this->error(); - dol_syslog(get_class($this)."::DoliDBPgsql : Erreur Select_db ".$this->error, LOG_ERR); + dol_syslog(get_class($this)."::DoliDBPgsql : Select_db Error ".$this->error, LOG_ERR); } } else { - // Pas de selection de base demandee, ok ou ko + // No database selection requested, ok or ko $this->database_selected = false; } } @@ -592,7 +592,7 @@ class DoliDBPgsql extends DoliDB /** * Returns the current line (as an object) for the resultset cursor * - * @param resource $resultset Curseur de la requete voulue + * @param resource $resultset Cursor of the db request * @return false|object Object result line or false if KO or end of cursor */ public function fetch_object($resultset) @@ -607,7 +607,7 @@ class DoliDBPgsql extends DoliDB // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Return datas as an array + * Return data as an array * * @param resource $resultset Resultset of request * @return array|null|false Array or null if KO or end of cursor @@ -633,7 +633,7 @@ class DoliDBPgsql extends DoliDB public function fetch_row($resultset) { // phpcs:enable - // Si le resultset n'est pas fourni, on prend le dernier utilise sur cette connection + // If resultset not provided, we take the last used by connection if (!is_resource($resultset) && !is_object($resultset)) { $resultset = $this->_results; } @@ -668,10 +668,10 @@ class DoliDBPgsql extends DoliDB // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Return the number of lines in the result of a request INSERT, DELETE or UPDATE + * Return the number of rows in the result of a request INSERT, DELETE or UPDATE * * @param resource $resultset Result set of request - * @return int Nb of lines + * @return int Number of lines * @see num_rows() */ public function affected_rows($resultset) @@ -681,8 +681,8 @@ class DoliDBPgsql extends DoliDB if (!is_resource($resultset) && !is_object($resultset)) { $resultset = $this->_results; } - // pgsql necessite un resultset pour cette fonction contrairement - // a mysql qui prend un link de base + // pgsql requires a resultset for this function contrary to + // mysql that requires a database link return pg_affected_rows($resultset); } @@ -699,7 +699,7 @@ class DoliDBPgsql extends DoliDB if (!is_resource($resultset) && !is_object($resultset)) { $resultset = $this->_results; } - // Si resultset en est un, on libere la memoire + // If it is a resource, we free the memory if (is_resource($resultset) || is_object($resultset)) { pg_free_result($resultset); } @@ -1071,13 +1071,13 @@ class DoliDBPgsql extends DoliDB /** * Create a table into database * - * @param string $table Nom de la table - * @param array|string,position?:int,notnull?:int,visible?:int<-2,5>|string,alwayseditable?:int<0,1>,noteditable?:int<0,1>,default?:string,index?:int,foreignkey?:string,searchall?:int<0,1>,isameasure?:int<0,1>,css?:string,csslist?:string,help?:string,showoncombobox?:int<0,2>,disabled?:int<0,1>,arrayofkeyval?:array,autofocusoncreate?:int<0,1>,comment?:string,copytoclipboard?:int<1,2>,validate?:int<0,1>}> $fields Tableau associatif [nom champ][tableau des descriptions] - * @param string $primary_key Nom du champ qui sera la clef primaire + * @param string $table Table name + * @param array|string,position?:int,notnull?:int,visible?:int<-2,5>|string,alwayseditable?:int<0,1>,noteditable?:int<0,1>,default?:string,index?:int,foreignkey?:string,searchall?:int<0,1>,isameasure?:int<0,1>,css?:string,csslist?:string,help?:string,showoncombobox?:int<0,2>,disabled?:int<0,1>,arrayofkeyval?:array,autofocusoncreate?:int<0,1>,comment?:string,copytoclipboard?:int<1,2>,validate?:int<0,1>}> $fields Associative table [field name][table of descrioptions] + * @param string $primary_key Name of the field that will be the primary key * @param string $type Type de la table - * @param ?array $unique_keys Tableau associatifs Nom de champs qui seront clef unique => valeur - * @param string[] $fulltext_keys Tableau des Nom de champs qui seront indexes en fulltext - * @param array $keys Tableau des champs cles noms => valeur + * @param ?array $unique_keys Associative table - name of fields that will be unique key => value + * @param string[] $fulltext_keys Tableau des Name of fields that will be indexed in fulltext + * @param array $keys Table of key fields - names => value * @return int Return integer <0 if KO, >=0 if OK */ public function DDLCreateTable($table, $fields, $primary_key, $type, $unique_keys = null, $fulltext_keys = null, $keys = null) @@ -1445,7 +1445,7 @@ class DoliDBPgsql extends DoliDB if (file_exists('/usr/bin/pg_dump')) { $fullpathofdump = '/usr/bin/pg_dump'; } else { - // TODO L'utilisateur de la base doit etre un superadmin pour lancer cette commande + // TODO The database user must be a superadmin to run this command $resql = $this->query('SHOW data_directory'); if ($resql) { $liste = $this->fetch_array($resql); @@ -1458,9 +1458,9 @@ class DoliDBPgsql extends DoliDB } /** - * Return full path of restore program + * Return full path of restore program * - * @return string Full path of restore program + * @return string Full path of restore program */ public function getPathOfRestore() { diff --git a/htdocs/core/lib/xcal.lib.php b/htdocs/core/lib/xcal.lib.php index 7508e650082..329cb14ae78 100644 --- a/htdocs/core/lib/xcal.lib.php +++ b/htdocs/core/lib/xcal.lib.php @@ -1,6 +1,6 @@ - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * * 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 @@ -123,7 +123,7 @@ function build_calfile($format, $title, $desc, $events_array, $outputfile) LOCATION: SEQUENCE:0 STATUS:CONFIRMED - SUMMARY:Tâche 1 heure + SUMMARY:Task 1 hour TRANSP:OPAQUE END:VEVENT @@ -139,7 +139,7 @@ function build_calfile($format, $title, $desc, $events_array, $outputfile) LOCATION: SEQUENCE:0 STATUS:CONFIRMED - SUMMARY:Tâche 1 jour + SUMMARY:Task 1 day TRANSP:TRANSPARENT END:VEVENT */ diff --git a/htdocs/core/login/functions_ldap.php b/htdocs/core/login/functions_ldap.php index 58ceff905b1..472c4948425 100644 --- a/htdocs/core/login/functions_ldap.php +++ b/htdocs/core/login/functions_ldap.php @@ -1,7 +1,7 @@ * Copyright (C) 2008-2021 Regis Houssin - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 William Mead * * This program is free software; you can redistribute it and/or modify @@ -28,7 +28,7 @@ /** * Check validity of user/password/entity - * If test is ko, reason must be filled into $_SESSION["dol_loginmesg"] + * If the test fails, the reason must be filled into $_SESSION["dol_loginmesg"] * * @param string $usertotest Login * @param string $passwordtotest Password @@ -68,7 +68,7 @@ function check_user_password_ldap($usertotest, $passwordtotest, $entitytotest) if ($usertotest) { dol_syslog("functions_ldap::check_user_password_ldap usertotest=".$usertotest." passwordtotest=".preg_replace('/./', '*', $passwordtotest)." entitytotest=".$entitytotest); - // If test username/password asked, we define $test=false and $login var if ok, set $_SESSION["dol_loginmesg"] if ko + // If testing user/password is requested, we define $test=false and $login var if successful, and set $_SESSION["dol_loginmesg"] if failed. $ldaphost = $dolibarr_main_auth_ldap_host; $ldapport = $dolibarr_main_auth_ldap_port; $ldapversion = $dolibarr_main_auth_ldap_version; @@ -141,7 +141,7 @@ function check_user_password_ldap($usertotest, $passwordtotest, $entitytotest) // If LDAP need a dn with login like "uid=jbloggs,ou=People,dc=foo,dc=com", default dn may work even if previous code with // admin login no executed. $ldap->searchUser = $ldapuserattr."=".$usertotest.",".$ldapdn; // Default dn (will work if LDAP accept a dn with login value inside) - // But if LDAP need a dn with name like "cn=Jhon Bloggs,ou=People,dc=foo,dc=com", previous part must have been executed to have + // However, if LDAP requires a DN with a name like "cn=Jhon Bloggs,ou=People,dc=foo,dc=com", the previous part must have been executed to have // dn detected into ldapUserDN. if ($resultFetchLdapUser && !empty($ldap->ldapUserDN)) { $ldap->searchUser = $ldap->ldapUserDN; @@ -166,11 +166,11 @@ function check_user_password_ldap($usertotest, $passwordtotest, $entitytotest) // Note: Test on date validity is done later natively with isNotIntoValidityDateRange() by core after calling checkLoginPassEntity() that call this method - // ldap2dolibarr synchronisation + // ldap2dolibarr synchronization if ($login && isModEnabled('ldap') && getDolGlobalInt('LDAP_SYNCHRO_ACTIVE') == Ldap::SYNCHRO_LDAP_TO_DOLIBARR) { // ldap2dolibarr synchronization dol_syslog("functions_ldap::check_user_password_ldap Sync ldap2dolibarr"); - // On charge les attributes du user ldap + // We fetch the LDAP user attributes if ($ldapdebug) { print "DEBUG: login ldap = ".$login."
\n"; } @@ -186,7 +186,7 @@ function check_user_password_ldap($usertotest, $passwordtotest, $entitytotest) print "DEBUG: badPasswordTime = ".dol_print_date($ldap->badpwdtime, 'day')."
\n"; } - // On recherche le user dolibarr en fonction de son SID ldap (only for Active Directory) + // We search for the Dolibarr user based on its LDAP SID (only for Active Directory) $sid = null; if (getDolGlobalString('LDAP_SERVER_TYPE') == "activedirectory") { $sid = $ldap->getObjectSid($login); diff --git a/htdocs/core/modules/mailings/contacts1.modules.php b/htdocs/core/modules/mailings/contacts1.modules.php index 7e6e28669c6..a4afddb467c 100644 --- a/htdocs/core/modules/mailings/contacts1.modules.php +++ b/htdocs/core/modules/mailings/contacts1.modules.php @@ -2,7 +2,7 @@ /* Copyright (C) 2005 Rodolphe Quiedeville * Copyright (C) 2005-2009 Laurent Destailleur * Copyright (C) 2005-2009 Regis Houssin - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024-2025 Frédéric France * * This program is free software; you can redistribute it and/or modify @@ -125,9 +125,9 @@ class mailing_contacts1 extends MailingTargets /** - * Affiche formulaire de filtre qui apparait dans page de selection des destinataires de mailings + * Show filter form that appears in a page to select the recipients of mailings * - * @return string Retourne zone select + * @return string HTML for selection filter. */ public function formFilter() { diff --git a/htdocs/core/modules/mailings/fraise.modules.php b/htdocs/core/modules/mailings/fraise.modules.php index 177d3e676df..c007ccb4e10 100644 --- a/htdocs/core/modules/mailings/fraise.modules.php +++ b/htdocs/core/modules/mailings/fraise.modules.php @@ -2,7 +2,7 @@ /* Copyright (C) 2005 Laurent Destailleur * Copyright (C) 2005-2009 Regis Houssin * Copyright (C) 2018-2025 Frédéric France - * Copyright (C) 2024 MDW + * Copyright (C) 2024-2026 MDW * * 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 @@ -22,7 +22,7 @@ /** * \file htdocs/core/modules/mailings/fraise.modules.php * \ingroup mailing - * \brief File of class to generate target according to rule Fraise + * \brief Class file to generate targets according to the Fraise rule */ include_once DOL_DOCUMENT_ROOT.'/core/modules/mailings/modules_mailings.php'; @@ -30,7 +30,7 @@ include_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; /** - * Class to generate target according to rule Fraise + * Class to generate targets according to the Fraise rule */ class mailing_fraise extends MailingTargets { @@ -71,9 +71,9 @@ class mailing_fraise extends MailingTargets /** - * On the main mailing area, there is a box with statistics. - * If you want to add a line in this report you must provide an - * array of SQL request that returns two field: + * In the main mailing area, there is a box with statistics. + * If you want to add a line in this report, you must provide an + * array of SQL requests that return two fields: * One called "label", One called "nb". * * @return string[] Array with SQL requests @@ -95,12 +95,12 @@ class mailing_fraise extends MailingTargets /** - * Return here number of distinct emails returned by your selector. + * Returns the number of distinct emails returned by your selector. * For example if this selector is used to extract 500 different * emails from a text file, this function must return 500. * - * @param string $sql Requete sql de comptage - * @return int|string Nb of recipient, or <0 if error, or '' if NA + * @param string $sql SQL query for counting + * @return int|string Number of recipients, or <0 if error, or '' if N/A */ public function getNbOfRecipients($sql = '') { @@ -118,9 +118,9 @@ class mailing_fraise extends MailingTargets /** - * Affiche formulaire de filtre qui apparait dans page de selection des destinataires de mailings + * Displays the filter form that appears on the mailing recipient selection page * - * @return string Retourne zone select + * @return string Returns the select area */ public function formFilter() { @@ -222,7 +222,7 @@ class mailing_fraise extends MailingTargets /** - * Provide the URL to the car of the source information of the recipient for the mailing + * Provides the URL to the card of the source information of the recipient for the mailing * * @param int $id ID * @return string URL link @@ -235,10 +235,10 @@ class mailing_fraise extends MailingTargets // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Ajoute destinataires dans table des cibles + * Adds recipients to the targets table * * @param int $mailing_id Id of emailing - * @return int Return integer < 0 si erreur, nb ajout si ok + * @return int Returns integer < 0 if error, number added if successful */ public function add_to_target($mailing_id) { diff --git a/htdocs/core/modules/mailings/modules_mailings.php b/htdocs/core/modules/mailings/modules_mailings.php index 7e73d79588a..f125d523533 100644 --- a/htdocs/core/modules/mailings/modules_mailings.php +++ b/htdocs/core/modules/mailings/modules_mailings.php @@ -149,10 +149,9 @@ class MailingTargets // This can't be abstract as it is used for some method } /** - * Affiche formulaire de filtre qui apparait dans page de selection - * des destinataires de mailings + * Displays filter form that appears on the mailing recipient selection page * - * @return string Retourne zone select + * @return string Returns select area */ public function formFilter() { @@ -244,20 +243,20 @@ class MailingTargets // This can't be abstract as it is used for some method dol_syslog(__METHOD__.": mailing ".$j." targets added"); /* - //Update the status to show thirdparty mail that don't want to be contacted anymore' + //Update the status to show third-party emails that no longer wish to be contacted' $sql = "UPDATE ".$this->db->prefix()."mailing_cibles"; $sql .= " SET statut=3"; $sql .= " WHERE fk_mailing = ".((int) $mailing_id)." AND email in (SELECT email FROM ".$this->db->prefix()."societe where fk_stcomm=-1)"; $sql .= " AND source_type='thirdparty'"; - dol_syslog(__METHOD__.": mailing update status to display thirdparty mail that do not want to be contacted"); + dol_syslog(__METHOD__.": mailing update status to display third-party emails that no longer wish to be contacted"); $result=$this->db->query($sql); - //Update the status to show contact mail that don't want to be contacted anymore' + //Update the status to show contact emails that no longer wish to be contacted' $sql = "UPDATE ".$this->db->prefix()."mailing_cibles"; $sql .= " SET statut=3"; $sql .= " WHERE fk_mailing = ".((int) $mailing_id)." AND source_type='contact' AND (email in (SELECT sc.email FROM ".$this->db->prefix()."socpeople AS sc "; $sql .= " INNER JOIN ".$this->db->prefix()."societe s ON s.rowid=sc.fk_soc WHERE s.fk_stcomm=-1 OR no_email=1))"; - dol_syslog(__METHOD__.": mailing update status to display contact mail that do not want to be contacted",LOG_DEBUG); + dol_syslog(__METHOD__.": mailing update status to display contact emails that no longer wish to be contacted",LOG_DEBUG); $result=$this->db->query($sql); */ @@ -284,7 +283,7 @@ class MailingTargets // This can't be abstract as it is used for some method // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Supprime tous les destinataires de la table des cibles + * Deletes all recipients from the targets table * * @param int $mailing_id Id of emailing * @return void diff --git a/htdocs/exports/export.php b/htdocs/exports/export.php index 86dd9aef764..b66c7f095cc 100644 --- a/htdocs/exports/export.php +++ b/htdocs/exports/export.php @@ -5,7 +5,7 @@ * Copyright (C) 2012 Charles-Fr BENKE * Copyright (C) 2015 Juanjo Menent * Copyright (C) 2024 Frédéric France - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * * 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 @@ -24,7 +24,7 @@ /** * \file htdocs/exports/export.php * \ingroup export - * \brief Pages of export Wizard + * \brief Export Wizard pages */ require_once '../main.inc.php'; @@ -185,7 +185,7 @@ $result = restrictedArea($user, 'export'); * Actions */ -if ($action == 'selectfield' && $user->hasRight('export', 'creer')) { // Selection of field at step 2 +if ($action == 'selectfield' && $user->hasRight('export', 'creer')) { // Select field at step 2 $fieldsarray = $objexport->array_export_fields[0]; $fieldsentitiesarray = $objexport->array_export_entities[0]; $fieldsdependenciesarray = $objexport->array_export_dependencies[0]; @@ -209,7 +209,7 @@ if ($action == 'selectfield' && $user->hasRight('export', 'creer')) { // Sel var_dump($fieldsdependenciesarray);*/ $listofdependencies = array(); if (!empty($fieldsentitiesarray[$field]) && !empty($fieldsdependenciesarray[$fieldsentitiesarray[$field]])) { - // We found a dependency on the type of field + // Found a dependency on the field type $tmp = $fieldsdependenciesarray[$fieldsentitiesarray[$field]]; // $fieldsdependenciesarray=array('element'=>'fd.rowid') or array('element'=>array('fd.rowid','ab.rowid')) if (is_array($tmp)) { $listofdependencies = $tmp; @@ -217,7 +217,7 @@ if ($action == 'selectfield' && $user->hasRight('export', 'creer')) { // Sel $listofdependencies = array($tmp); } } elseif (!empty($field) && !empty($fieldsdependenciesarray[$field])) { - // We found a dependency on a dedicated field + // Found a dependency on a specific field $tmp = $fieldsdependenciesarray[$field]; // $fieldsdependenciesarray=array('fd.fieldx'=>'fd.rowid') or array('fd.fieldx'=>array('fd.rowid','ab.rowid')) if (is_array($tmp)) { $listofdependencies = $tmp; @@ -434,7 +434,7 @@ if ($step == 4 && $action == 'submitFormField' && $user->hasRight('export', 'lir accessforbidden(); } - // on boucle sur les champs selectionne pour recuperer la valeur + // loop over selected fields to retrieve the value if (is_array($objexport->array_export_TypeFields[0])) { $_SESSION["export_filtered_fields"] = array(); foreach ($objexport->array_export_TypeFields[0] as $code => $type) { // $code: s.fieldname $value: Text|Boolean|List:ccc @@ -566,7 +566,7 @@ if ($step == 2 && $datatoexport) { print $objexport->array_export_module[0]->getName(); print ''; - // Lot de donnees a exporter + // Dataset to export print ''.$langs->trans("DatasetToExport").''; print ''; $entity = preg_replace('/:.*$/', '', $objexport->array_export_icon[0]); @@ -829,7 +829,7 @@ if ($step == 3 && $datatoexport) { print ''.$langs->trans("SelectFilterFields").'...

'; - // un formulaire en plus pour recuperer les filtres + // An extra form to get the filters print ''; print ''; @@ -989,7 +989,7 @@ if ($step == 4 && $datatoexport) { print dolPrintHTML($titleofmodule); print ''; - // Lot de donnees a exporter + // Dataset to export print ''.$langs->trans("DatasetToExport").''; print ''; $entity = preg_replace('/:.*$/', '', $objexport->array_export_icon[0]); @@ -1264,7 +1264,7 @@ if ($step == 5 && $datatoexport) { $head[$h][1] = $langs->trans("Step")." 2"; $h++; - // si le filtrage est parameter pour l'export ou pas + // whether filtering is a parameter for the export or not if ($usefilters && isset($objexport->array_export_TypeFields[0]) && is_array($objexport->array_export_TypeFields[0])) { $head[$h][0] = DOL_URL_ROOT.'/exports/export.php?step=3&datatoexport='.$datatoexport; $head[$h][1] = $langs->trans("Step")." 3"; diff --git a/htdocs/fourn/facture/rapport.php b/htdocs/fourn/facture/rapport.php index 4ac1da28da9..a2cbe28e371 100644 --- a/htdocs/fourn/facture/rapport.php +++ b/htdocs/fourn/facture/rapport.php @@ -129,7 +129,7 @@ llxHeader('', $titre, '', '', 0, 0, '', '', '', 'mod-fourn-facture page-rapport' print load_fiche_titre($titre, '', 'supplier_invoice'); -// Formulaire de generation +// Generation form print ''; print ''; print ''; diff --git a/htdocs/ftp/admin/ftpclient.php b/htdocs/ftp/admin/ftpclient.php index 9dc2e36913a..debcbc45df6 100644 --- a/htdocs/ftp/admin/ftpclient.php +++ b/htdocs/ftp/admin/ftpclient.php @@ -2,7 +2,7 @@ /* Copyright (C) 2004-2022 Laurent Destailleur * Copyright (C) 2011 Juanjo Menent * Copyright (C) 2024 Frédéric France - * Copyright (C) 2025 MDW + * Copyright (C) 2025-2026 MDW * * 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 @@ -180,7 +180,7 @@ print '
'; if (!function_exists('ftp_connect')) { print $langs->trans("FTPFeatureNotSupportedByYourPHP"); } else { - // Formulaire ajout + // Add form print ''; print ''; diff --git a/htdocs/install/repair.php b/htdocs/install/repair.php index b79c0154d9e..be4132f8355 100644 --- a/htdocs/install/repair.php +++ b/htdocs/install/repair.php @@ -1421,7 +1421,7 @@ if ($ok && GETPOST('clean_perm_table', 'alpha')) { $obj = $db->fetch_object($resql); if ($obj->id > 0) { print 'Found line with id '.$obj->id.', label "'.$obj->label.'" of module "'.$obj->module.'" to delete'; - if (GETPOST('clean_perm_table', 'alpha') == 'confirmed') { + if (GETPOST('clean_perm_table', 'alpha') == 'confirmed') { // If confirmed, proceed with deletion $sqldelete = "DELETE FROM ".MAIN_DB_PREFIX."rights_def WHERE id = ".((int) $obj->id); $resqldelete = $db->query($sqldelete); if (!$resqldelete) { @@ -1464,7 +1464,7 @@ if ($ok && GETPOST('clean_ecm_files_table', 'alpha')) { $obj = $db->fetch_object($resql); if ($obj->rowid > 0) { $filetocheck = DOL_DATA_ROOT.'/'.$obj->filepath.'/'.$obj->filename; - $nbfile++; + $nbfile++; // Count of files analyzed if (!dol_is_file($filetocheck) && !dol_is_file($filetocheck.'.noexe')) { $nbfiletodelete++; if ($nbfiletodelete <= $MAXTODELETE) { @@ -2057,7 +2057,7 @@ if ($ok && GETPOST('recalculateinvoicetotal') == 'confirmed') { fd.fk_facture = $obj->rowid"; $ressql_calculs = $db->query($sql_calculs); while ($obj_calcul = $db->fetch_object($ressql_calculs)) { - // Calcul de la somme des paiements reçus + // Calculate the sum of received payments $sql_paiements = "SELECT SUM(amount) as somme from ".MAIN_DB_PREFIX."paiement_facture WHERE fk_facture = $obj->rowid"; $montantPaiements = $db->fetch_object($db->query($sql_paiements))->somme; $totHt = ($obj_calcul->total_ht ? price2num($obj_calcul->total_ht, 'MT') : 0); diff --git a/htdocs/intracommreport/card.php b/htdocs/intracommreport/card.php index f32e4250228..0d81894d2dd 100644 --- a/htdocs/intracommreport/card.php +++ b/htdocs/intracommreport/card.php @@ -2,6 +2,7 @@ /* Copyright (C) 2017 Laurent Destailleur * Copyright (C) ---Put here your own copyright and developer email--- * Copyright (C) 2024-2026 Frédéric France + * Copyright (C) 2026 MDW * * 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 @@ -28,7 +29,7 @@ * Terms * * DEB = Declaration d'Exchanges de Biens (FR) = Declaration of Exchange of Goods (EN) - * DES = Déclaration Européenne de Services (FR) = European Declaration of Services (EN) + * DES = Declaration Europeenne de Services (FR) = European Declaration of Services (EN) * * INTRACOMM: Douanes françaises (FR) = french customs (EN) - https://www.douane.gouv.fr/professionnels/commerce-international/import-export * diff --git a/htdocs/intracommreport/class/intracommreport.class.php b/htdocs/intracommreport/class/intracommreport.class.php index 93513905112..9826241e5b8 100644 --- a/htdocs/intracommreport/class/intracommreport.class.php +++ b/htdocs/intracommreport/class/intracommreport.class.php @@ -360,7 +360,7 @@ class IntracommReport extends CommonObject $declaration_des = $e->addChild('declaration_des'); $declaration_des->addChild('num_des', self::getDeclarationNumber($this->numero_declaration)); - $declaration_des->addChild('num_tvaFr', $mysoc->tva_intra ?? ''); // /^FR[a-Z0-9]{2}[0-9]{9}$/ // Doit faire 13 caractères + $declaration_des->addChild('num_tvaFr', $mysoc->tva_intra ?? ''); // /^FR[a-Z0-9]{2}[0-9]{9}$/ // Must be 13 characters $declaration_des->addChild('mois_des', (string) $period_month); $declaration_des->addChild('an_des', (string) $period_year); diff --git a/htdocs/opensurvey/lib/opensurvey.lib.php b/htdocs/opensurvey/lib/opensurvey.lib.php index b029021ad40..52c57fa3ac3 100644 --- a/htdocs/opensurvey/lib/opensurvey.lib.php +++ b/htdocs/opensurvey/lib/opensurvey.lib.php @@ -1,7 +1,7 @@ * Copyright (C) 2014 Marcos García - * Copyright (C) 2024 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024-2026 Frédéric France * * This program is free software; you can redistribute it and/or modify @@ -161,8 +161,8 @@ function issetAndNoEmpty($name, $tableau = null) /** * Function for generating URLs for surveys. * - * @param string $id L'identifiant du sondage - * @param bool $admin True pour générer une URL pour l'administration d'un sondage, False pour un URL publique + * @param string $id Survey ID + * @param bool $admin True to generate a URL for survey administration, False for a public URL * @return string The url of the survey */ function getUrlSondage($id, $admin = false) diff --git a/htdocs/product/class/api_products.class.php b/htdocs/product/class/api_products.class.php index 2179eb18e83..705f15983ff 100644 --- a/htdocs/product/class/api_products.class.php +++ b/htdocs/product/class/api_products.class.php @@ -2,7 +2,7 @@ /* Copyright (C) 2015 Jean-François Ferry * Copyright (C) 2019 Cedric Ancelin * Copyright (C) 2024-2025 Frédéric France - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2025 William Mead * Copyright (C) 2025 Charlene Benke * @@ -91,7 +91,7 @@ class Products extends DolibarrApi */ public function get($id, $includestockdata = 0, $includesubproducts = false, $includeparentid = false, $includetrans = false) { - if ($id < 1 ) { + if ($id < 1) { throw new RestException(400, 'No Product with id<1 can exist'); } return $this->_fetch($id, '', '', '', $includestockdata, $includesubproducts, $includeparentid, false, $includetrans); @@ -399,7 +399,7 @@ class Products extends DolibarrApi */ public function put($id, $request_data = null) { - if ($id < 1 ) { + if ($id < 1) { throw new RestException(400, 'No Product with id<1 can exist'); } @@ -570,7 +570,7 @@ class Products extends DolibarrApi */ public function delete($id) { - if ($id < 1 ) { + if ($id < 1) { throw new RestException(400, 'No Product with id<1 can exist'); } if (!DolibarrApiAccess::$user->hasRight('produit', 'supprimer')) { @@ -2260,7 +2260,7 @@ class Products extends DolibarrApi throw new RestException(403, 'Access not allowed for login ' . DolibarrApiAccess::$user->login); } - // Récupérer les contacts externes et internes + // Retrieve external and internal contacts $contacts = $this->product->liste_contact(-1, 'external', 0, $type); $socpeoples = $this->product->liste_contact(-1, 'internal', 0, $type); diff --git a/htdocs/product/class/product.class.php b/htdocs/product/class/product.class.php index e07e9556eaa..773b3d7a4aa 100644 --- a/htdocs/product/class/product.class.php +++ b/htdocs/product/class/product.class.php @@ -3302,7 +3302,7 @@ class Product extends CommonObject /* $this->prices_by_qty[$i]=$result["price_by_qty"]; $this->prices_by_qty_id[$i]=$result["rowid"]; - // Récuperation de la liste des prix selon qty si flag positionné + // Get the pricelist according to the qty if flag is set if ($this->prices_by_qty[$i] == 1) { $sql = "SELECT rowid, price, unitprice, quantity, remise_percent, remise, price_base_type"; @@ -3357,7 +3357,7 @@ class Product extends CommonObject // Price by quantity $this->prices_by_qty[0] = $result["price_by_qty"]; $this->prices_by_qty_id[0] = $result["rowid"]; - // Récuperation de la liste des prix selon qty si flag positionné + // Get the pricelist according to the qty if flag is set if ($this->prices_by_qty[0] == 1) { $sql = "SELECT rowid,price, unitprice, quantity, remise_percent, remise, remise, price_base_type"; $sql .= " FROM ".$this->db->prefix()."product_price_by_qty"; @@ -3419,7 +3419,7 @@ class Product extends CommonObject // Price by quantity $this->prices_by_qty[$i] = (!empty($result["price_by_qty"]) ? $result["price_by_qty"] : 0); $this->prices_by_qty_id[$i] = (!empty($result["rowid"]) ? $result["rowid"] : 0); - // Récuperation de la liste des prix selon qty si flag positionné + // Get the pricelist according to the qty if flag is set if ($this->prices_by_qty[$i] == 1) { $sql = "SELECT rowid, price, unitprice, quantity, remise_percent, remise, price_base_type"; $sql .= " FROM ".$this->db->prefix()."product_price_by_qty"; @@ -4034,7 +4034,7 @@ class Product extends CommonObject // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Charge tableau des stats réception fournisseur pour le produit/service + * Loads supplier reception statistics table for given product or service * * @param int $socid Id thirdparty to filter on a thirdparty * @param string $filtrestatut Id status to filter on a status @@ -4486,7 +4486,7 @@ class Product extends CommonObject /** * Load array of statistics for recurring supplier invoice for product/service * - * @param int $socid Id societe + * @param int $socid Supplier ID * @return int Array of stats in $this->stats_facturefournrec, <0 if ko or >0 if ok */ public function load_stats_facturefournrec($socid = 0) diff --git a/htdocs/product/price.php b/htdocs/product/price.php index debf97893ab..a857a96bc67 100644 --- a/htdocs/product/price.php +++ b/htdocs/product/price.php @@ -13,7 +13,7 @@ * Copyright (C) 2016 Ferran Marcet * Copyright (C) 2018-2026 Frédéric France * Copyright (C) 2018 Nicolas ZABOURI - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2025 Mélina Joum * * This program is free software; you can redistribute it and/or modify @@ -622,7 +622,7 @@ if (empty($reshook)) { // Add or update price by quantity if ($action == 'update_price_by_qty' && $permissiontoadd) { - // Récupération des variables + // Get the variables $rowid = GETPOSTINT('rowid'); $priceid = GETPOSTINT('priceid'); $newprice = price2num(GETPOST("price"), 'MU', 2); @@ -648,7 +648,7 @@ if (empty($reshook)) { $price = price2num($newprice, 'MU'); $unitPrice = price2num((float) $price / (float) $quantity, 'MU'); - // Ajout / mise à jour + // Update or add if ($rowid > 0) { $sql = "UPDATE ".MAIN_DB_PREFIX."product_price_by_qty SET"; $sql .= " price=".((float) $price).","; diff --git a/htdocs/product/stock/class/api_productlots.class.php b/htdocs/product/stock/class/api_productlots.class.php index 6e922e11dce..134a2e7ed8f 100644 --- a/htdocs/product/stock/class/api_productlots.class.php +++ b/htdocs/product/stock/class/api_productlots.class.php @@ -1,7 +1,7 @@ * Copyright (C) 2016 Laurent Destailleur - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2025 William Mead * Copyright (C) 2025 Frédéric France * @@ -129,7 +129,7 @@ class Productlots extends DolibarrApi $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product_lot_extrafields AS ple ON ple.fk_object = pl.rowid"; $sql .= " WHERE 1=1"; - // Filtres universels + // Global filters if ($sqlfilters) { $errormessage = ''; $sql .= forgeSQLFromUniversalSearchCriteria($sqlfilters, $errormessage); @@ -138,7 +138,7 @@ class Productlots extends DolibarrApi } } - // Clone pour total (avant ORDER/LIMIT) + // Clone for total (before ORDER/LIMIT) $sqlTotals = preg_replace('/^\s*SELECT\s+pl\.rowid/i', 'SELECT COUNT(pl.rowid) AS total', $sql); // ORDER BY @@ -180,7 +180,7 @@ class Productlots extends DolibarrApi if ($row && isset($row->total)) $total = (int) $row->total; } - // Evite division par zéro + // Avoid division by zero $safeLimit = ($limit > 0) ? (int) $limit : max(1, count($obj_ret)); $pageCount = (int) ceil($total / $safeLimit); diff --git a/htdocs/product/stock/productlot_card.php b/htdocs/product/stock/productlot_card.php index 74cc46d1604..29b3238dadc 100644 --- a/htdocs/product/stock/productlot_card.php +++ b/htdocs/product/stock/productlot_card.php @@ -4,7 +4,7 @@ * Copyright (C) 2021 Noé Cendrier * Copyright (C) 2024-2026 Frédéric France * Copyright (C) 2025 Alexandre Spangaro - * Copyright (C) 2025 MDW + * Copyright (C) 2025-2026 MDW * * 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 @@ -252,7 +252,7 @@ if (empty($reshook)) { if ($action == 'setqc_frequency' && $permissiontoadd && ! GETPOST('cancel', 'alpha')) { $result = $object->setValueFrom('qc_frequency', GETPOST('qc_frequency'), '', null, 'int', '', $user, 'PRODUCT_MODIFY'); - if ($result < 0) { // Prévoir un test de format de durée + if ($result < 0) { // To provide a duration format test setEventMessages($object->error, null, 'errors'); $action = 'editqc_frequency'; } else { diff --git a/htdocs/product/stock/stocktransfer/stocktransfer_card.php b/htdocs/product/stock/stocktransfer/stocktransfer_card.php index 81fcee7e4ea..4e6ed30b29a 100644 --- a/htdocs/product/stock/stocktransfer/stocktransfer_card.php +++ b/htdocs/product/stock/stocktransfer/stocktransfer_card.php @@ -1,6 +1,6 @@ - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024-2025 Frédéric France * Copyright (C) 2025-2026 Pierre Ardoin * @@ -148,7 +148,7 @@ if (empty($reshook)) { // Actions cancel, add, update, update_extras, confirm_validate, confirm_delete, confirm_deleteline, confirm_clone, confirm_close, confirm_setdraft, confirm_reopen include DOL_DOCUMENT_ROOT.'/core/actions_addupdatedelete.inc.php'; - // On remet cette lecture de permission ici car nécessaire d'avoir le nouveau statut de l'objet après toute action exécutée dessus (après incrémentation par example, le bouton supprimer doit disparaître) + // We put this permission read here because it is necessary to have the new status of the object after any executed action on it (for example, the delete button must disappear after incrementation) $permissiontodelete = $user->rights->stocktransfer->stocktransfer->delete || ($permissiontoadd && isset($object->status) && $object->status == $object::STATUS_DRAFT); // Actions when linking object each other @@ -313,7 +313,7 @@ if (empty($reshook)) { } } - // Annulation décrémentation + // Cancel Decrementation if ($action == 'confirm_destockcancel' && $confirm == 'yes' && $object->status == $object::STATUS_TRANSFERED && $permissiontoadd) { $lines = $object->getLinesArray(); if (!empty($lines)) { @@ -340,7 +340,7 @@ if (empty($reshook)) { } } - // Incrémentation + // Incrementing if ($action == 'confirm_addstock' && $confirm == 'yes' && $object->status == $object::STATUS_TRANSFERED && $permissiontoadd) { $lines = $object->getLinesArray(); if (!empty($lines)) { @@ -372,7 +372,7 @@ if (empty($reshook)) { } } - // Annulation incrémentation + // Cancel Incrementing if ($action == 'confirm_addstockcancel' && $confirm == 'yes' && $object->status == $object::STATUS_CLOSED && $permissiontoadd) { $lines = $object->getLinesArray(); if (!empty($lines)) { @@ -427,7 +427,7 @@ $help_url = ''; llxHeader('', $title, $help_url, '', 0, 0, '', '', '', 'mod-product page-stock-stocktransfer_stocktransfer_card'); -// Example : Adding jquery code +// Example: Adding jQuery code print ''; diff --git a/htdocs/exports/export.php b/htdocs/exports/export.php index b66c7f095cc..3a1c4bece46 100644 --- a/htdocs/exports/export.php +++ b/htdocs/exports/export.php @@ -1390,14 +1390,14 @@ if ($step == 5 && $datatoexport) { $htmltabloflibs .= '
'; print '
'; - print ''.$form->textwithpicto($langs->trans("NowClickToGenerateToBuildExportFile"), $htmltabloflibs, 1, 'help', '', 0, 2, 'helphonformat').''; + print '
'.$form->textwithpicto($langs->trans("NowClickToGenerateToBuildExportFile"), $htmltabloflibs, 1, 'help', '', 0, 2, 'helphonformat').'
'; //print $htmltabloflibs; print ''; if ($sqlusedforexport && $user->admin) { - print info_admin($langs->trans("SQLUsedForExport").':
'.$sqlusedforexport, 0, 0, '1', '', 'TechnicalInformation').'
'; + print info_admin($langs->trans("SQLUsedForExport").':
'.$sqlusedforexport.'', 0, 0, 'info', '', 'TechnicalInformation').'
'; print '
'; } From c895a4531ea3d321fd484bcafc45fae62bc70ac3 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 3 Jul 2026 18:48:20 +0200 Subject: [PATCH 044/307] Doc --- htdocs/core/modules/DolibarrModules.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/modules/DolibarrModules.class.php b/htdocs/core/modules/DolibarrModules.class.php index 1a1821ab76e..2c4cb8dc290 100644 --- a/htdocs/core/modules/DolibarrModules.class.php +++ b/htdocs/core/modules/DolibarrModules.class.php @@ -114,7 +114,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it public $boxes = array(); /** - * @var array,5?:string,6?:int<0,1>}> Module constants + * @var array,5?:string,6?:int<0,1>}> Module constants * (0:name,1:type,2:val,3:note,4:visible,5:entity,6:deleteonunactive) */ public $const = array(); From b8daa5d3995d5aff098ca4552122bbce77c78384 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 3 Jul 2026 19:06:38 +0200 Subject: [PATCH 045/307] Clean code --- htdocs/core/modules/modCommande.class.php | 6 ------ htdocs/core/modules/modPaymentByBankTransfer.class.php | 8 -------- 2 files changed, 14 deletions(-) diff --git a/htdocs/core/modules/modCommande.class.php b/htdocs/core/modules/modCommande.class.php index 2c1a311dd2d..14db76ae67d 100644 --- a/htdocs/core/modules/modCommande.class.php +++ b/htdocs/core/modules/modCommande.class.php @@ -101,12 +101,6 @@ class modCommande extends DolibarrModules ], ]; - /*$r++; - $this->const[$r][0] = "COMMANDE_DRAFT_WATERMARK"; - $this->const[$r][1] = "chaine"; - $this->const[$r][2] = "__(Draft)__"; - $this->const[$r][3] = 'Watermark to show on draft orders'; - $this->const[$r][4] = 0;*/ // Boxes $this->boxes = array( diff --git a/htdocs/core/modules/modPaymentByBankTransfer.class.php b/htdocs/core/modules/modPaymentByBankTransfer.class.php index 28a6efd6dd0..6920a211ecd 100644 --- a/htdocs/core/modules/modPaymentByBankTransfer.class.php +++ b/htdocs/core/modules/modPaymentByBankTransfer.class.php @@ -77,14 +77,6 @@ class modPaymentByBankTransfer extends DolibarrModules $this->const = array(); $r = 0; - /*$this->const[$r][0] = "BANK_ADDON_PDF"; - $this->const[$r][1] = "chaine"; - $this->const[$r][2] = "sepamandate"; - $this->const[$r][3] = 'Name of manager to generate SEPA mandate'; - $this->const[$r][4] = 0; - $r++;*/ - - // Boxes $this->boxes = array(); From c75d2eef4fae5ce5a013d17d545ce7a3c0cd9507 Mon Sep 17 00:00:00 2001 From: HENRY Florian Date: Fri, 3 Jul 2026 19:08:10 +0200 Subject: [PATCH 046/307] fix: missing create table on fresh intall when module catagory is activated (#39043) * fix: missing create table on fresh intall when module catagory is activated * fix: missing create table on fresh intall when module catagory is activated * fix: missing create table on fresh intall when module catagory is activated --------- Co-authored-by: Laurent Destailleur --- htdocs/core/modules/modCategorie.class.php | 3 +++ htdocs/core/modules/modPropale.class.php | 2 ++ 2 files changed, 5 insertions(+) diff --git a/htdocs/core/modules/modCategorie.class.php b/htdocs/core/modules/modCategorie.class.php index 4115daf56df..8069fbe10dd 100644 --- a/htdocs/core/modules/modCategorie.class.php +++ b/htdocs/core/modules/modCategorie.class.php @@ -944,6 +944,9 @@ class modCategorie extends DolibarrModules if (isModEnabled("order")) { $this->_load_tables('/install/mysql/', 'commande'); } + if (isModEnabled("propal")) { + $this->_load_tables('/install/mysql/', 'propal'); + } // Permissions $this->remove($options); diff --git a/htdocs/core/modules/modPropale.class.php b/htdocs/core/modules/modPropale.class.php index e1d6216cd96..571e7223ddc 100644 --- a/htdocs/core/modules/modPropale.class.php +++ b/htdocs/core/modules/modPropale.class.php @@ -505,6 +505,8 @@ class modPropale extends DolibarrModules { global $conf, $langs; + $this->_load_tables('/install/mysql/', 'propal'); + // Remove permissions and default values $this->remove($options); From 215cfa465da8c71e9891d659af18edb3417557f5 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 3 Jul 2026 19:19:54 +0200 Subject: [PATCH 047/307] FIX Export of string starting with = must be quoted in CSV. Reported by tk gamefactory. --- htdocs/core/lib/functions2.lib.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/htdocs/core/lib/functions2.lib.php b/htdocs/core/lib/functions2.lib.php index 556890f89ba..e0df2fb5f99 100644 --- a/htdocs/core/lib/functions2.lib.php +++ b/htdocs/core/lib/functions2.lib.php @@ -3114,6 +3114,11 @@ function csvClean($newvalue, $charset = '', $separator = '') $addquote = 1; } + // Rule 4 CSV: If value is a string started with =, we must add " + if (preg_match('/^\s*=/', $newvalue)) { + $addquote = 1; + } + return ($addquote ? '"' : '').$newvalue.($addquote ? '"' : ''); } From 1e74fc3885717b4daf966564406c665bb1472e2f Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 3 Jul 2026 19:30:34 +0200 Subject: [PATCH 048/307] FIX Export of string starting with = must be quoted in CSV. Reported by tk gamefactory. --- htdocs/core/lib/functions2.lib.php | 4 ++-- htdocs/core/modules/export/export_csvutf8.modules.php | 2 +- htdocs/core/modules/export/export_tsv.modules.php | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/htdocs/core/lib/functions2.lib.php b/htdocs/core/lib/functions2.lib.php index e0df2fb5f99..ec0a8109a3e 100644 --- a/htdocs/core/lib/functions2.lib.php +++ b/htdocs/core/lib/functions2.lib.php @@ -3114,8 +3114,8 @@ function csvClean($newvalue, $charset = '', $separator = '') $addquote = 1; } - // Rule 4 CSV: If value is a string started with =, we must add " - if (preg_match('/^\s*=/', $newvalue)) { + // Rule 4 CSV: If value is a string started with char that trigger formula in calc software, we must add " + if (preg_match('/^\s*[=+-@]/', $newvalue)) { $addquote = 1; } diff --git a/htdocs/core/modules/export/export_csvutf8.modules.php b/htdocs/core/modules/export/export_csvutf8.modules.php index ee73a8603c8..44232a15439 100644 --- a/htdocs/core/modules/export/export_csvutf8.modules.php +++ b/htdocs/core/modules/export/export_csvutf8.modules.php @@ -40,7 +40,7 @@ class ExportCsvUtf8 extends ExportCsv */ public function __construct($db) { - global $conf, $langs; + global $langs; $this->db = $db; $this->separator = ','; diff --git a/htdocs/core/modules/export/export_tsv.modules.php b/htdocs/core/modules/export/export_tsv.modules.php index 7eb876abda3..77b1f76170e 100644 --- a/htdocs/core/modules/export/export_tsv.modules.php +++ b/htdocs/core/modules/export/export_tsv.modules.php @@ -373,6 +373,12 @@ class ExportTsv extends ModeleExports $newvalue = str_replace("\t", " ", $newvalue); } + // Note: with TSV format, there is no standard to escape a value that contains a tabulation or a string + // so we add a ' char before. + if (preg_match('/^\s*[=+-@]/', $newvalue)) { + $newvalue = "'".$newvalue; + } + return $newvalue; } } From 5f2905d54a2e5c536f68b4245459391fafc16acb Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Sat, 4 Jul 2026 13:27:58 +0200 Subject: [PATCH 049/307] FIX bon prelevement SEPA generation (#38998) * TEST : add test for bon_prelevement generation * TEST : add tests for bank transfer SEPA file generation * FIX WARNING : dol_syslog(array) outputs a warning. * FIX bon prelevement : non-default IBAN were not used in the generated XML. * BonPrelevement test : add validation against XSD * BonVirement test : add validation against XSD * FIX codesniffer * Enhance test error messages * Avoid PHP warning during tests * TEST FIX : one bank account was not created * TEST FIX : runnning all tests makes this one fail because is corrupted by a previous test. --------- Co-authored-by: Laurent Destailleur --- .../class/bonprelevement.class.php | 35 +- test/assets/xsd/pain.001.001.03.xsd | 921 ++++++++++++++++++ test/assets/xsd/pain.008.001.02.xsd | 879 +++++++++++++++++ test/phpunit/BonPrelevementTest.php | 654 ++++++++++++- test/phpunit/BonVirementTest.php | 695 +++++++++++++ 5 files changed, 3180 insertions(+), 4 deletions(-) create mode 100644 test/assets/xsd/pain.001.001.03.xsd create mode 100644 test/assets/xsd/pain.008.001.02.xsd create mode 100644 test/phpunit/BonVirementTest.php diff --git a/htdocs/compta/prelevement/class/bonprelevement.class.php b/htdocs/compta/prelevement/class/bonprelevement.class.php index 342aca24358..ae0f57bb388 100644 --- a/htdocs/compta/prelevement/class/bonprelevement.class.php +++ b/htdocs/compta/prelevement/class/bonprelevement.class.php @@ -1099,7 +1099,7 @@ class BonPrelevement extends CommonObject // phpcs:enable global $conf, $langs, $user; - dol_syslog(__METHOD__ . " mode=".$mode." format=".$format." type=".$type." dids=".$dids." fk_bank_account=".$fk_bank_account." sourcetype=".$sourcetype, LOG_DEBUG); + dol_syslog(__METHOD__ . " mode=".$mode." format=".$format." type=".$type." dids=".(is_array($dids) ? implode(',', $dids) : $dids)." fk_bank_account=".$fk_bank_account." sourcetype=".$sourcetype, LOG_DEBUG); require_once DOL_DOCUMENT_ROOT . "/compta/facture/class/facture.class.php"; require_once DOL_DOCUMENT_ROOT . "/societe/class/societe.class.php"; @@ -1421,7 +1421,7 @@ class BonPrelevement extends CommonObject $row = $this->db->fetch_row($resql); // Build the new ref - $ref = $prefixt . $ref . sprintf("%03d", (intval($row[0]) + 1)); + $ref = $prefixt . $ref . sprintf("%03d", (intval($row[0] ?? 0) + 1)); // $conf->abc->dir_output may be: // /home/ldestailleur/git/dolibarr_15.0/documents/abc/ @@ -1986,10 +1986,39 @@ class BonPrelevement extends CommonObject } $cachearraytotestduplicate[$obj->idfac] = $obj->rowid; + // Get the default value $daterum = (!empty($obj->date_rum)) ? $this->db->jdate($obj->date_rum) : $this->db->jdate($obj->datec); $iban = dolDecrypt($obj->iban); + $bic = $obj->bic; + $drum = $obj->drum; + $rum = $obj->rum; - $fileDebiteurSection .= $this->EnregDestinataireSEPA($obj->code, $obj->nom, $obj->address, $obj->zip, $obj->town, $obj->country_code, '', '', '', $obj->somme, $obj->reffac, $obj->idfac, $iban, $obj->bic, $daterum, (string) $obj->drum, $obj->rum, $type); + // But if a force bank account is defined, we use it instead + if (!empty($obj->fk_prelevement_demande)) { + $companybankaccountid = 0; + + $sqltmp = "SELECT fk_societe_rib FROM ".MAIN_DB_PREFIX."prelevement_demande"; + $sqltmp .= " WHERE rowid = ".((int) $obj->fk_prelevement_demande); + + $resqltmp = $this->db->query($sqltmp); + + $objtmp = $this->db->fetch_object($resqltmp); + if ($objtmp) { + $companybankaccountid = (int) $objtmp->fk_societe_rib; + } + + $bankaccount = new CompanyBankAccount($this->db); + $bankaccount->fetch($companybankaccountid); + if ($bankaccount->id > 0) { + $daterum = $bankaccount->date_rum; + $iban = $bankaccount->iban; + $bic = $bankaccount->bic; + $drum = $bankaccount->id; + $rum = $bankaccount->rum; + } + } + + $fileDebiteurSection .= $this->EnregDestinataireSEPA($obj->code, $obj->nom, $obj->address, $obj->zip, $obj->town, $obj->country_code, '', '', '', $obj->somme, $obj->reffac, $obj->idfac, $iban, $bic, $daterum, (string) $drum, $rum, $type); $this->total += $obj->somme; $i++; diff --git a/test/assets/xsd/pain.001.001.03.xsd b/test/assets/xsd/pain.001.001.03.xsd new file mode 100644 index 00000000000..8649779919c --- /dev/null +++ b/test/assets/xsd/pain.001.001.03.xsd @@ -0,0 +1,921 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/xsd/pain.008.001.02.xsd b/test/assets/xsd/pain.008.001.02.xsd new file mode 100644 index 00000000000..63359725617 --- /dev/null +++ b/test/assets/xsd/pain.008.001.02.xsd @@ -0,0 +1,879 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/phpunit/BonPrelevementTest.php b/test/phpunit/BonPrelevementTest.php index 42cf9daabab..2e77da29adc 100644 --- a/test/phpunit/BonPrelevementTest.php +++ b/test/phpunit/BonPrelevementTest.php @@ -2,6 +2,7 @@ /* Copyright (C) 2010 Laurent Destailleur * Copyright (C) 2023 Alexandre Janniaux * Copyright (C) 2024 Frédéric France + * Copyright (C) 2025 Thomas Negre * * 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 @@ -29,7 +30,12 @@ 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/lib/admin.lib.php'; require_once dirname(__FILE__).'/../../htdocs/compta/prelevement/class/bonprelevement.class.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/facture/class/facture.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/companybankaccount.class.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/bank/class/account.class.php'; require_once dirname(__FILE__).'/CommonClassTest.class.php'; if (empty($user->id)) { @@ -51,8 +57,188 @@ $langs->load("main"); */ class BonPrelevementTest extends CommonClassTest { + // --------------------------------------------------------------------------- + // Test IBANs valid (mod97 check compliant). + // Used to identify which bank account (RIB) was selected in the SEPA file. + // --------------------------------------------------------------------------- + const IBAN_A_DEFAULT = 'FR7630001007941234567890185'; // default bank account of COMPANY_A + const IBAN_A_SPECIFIC = 'FR7630001007941234567890282'; // specific bank account of COMPANY_A + const IBAN_B_DEFAULT = 'FR7630001007941234567890379'; // default bank account of COMPANY_B + const IBAN_B_SPECIFIC = 'FR7630001007941234567890476'; // specific bank account of COMPANY_B + const BIC = 'BNPAFRPPXXX'; + const XSD_PAIN_008 = __DIR__.'/../assets/xsd/pain.008.001.02.xsd'; + + // --------------------------------------------------------------------------- + // Shared fixtures created once in setUpBeforeClass(), + // inside the parent transaction (rolled back by tearDownAfterClass()). + // --------------------------------------------------------------------------- + /** @var int Row ID of COMPANY_A */ + protected static $socidA = 0; + /** @var int Row ID of COMPANY_B */ + protected static $socidB = 0; + /** @var int Row ID of the default bank account of COMPANY_A */ + protected static $ribADefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_A */ + protected static $ribASpecificId = 0; + /** @var int Row ID of the default bank account of COMPANY_B */ + protected static $ribBDefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_B */ + protected static $ribBSpecificId = 0; + /** @var int Row ID of the issuer bank account (llx_bank_account) */ + protected static $fkBankAccount = 0; + /** @var string Error message collected in setUpBeforeClass() if a fixture failed to be created */ + protected static $setUpError = ''; + /** @var ?Societe Global $mysoc as it was before this test class forced it into a SEPA country */ + protected static $savmysoc; + /** - * testBonPrevelementCreate + * setUpBeforeClass + * + * Creates shared fixtures for all tests in this class: + * - COMPANY_A and COMPANY_B (French customer third parties) + * - Two bank accounts per company (one default, one specific) + * - One issuer bank account used for BonPrelevement generation + * + * Everything is created inside the parent transaction; + * the final rollback in tearDownAfterClass() removes all this data. + * + * @return void + */ + public static function setUpBeforeClass(): void + { + global $db, $user, $mysoc; + parent::setUpBeforeClass(); // Opens the parent transaction ($db->begin()) + + // BonPrelevement::generate() requires $mysoc to be in a SEPA country (global $mysoc, + // not restored between test classes by CommonClassTest). Some other test class run + // earlier in the same PHPUnit process (e.g. PricesTest) may have left it on a + // non-SEPA country, so force it here and restore it in tearDownAfterClass(). + self::$savmysoc = clone $mysoc; + $mysoc->country_code = 'FR'; + $mysoc->country_id = 1; + + // Enable the prelevement module if not already active + if (!isModEnabled('prelevement')) { + activateModule('modPrelevement', 1, 1); + } + + // ------------------------------------------------------------------ + // COMPANY_A: French customer third party + // ------------------------------------------------------------------ + $socA = new Societe($db); + $socA->name = 'BonPrelevTest CompanyA'; + $socA->client = 1; + $socA->country_id = 1; // France (rowid=1 in c_country) + $socA->code_client = -1; // -1 = auto-generate customer code + self::$socidA = (int) $socA->create($user); + if (self::$socidA <= 0) { + self::$setUpError .= 'Societe::create() (COMPANY_A) failed: '.$socA->errorsToString().' '; + } + + // RIB_A_DEFAULT: first bank account for COMPANY_A, will be the default (default_rib=1) + $ribADef = new CompanyBankAccount($db); + $ribADef->socid = self::$socidA; + $ribADef->type = 'ban'; + $ribADef->iban = self::IBAN_A_DEFAULT; + $ribADef->bic = self::BIC; + $ribADef->rum = 'RUM-A-DEF-01'; + $ribADef->date_rum = dol_now(); + $ribADef->frstrecur = 'RCUR'; + $ribADef->default_rib = 1; + self::$ribADefaultId = (int) $ribADef->create($user); // minimal INSERT + $ribADef->update($user); // persists iban, bic, rum, date_rum, default_rib + + // RIB_A_SPECIFIC: second bank account for COMPANY_A, non-default + $ribASpec = new CompanyBankAccount($db); + $ribASpec->socid = self::$socidA; + $ribASpec->type = 'ban'; + $ribASpec->iban = self::IBAN_A_SPECIFIC; + $ribASpec->bic = self::BIC; + $ribASpec->rum = 'RUM-A-SPEC-01'; + $ribASpec->date_rum = dol_now(); + $ribASpec->frstrecur = 'RCUR'; + $ribASpec->default_rib = 0; + self::$ribASpecificId = (int) $ribASpec->create($user); + $ribASpec->update($user); + + // ------------------------------------------------------------------ + // COMPANY_B: same structure, distinct IBANs + // ------------------------------------------------------------------ + $socB = new Societe($db); + $socB->name = 'BonPrelevTest CompanyB'; + $socB->client = 1; + $socB->country_id = 1; + $socB->code_client = -1; // -1 = auto-generate customer code + self::$socidB = (int) $socB->create($user); + if (self::$socidB <= 0) { + self::$setUpError .= 'Societe::create() (COMPANY_B) failed: '.$socB->errorsToString().' '; + } + + $ribBDef = new CompanyBankAccount($db); + $ribBDef->socid = self::$socidB; + $ribBDef->type = 'ban'; + $ribBDef->iban = self::IBAN_B_DEFAULT; + $ribBDef->bic = self::BIC; + $ribBDef->rum = 'RUM-B-DEF-01'; + $ribBDef->date_rum = dol_now(); + $ribBDef->frstrecur = 'RCUR'; + $ribBDef->default_rib = 1; + self::$ribBDefaultId = (int) $ribBDef->create($user); + $ribBDef->update($user); + + $ribBSpec = new CompanyBankAccount($db); + $ribBSpec->socid = self::$socidB; + $ribBSpec->type = 'ban'; + $ribBSpec->iban = self::IBAN_B_SPECIFIC; + $ribBSpec->bic = self::BIC; + $ribBSpec->rum = 'RUM-B-SPEC-01'; + $ribBSpec->date_rum = dol_now(); + $ribBSpec->frstrecur = 'RCUR'; + $ribBSpec->default_rib = 0; + self::$ribBSpecificId = (int) $ribBSpec->create($user); + $ribBSpec->update($user); + + // ------------------------------------------------------------------ + // Issuer bank account (llx_bank_account) passed as fk_bank_account + // ------------------------------------------------------------------ + $account = new Account($db); + $account->ref = 'BONPRELTEST'; // max 12 chars (llx_bank_account.ref is varchar(12)) + $account->label = 'BonPrelevTest Issuer'; + $account->country_id = 1; // France + $account->date_solde = dol_now(); + $account->iban = 'FR7630001007941234567890573'; // valid IBAN, not used in assertions + $account->bic = self::BIC; + $account->ics = 'FR77ZZZ123456789'; // SEPA Creditor Identifier (ICS) + $account->owner_name = 'TestCorp'; + $account->currency_code = 'EUR'; + self::$fkBankAccount = (int) $account->create($user); + if (self::$fkBankAccount <= 0) { + self::$setUpError .= 'Account::create() (issuer) failed: '.$account->errorsToString().' '; + } + } + + /** + * tearDownAfterClass + * + * Restores the global $mysoc forced in setUpBeforeClass() before rolling back + * the parent transaction, so this class does not leak state to test classes + * that run after it in the same PHPUnit process. + * + * @return void + */ + public static function tearDownAfterClass(): void + { + global $mysoc; + $mysoc = self::$savmysoc; + + parent::tearDownAfterClass(); // Rolls back the parent transaction ($db->rollback()) + } + + /** + * testBonPrelevementCreate + * + * Non-regression test: verifies that create() in simulation mode + * with no pending payment requests returns 0 (no requests processed, no error). * * @return int */ @@ -85,6 +271,472 @@ class BonPrelevementTest extends CommonClassTest // Test SEPA file + return $result; + } + + /** + * testTwoCompaniesSimpleRib + * + * Verifies that when two different companies each have one invoice with a + * specific bank account forced in the direct debit request, the generated + * SEPA file contains the forced IBAN for each transaction (not the + * company's default bank account.) + * + * Scenario: + * INV_A (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B (300) -> request with RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testTwoCompaniesSimpleRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedInvoice(self::$socidB, 300.0); + + // Link each invoice to its specific bank account (not the default) + $demAId = $this->createPaymentRequest($facA, 100.0, self::$ribASpecificId); + $demBId = $this->createPaymentRequest($facB, 300.0, self::$ribBSpecificId); + + // Generate the direct debit order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'COMPANY_A invoice must use IBAN_A_SPECIFIC (forced bank account)'); + // Invoice B must use COMPANY_B's specific IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'COMPANY_B invoice must use IBAN_B_SPECIFIC (forced bank account)'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoCompaniesDefaultRib + * + * Verifies that when two different companies each have one invoice with no + * forced bank account in the direct debit request (fk_societe_rib IS NULL), + * the generated SEPA file contains each company's default IBAN (default_rib=1). + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT + * INV_B (300) -> request with no forced RIB -> expects IBAN_B_DEFAULT + * + * @return void + */ + public function testTwoCompaniesDefaultRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedInvoice(self::$socidB, 300.0); + + // No forced bank account: fk_societe_rib will be NULL -> default RIB used + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); + + // Generate the direct debit order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT (default_rib=1, no forced account)'); + // Invoice B must use COMPANY_B's default IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'COMPANY_B invoice must use IBAN_B_DEFAULT (default_rib=1, no forced account)'); + // No specific IBAN must appear in this order + $this->assertArrayNotHasKey(self::IBAN_A_SPECIFIC, $ibanAmounts, + 'IBAN_A_SPECIFIC must not appear when no RIB is forced for COMPANY_A'); + $this->assertArrayNotHasKey(self::IBAN_B_SPECIFIC, $ibanAmounts, + 'IBAN_B_SPECIFIC must not appear when no RIB is forced for COMPANY_B'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoCompaniesDefaultRibFilteredToFirst + * + * Verifies that when $dids is limited to only the first payment request, + * only COMPANY_A's invoice appears in the SEPA file (even though a second + * request exists in the database for COMPANY_B.) + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT (in $dids) + * INV_B (300) -> request with no forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testTwoCompaniesDefaultRibFilteredToFirst() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Create one invoice for each company + $facA = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedInvoice(self::$socidB, 300.0); + + // Create both payment requests (no forced bank account for either) + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); // exists in DB but excluded from $dids + + // Generate the order with ONLY the first request ($demAId) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demAId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // COMPANY_A must appear with its default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT when no RIB is forced'); + // COMPANY_B must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneCompanyTwoRibs + * + * Verifies that for the same company with two invoices, the bank account + * selection is correct based on whether a specific account is forced: + * - A request with a forced bank account uses that specific IBAN. + * - A request without a forced account (ribId=0) falls back to the + * company's default bank account (default_rib=1). + * No IBAN from COMPANY_B should appear in the file. + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_A2 (200) -> request without forced RIB -> expects IBAN_A_DEFAULT + * + * @return void + */ + public function testOneCompanyTwoRibs() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced (not the default) + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (ribId=0 -> default will be used) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with both COMPANY_A requests only + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demA1Id, $demA2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1: forced bank account -> must use IBAN_A_SPECIFIC + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2: no forced account -> must fall back to default IBAN_A_DEFAULT + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1)'); + // No IBAN from COMPANY_B must appear in this order + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B bank account must not appear in a COMPANY_A-only order'); + $this->assertEquals(300.0, $bon->total); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneCompanyTwoRibsSpecificOnly + * + * Verifies that when $dids contains only the request with the specific bank + * account, only that invoice appears in the SEPA file (even though a second + * request using the default RIB exists in the database.) + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC (in $dids) + * INV_A2 (200) -> request without forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testOneCompanyTwoRibsSpecificOnly() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (exists in DB but excluded from $dids) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with ONLY the specific-RIB request ($demA1Id) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demA1Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1 must appear with the forced specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2 must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_A_DEFAULT, $ibanAmounts, + 'INV_A2 must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testMixedOrderFourInvoices + * + * Verifies that bank account assignment remains correct when direct debit + * requests from two companies are interleaved (A1, B1, A2, B2). + * The test guards against cross-contamination between companies or between + * consecutive requests. + * + * Scenario (requests created in interleaved order): + * INV_A1 (100) -> forced: RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B1 (300) -> no forced RIB -> default -> expects IBAN_B_DEFAULT + * INV_A2 (200) -> no forced RIB -> default -> expects IBAN_A_DEFAULT + * INV_B2 (400) -> forced: RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testMixedOrderFourInvoices() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Interleaved invoices: A1, B1, A2, B2 + $facA1 = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB1 = $this->createValidatedInvoice(self::$socidB, 300.0); + $facA2 = $this->createValidatedInvoice(self::$socidA, 200.0); + $facB2 = $this->createValidatedInvoice(self::$socidB, 400.0); + + // Interleaved requests: forced, no RIB, no RIB, forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); // forced + $demB1Id = $this->createPaymentRequest($facB1, 300.0); // -> RIB_B_DEFAULT + $demA2Id = $this->createPaymentRequest($facA2, 200.0); // -> RIB_A_DEFAULT + $demB2Id = $this->createPaymentRequest($facB2, 400.0, self::$ribBSpecificId); // forced + + // Generate the order with all four requests in interleaved order + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demA1Id, $demB1Id, $demA2Id, $demB2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Verify each invoice is matched to the correct IBAN with no cross-contamination + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'INV_B1 without forced account must use IBAN_B_DEFAULT (default_rib=1 of COMPANY_B)'); + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1 of COMPANY_A)'); + $this->assertEquals(400.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'INV_B2 with forced bank account must use IBAN_B_SPECIFIC'); + $this->assertEquals(1000.0, $bon->total, + 'Order total must equal the sum of all four invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Creates and validates a customer invoice for a given company and amount. + * The invoice contains a single service line at 0% VAT. + * + * @param int $socid Row ID of the third party + * @param float $amount Pre-tax amount of the line (= total incl. tax with 0% VAT) + * @return Facture + */ + private function createValidatedInvoice(int $socid, float $amount): Facture + { + global $user, $db; + + $fac = new Facture($db); + $fac->socid = $socid; + $fac->date = dol_now(); + $fac->cond_reglement_code = 'RECEP'; + $fac->mode_reglement_code = 'PRE'; // direct debit payment mode + + $result = $fac->create($user); + $this->assertGreaterThan(0, $result, 'Facture::create() failed: '.$fac->errorsToString()); + + // Service line at 0% VAT so total_ttc = $amount + $fac->addline('Service test', $amount, 1, 0); + + $result = $fac->validate($user); + $this->assertGreaterThanOrEqual(0, $result, 'Facture::validate() failed: '.$fac->errorsToString()); + + return $fac; + } + + /** + * Creates a direct debit payment request (demande_prelevement) for a validated invoice. + * If $ribId is provided, the request stores that specific bank account in fk_societe_rib. + * Otherwise (ribId=0), the company's default bank account will be used. + * + * @param Facture $fac Validated invoice + * @param float $amount Requested amount + * @param int $ribId Row ID of the bank account to force (0 = use default) + * @return int Row ID of the created entry in llx_prelevement_demande + */ + private function createPaymentRequest(Facture $fac, float $amount, int $ribId = 0): int + { + global $user, $db; + + // Insert a row in llx_prelevement_demande with fk_societe_rib = $ribId + $result = $fac->demande_prelevement($user, $amount, 'direct-debit', 'facture', 0, $ribId); + $this->assertEquals(1, $result, 'demande_prelevement() failed for invoice #'.$fac->id.': '.$fac->errorsToString()); + + // Retrieve the row ID of the freshly inserted request + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."prelevement_demande WHERE fk_facture = ".((int) $fac->id)." AND traite = 0 ORDER BY rowid DESC LIMIT 1"; + $resql = $db->query($sql); + $obj = $db->fetch_object($resql); + $this->assertNotNull($obj, 'Payment request not found in DB for invoice #'.$fac->id); + + return (int) $obj->rowid; + } + + /** + * Asserts that a generated SEPA XML file validates against the pain.008.001.02 XSD schema. + * + * @param string $filename Path to the SEPA XML file + * @return void + */ + private function assertSepaXmlValid(string $filename): void + { + $this->assertFileExists($filename, 'SEPA XML file does not exist: '.$filename); + $this->assertFileExists(self::XSD_PAIN_008, 'XSD schema file not found: '.self::XSD_PAIN_008); + + $dom = new DOMDocument(); + $loaded = $dom->load($filename); + $this->assertTrue($loaded, 'DOMDocument failed to load SEPA XML: '.$filename); + + libxml_use_internal_errors(true); + $valid = $dom->schemaValidate(self::XSD_PAIN_008); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors(false); + + $messages = array(); + foreach ($errors as $error) { + $messages[] = trim($error->message).' (line '.$error->line.')'; + } + $this->assertTrue($valid, 'SEPA XML does not validate against pain.008.001.02 XSD: '.implode('; ', $messages)); + } + + /** + * Parses a generated SEPA pain.008 XML file and returns an associative + * array of IBAN => amount for each DrctDbtTxInf transaction element. + * + * @param string $filename Path to the SEPA XML file + * @return array Array keyed by IBAN with transaction amount as value + */ + private function parseSepaIbanAmounts(string $filename): array + { + $this->assertFileExists($filename, 'SEPA XML file was not created: '.$filename); + + $xml = simplexml_load_file($filename); + $this->assertNotFalse($xml, 'Failed to parse SEPA XML file: '.$filename); + + // Register the SEPA direct-debit namespace (pain.008.001.02) + $ns = 'urn:iso:std:iso:20022:tech:xsd:pain.008.001.02'; + $xml->registerXPathNamespace('ns', $ns); + + $result = array(); + foreach ($xml->xpath('//ns:DrctDbtTxInf') as $txInf) { + $txInf->registerXPathNamespace('ns', $ns); + // Debtor IBAN (the third party being debited) + $ibanNodes = $txInf->xpath('ns:DbtrAcct/ns:Id/ns:IBAN'); + // Transaction amount + $amountNodes = $txInf->xpath('ns:InstdAmt'); + + if (!empty($ibanNodes) && !empty($amountNodes)) { + $iban = (string) $ibanNodes[0]; + $amount = (float) $amountNodes[0]; + $result[$iban] = $amount; + } + } + return $result; } } diff --git a/test/phpunit/BonVirementTest.php b/test/phpunit/BonVirementTest.php new file mode 100644 index 00000000000..e0cc051da06 --- /dev/null +++ b/test/phpunit/BonVirementTest.php @@ -0,0 +1,695 @@ + + * + * 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/BonVirementTest.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/core/lib/admin.lib.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/prelevement/class/bonprelevement.class.php'; +require_once dirname(__FILE__).'/../../htdocs/fourn/class/fournisseur.facture.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/companybankaccount.class.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/bank/class/account.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; + +$langs->load("main"); + + +/** + * Class for PHPUnit tests + * + * @backupGlobals disabled + * @backupStaticAttributes enabled + * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased. + */ +class BonVirementTest extends CommonClassTest +{ + // --------------------------------------------------------------------------- + // Test IBANs valid (mod97 check compliant). + // Distinct from BonPrelevementTest IBANs to avoid collisions when both + // suites run against the same database instance. + // --------------------------------------------------------------------------- + const IBAN_A_DEFAULT = 'FR7630001007941234567890670'; // default bank account of COMPANY_A + const IBAN_A_SPECIFIC = 'FR7630001007941234567890767'; // specific bank account of COMPANY_A + const IBAN_B_DEFAULT = 'FR7630001007941234567890864'; // default bank account of COMPANY_B + const IBAN_B_SPECIFIC = 'FR7630001007941234567890961'; // specific bank account of COMPANY_B + const BIC = 'BNPAFRPPXXX'; + const XSD_PAIN_001 = __DIR__.'/../assets/xsd/pain.001.001.03.xsd'; + + // --------------------------------------------------------------------------- + // Shared fixtures created once in setUpBeforeClass(), + // inside the parent transaction (rolled back by tearDownAfterClass()). + // --------------------------------------------------------------------------- + /** @var int Row ID of COMPANY_A */ + protected static $socidA = 0; + /** @var int Row ID of COMPANY_B */ + protected static $socidB = 0; + /** @var int Row ID of the default bank account of COMPANY_A */ + protected static $ribADefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_A */ + protected static $ribASpecificId = 0; + /** @var int Row ID of the default bank account of COMPANY_B */ + protected static $ribBDefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_B */ + protected static $ribBSpecificId = 0; + /** @var int Row ID of the issuer bank account (llx_bank_account) */ + protected static $fkBankAccount = 0; + + /** + * setUpBeforeClass + * + * Creates shared fixtures for all tests in this class: + * - COMPANY_A and COMPANY_B (French supplier third parties) + * - Two bank accounts per company (one default, one specific) + * - One issuer bank account used for BonPrelevement generation + * + * Everything is created inside the parent transaction; + * the final rollback in tearDownAfterClass() removes all this data. + * + * @return void + */ + public static function setUpBeforeClass(): void + { + global $db, $user; + parent::setUpBeforeClass(); // Opens the parent transaction ($db->begin()) + + // Enable required modules if not already active + if (!isModEnabled('paymentbybanktransfer')) { + activateModule('modPaymentByBankTransfer', 1, 1); + } + if (!isModEnabled('fournisseur')) { + activateModule('modFournisseur', 1, 1); + } + + // ------------------------------------------------------------------ + // COMPANY_A: French supplier third party + // ------------------------------------------------------------------ + $socA = new Societe($db); + $socA->name = 'BonVirementTest CompanyA'; + $socA->fournisseur = 1; + $socA->country_id = 1; // France (rowid=1 in c_country) + $socA->code_fournisseur = -1; // -1 = auto-generate supplier code + self::$socidA = (int) $socA->create($user); + + // RIB_A_DEFAULT: first bank account for COMPANY_A, will be the default (default_rib=1) + $ribADef = new CompanyBankAccount($db); + $ribADef->socid = self::$socidA; + $ribADef->type = 'ban'; + $ribADef->iban = self::IBAN_A_DEFAULT; + $ribADef->bic = self::BIC; + $ribADef->default_rib = 1; + self::$ribADefaultId = (int) $ribADef->create($user); + $ribADef->update($user); + + // RIB_A_SPECIFIC: second bank account for COMPANY_A, non-default + $ribASpec = new CompanyBankAccount($db); + $ribASpec->socid = self::$socidA; + $ribASpec->type = 'ban'; + $ribASpec->iban = self::IBAN_A_SPECIFIC; + $ribASpec->bic = self::BIC; + $ribASpec->default_rib = 0; + self::$ribASpecificId = (int) $ribASpec->create($user); + $ribASpec->update($user); + + // ------------------------------------------------------------------ + // COMPANY_B: same structure, distinct IBANs + // ------------------------------------------------------------------ + $socB = new Societe($db); + $socB->name = 'BonVirementTest CompanyB'; + $socB->fournisseur = 1; + $socB->country_id = 1; + $socB->code_fournisseur = -1; + self::$socidB = (int) $socB->create($user); + + $ribBDef = new CompanyBankAccount($db); + $ribBDef->socid = self::$socidB; + $ribBDef->type = 'ban'; + $ribBDef->iban = self::IBAN_B_DEFAULT; + $ribBDef->bic = self::BIC; + $ribBDef->default_rib = 1; + self::$ribBDefaultId = (int) $ribBDef->create($user); + $ribBDef->update($user); + + $ribBSpec = new CompanyBankAccount($db); + $ribBSpec->socid = self::$socidB; + $ribBSpec->type = 'ban'; + $ribBSpec->iban = self::IBAN_B_SPECIFIC; + $ribBSpec->bic = self::BIC; + $ribBSpec->default_rib = 0; + self::$ribBSpecificId = (int) $ribBSpec->create($user); + $ribBSpec->update($user); + + // ------------------------------------------------------------------ + // Issuer bank account (llx_bank_account) passed as fk_bank_account + // ------------------------------------------------------------------ + $account = new Account($db); + $account->ref = 'BONVIR-TEST'; + $account->label = 'BonVirementTest Issuer'; + $account->country_id = 1; // France + $account->date_solde = dol_now(); + $account->iban = 'FR7630001007941234567891058'; // valid IBAN, not used in assertions + $account->bic = self::BIC; + $account->ics = 'FR77ZZZ123456789'; // SEPA identifier (also used for bank-transfer) + $account->ics_transfer = 'FR77ZZZ123456789'; // used when SEPA_USE_IDS is enabled + $account->owner_name = 'TestCorp'; + $account->currency_code = 'EUR'; + self::$fkBankAccount = (int) $account->create($user); + } + + /** + * Constructor + * We save global variables into local variables + * + * @param string $name Name + * @param array $data Test data + * @param string $dataName Test data name. + */ + public function __construct($name = null, array $data = array(), $dataName = '') + { + parent::__construct($name, $data, $dataName); + + // This const is tested in prelevement_check_config(), it SHOULD be set. + $this->savconf->global->PAYMENTBYBANKTRANSFER_ID_BANKACCOUNT = self::$fkBankAccount; + } + + /** + * testBonVirementCreate + * + * Non-regression test: verifies that create() in simulation mode + * with no pending payment requests returns 0 (no requests processed, no error). + * + * @return int + */ + public function testBonVirementCreate() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $localobject = new BonPrelevement($db); + $result = $localobject->Create(0, 0, 'simu'); + + print __METHOD__." result=".$result."\n"; + $this->assertEquals($result, 0); + + return $result; + } + + /** + * testTwoSuppliersSpecificRib + * + * Verifies that when two different suppliers each have one invoice with a + * specific bank account forced in the bank transfer request, the generated + * SEPA file contains the forced IBAN for each transaction (not the + * supplier's default bank account.) + * + * Scenario: + * INV_A (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B (300) -> request with RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testTwoSuppliersSpecificRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + + // Link each invoice to its specific bank account (not the default) + $demAId = $this->createPaymentRequest($facA, 100.0, self::$ribASpecificId); + $demBId = $this->createPaymentRequest($facB, 300.0, self::$ribBSpecificId); + + // Generate the bank transfer order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'COMPANY_A invoice must use IBAN_A_SPECIFIC (forced bank account)'); + // Invoice B must use COMPANY_B's specific IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'COMPANY_B invoice must use IBAN_B_SPECIFIC (forced bank account)'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoSuppliersDefaultRib + * + * Verifies that when two different suppliers each have one invoice with no + * forced bank account in the bank transfer request (fk_societe_rib IS NULL), + * the generated SEPA file contains each supplier's default IBAN (default_rib=1). + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT + * INV_B (300) -> request with no forced RIB -> expects IBAN_B_DEFAULT + * + * @return void + */ + public function testTwoSuppliersDefaultRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + + // No forced bank account: fk_societe_rib will be NULL -> default RIB used + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); + + // Generate the bank transfer order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT (default_rib=1, no forced account)'); + // Invoice B must use COMPANY_B's default IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'COMPANY_B invoice must use IBAN_B_DEFAULT (default_rib=1, no forced account)'); + // No specific IBAN must appear in this order + $this->assertArrayNotHasKey(self::IBAN_A_SPECIFIC, $ibanAmounts, + 'IBAN_A_SPECIFIC must not appear when no RIB is forced for COMPANY_A'); + $this->assertArrayNotHasKey(self::IBAN_B_SPECIFIC, $ibanAmounts, + 'IBAN_B_SPECIFIC must not appear when no RIB is forced for COMPANY_B'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoSuppliersDefaultRibFilteredToFirst + * + * Verifies that when $dids is limited to only the first payment request, + * only COMPANY_A's invoice appears in the SEPA file (even though a second + * request exists in the database for COMPANY_B.) + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT (in $dids) + * INV_B (300) -> request with no forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testTwoSuppliersDefaultRibFilteredToFirst() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Create one invoice for each supplier + $facA = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + + // Create both payment requests (no forced bank account for either) + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); // exists in DB but excluded from $dids + + // Generate the order with ONLY the first request ($demAId) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demAId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // COMPANY_A must appear with its default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT when no RIB is forced'); + // COMPANY_B must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneSupplierTwoRibs + * + * Verifies that for the same supplier with two invoices, the bank account + * selection is correct based on whether a specific account is forced: + * - A request with a forced bank account uses that specific IBAN. + * - A request without a forced account (ribId=0) falls back to the + * supplier's default bank account (default_rib=1). + * No IBAN from COMPANY_B should appear in the file. + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_A2 (200) -> request without forced RIB -> expects IBAN_A_DEFAULT + * + * @return void + */ + public function testOneSupplierTwoRibs() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedSupplierInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced (not the default) + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (ribId=0 -> default will be used) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with both COMPANY_A requests only + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demA1Id, $demA2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1: forced bank account -> must use IBAN_A_SPECIFIC + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2: no forced account -> must fall back to default IBAN_A_DEFAULT + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1)'); + // No IBAN from COMPANY_B must appear in this order + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B bank account must not appear in a COMPANY_A-only order'); + $this->assertEquals(300.0, $bon->total); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneSupplierTwoRibsSpecificOnly + * + * Verifies that when $dids contains only the request with the specific bank + * account, only that invoice appears in the SEPA file (even though a second + * request using the default RIB exists in the database.) + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC (in $dids) + * INV_A2 (200) -> request without forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testOneSupplierTwoRibsSpecificOnly() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedSupplierInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (exists in DB but excluded from $dids) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with ONLY the specific-RIB request ($demA1Id) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demA1Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1 must appear with the forced specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2 must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_A_DEFAULT, $ibanAmounts, + 'INV_A2 must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testMixedOrderFourInvoices + * + * Verifies that bank account assignment remains correct when bank transfer + * requests from two suppliers are interleaved (A1, B1, A2, B2). + * The test guards against cross-contamination between suppliers or between + * consecutive requests. + * + * Scenario (requests created in interleaved order): + * INV_A1 (100) -> forced: RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B1 (300) -> no forced RIB -> default -> expects IBAN_B_DEFAULT + * INV_A2 (200) -> no forced RIB -> default -> expects IBAN_A_DEFAULT + * INV_B2 (400) -> forced: RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testMixedOrderFourInvoices() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Interleaved invoices: A1, B1, A2, B2 + $facA1 = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB1 = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + $facA2 = $this->createValidatedSupplierInvoice(self::$socidA, 200.0); + $facB2 = $this->createValidatedSupplierInvoice(self::$socidB, 400.0); + + // Interleaved requests: forced, no RIB, no RIB, forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); // forced + $demB1Id = $this->createPaymentRequest($facB1, 300.0); // -> RIB_B_DEFAULT + $demA2Id = $this->createPaymentRequest($facA2, 200.0); // -> RIB_A_DEFAULT + $demB2Id = $this->createPaymentRequest($facB2, 400.0, self::$ribBSpecificId); // forced + + // Generate the order with all four requests in interleaved order + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demA1Id, $demB1Id, $demA2Id, $demB2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Verify each invoice is matched to the correct IBAN with no cross-contamination + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'INV_B1 without forced account must use IBAN_B_DEFAULT (default_rib=1 of COMPANY_B)'); + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1 of COMPANY_A)'); + $this->assertEquals(400.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'INV_B2 with forced bank account must use IBAN_B_SPECIFIC'); + $this->assertEquals(1000.0, $bon->total, + 'Order total must equal the sum of all four invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Creates and validates a supplier invoice for a given company and amount. + * The invoice contains a single service line at 0% VAT. + * + * @param int $socid Row ID of the third party + * @param float $amount Pre-tax amount of the line (= total incl. tax with 0% VAT) + * @return FactureFournisseur + */ + private function createValidatedSupplierInvoice(int $socid, float $amount): FactureFournisseur + { + global $user, $db; + + $fac = new FactureFournisseur($db); + $fac->socid = $socid; + $fac->date = dol_now(); + $fac->cond_reglement_code = 'RECEP'; + $fac->mode_reglement_code = 'VIR'; // bank transfer payment mode + $fac->ref_supplier = 'SUPP-TEST-'.$socid.'-'.$amount.'-'.uniqid(); + + $result = $fac->create($user); + $this->assertGreaterThan(0, $result, 'FactureFournisseur::create() failed: '.$fac->error); + + // Service line at 0% VAT so total_ttc = $amount + // Signature: addline(desc, pu, txtva, txlocaltax1, txlocaltax2, qty, ...) + $fac->addline('Service test', $amount, 0, 0, 0, 1); + + $result = $fac->validate($user); + $this->assertGreaterThanOrEqual(0, $result, 'FactureFournisseur::validate() failed: '.$fac->error); + + return $fac; + } + + /** + * Creates a bank transfer payment request (demande_prelevement) for a validated supplier invoice. + * If $ribId is provided, the request stores that specific bank account in fk_societe_rib. + * Otherwise (ribId=0), the supplier's default bank account will be used. + * + * @param FactureFournisseur $fac Validated supplier invoice + * @param float $amount Requested amount + * @param int $ribId Row ID of the bank account to force (0 = use default) + * @return int Row ID of the created entry in llx_prelevement_demande + */ + private function createPaymentRequest(FactureFournisseur $fac, float $amount, int $ribId = 0): int + { + global $user, $db; + + // Insert a row in llx_prelevement_demande with fk_facture_fourn and fk_societe_rib = $ribId + $result = $fac->demande_prelevement($user, $amount, 'bank-transfer', 'supplier_invoice', 0, $ribId); + $this->assertEquals(1, $result, 'demande_prelevement() failed for invoice #'.$fac->id.': '.$fac->error); + + // Retrieve the row ID of the freshly inserted request + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."prelevement_demande WHERE fk_facture_fourn = ".((int) $fac->id)." AND traite = 0 ORDER BY rowid DESC LIMIT 1"; + $resql = $db->query($sql); + $obj = $db->fetch_object($resql); + $this->assertNotNull($obj, 'Payment request not found in DB for invoice #'.$fac->id); + + return (int) $obj->rowid; + } + + /** + * Asserts that a generated SEPA XML file validates against the pain.001.001.03 XSD schema. + * + * @param string $filename Path to the SEPA XML file + * @return void + */ + private function assertSepaXmlValid(string $filename): void + { + $this->assertFileExists($filename, 'SEPA XML file does not exist: '.$filename); + $this->assertFileExists(self::XSD_PAIN_001, 'XSD schema file not found: '.self::XSD_PAIN_001); + + $dom = new DOMDocument(); + $loaded = $dom->load($filename); + $this->assertTrue($loaded, 'DOMDocument failed to load SEPA XML: '.$filename); + + libxml_use_internal_errors(true); + $valid = $dom->schemaValidate(self::XSD_PAIN_001); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors(false); + + $messages = array(); + foreach ($errors as $error) { + $messages[] = trim($error->message).' (line '.$error->line.')'; + } + $this->assertTrue($valid, 'SEPA XML does not validate against pain.001.001.03 XSD: '.implode('; ', $messages)); + } + + /** + * Parses a generated SEPA pain.001 XML file and returns an associative + * array of IBAN => amount for each CdtTrfTxInf transaction element. + * + * @param string $filename Path to the SEPA XML file + * @return array Array keyed by IBAN with transaction amount as value + */ + private function parseSepaIbanAmounts(string $filename): array + { + $this->assertFileExists($filename, 'SEPA XML file was not created: '.$filename); + + $xml = simplexml_load_file($filename); + $this->assertNotFalse($xml, 'Failed to parse SEPA XML file: '.$filename); + + // Register the SEPA credit-transfer namespace (pain.001.001.03) + $ns = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'; + $xml->registerXPathNamespace('ns', $ns); + + $result = array(); + foreach ($xml->xpath('//ns:CdtTrfTxInf') as $txInf) { + $txInf->registerXPathNamespace('ns', $ns); + // Creditor IBAN (the supplier being paid) + $ibanNodes = $txInf->xpath('ns:CdtrAcct/ns:Id/ns:IBAN'); + // Transaction amount (nested under Amt element in pain.001) + $amountNodes = $txInf->xpath('ns:Amt/ns:InstdAmt'); + + if (!empty($ibanNodes) && !empty($amountNodes)) { + $iban = (string) $ibanNodes[0]; + $amount = (float) $amountNodes[0]; + $result[$iban] = $amount; + } + } + + return $result; + } +} From 9467f22c5ab5bf4cc42b425367b648b0577e0072 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 4 Jul 2026 13:33:17 +0200 Subject: [PATCH 050/307] Fix option to set max short list --- htdocs/comm/index.php | 3 ++- htdocs/comm/propal/index.php | 3 ++- htdocs/commande/index.php | 3 ++- htdocs/compta/facture/index.php | 3 ++- htdocs/compta/index.php | 10 +++++----- htdocs/contrat/index.php | 2 +- htdocs/don/index.php | 2 +- htdocs/eventorganization/eventorganizationindex.php | 4 +++- htdocs/expensereport/index.php | 2 +- htdocs/fichinter/index.php | 2 +- htdocs/fourn/class/fournisseur.facture.class.php | 2 +- htdocs/fourn/commande/index.php | 2 +- htdocs/fourn/facture/index.php | 3 ++- htdocs/hrm/index.php | 2 +- htdocs/modulebuilder/template/mymoduleindex.php | 3 ++- htdocs/mrp/index.php | 2 +- htdocs/product/index.php | 2 +- htdocs/projet/index.php | 2 +- htdocs/recruitment/index.php | 2 +- htdocs/ticket/index.php | 2 +- htdocs/user/home.php | 2 +- 21 files changed, 33 insertions(+), 25 deletions(-) diff --git a/htdocs/comm/index.php b/htdocs/comm/index.php index 5784c832162..3a8bab1a967 100644 --- a/htdocs/comm/index.php +++ b/htdocs/comm/index.php @@ -74,7 +74,8 @@ if (!empty($user->socid) && $user->socid > 0) { $total = 0; -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); + $maxofloop = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', 500); $now = dol_now(); diff --git a/htdocs/comm/propal/index.php b/htdocs/comm/propal/index.php index 4438bed9e71..d50c9ec9737 100644 --- a/htdocs/comm/propal/index.php +++ b/htdocs/comm/propal/index.php @@ -47,7 +47,8 @@ $hookmanager->initHooks(array('proposalindex')); $langs->loadLangs(array('propal', 'companies')); $now = dol_now(); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); + +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); // Security check $socid = GETPOSTINT('socid'); diff --git a/htdocs/commande/index.php b/htdocs/commande/index.php index cf33b2d22a5..3b75ed7b108 100644 --- a/htdocs/commande/index.php +++ b/htdocs/commande/index.php @@ -62,7 +62,8 @@ if ($user->socid > 0) { } // Maximum elements of the tables -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); + $maxDraftCount = !getDolGlobalString('MAIN_MAXLIST_OVERLOAD') ? 500 : $conf->global->MAIN_MAXLIST_OVERLOAD; $maxLatestEditCount = 5; $maxOpenCount = !getDolGlobalString('MAIN_MAXLIST_OVERLOAD') ? 500 : $conf->global->MAIN_MAXLIST_OVERLOAD; diff --git a/htdocs/compta/facture/index.php b/htdocs/compta/facture/index.php index 0ea10bee8ae..0a54a35d2eb 100644 --- a/htdocs/compta/facture/index.php +++ b/htdocs/compta/facture/index.php @@ -51,7 +51,8 @@ if (!empty($user->socid) && $user->socid > 0) { } // Maximum elements of the tables -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); + $maxDraftCount = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', $max); $maxOpenCount = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', $max); diff --git a/htdocs/compta/index.php b/htdocs/compta/index.php index 59dacdd337b..36f9c6c3b2d 100644 --- a/htdocs/compta/index.php +++ b/htdocs/compta/index.php @@ -75,17 +75,17 @@ if ($user->socid > 0) { } // Maximum elements of the tables -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); -$maxDraftCount = !getDolGlobalString('MAIN_MAXLIST_OVERLOAD') ? 500 : $conf->global->MAIN_MAXLIST_OVERLOAD; +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); + $maxLatestEditCount = 5; -$maxOpenCount = !getDolGlobalString('MAIN_MAXLIST_OVERLOAD') ? 500 : $conf->global->MAIN_MAXLIST_OVERLOAD; +$maxDraftCount = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', 500); +$maxOpenCount = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', 500); +$maxofloop = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', 500); // Initialize a technical object to manage hooks. Note that conf->hooks_modules contains array $hookmanager->initHooks(array('invoiceindex')); -$maxofloop = getDolGlobalString('MAIN_MAXLIST_OVERLOAD', 500); - /* * Actions diff --git a/htdocs/contrat/index.php b/htdocs/contrat/index.php index 007a9f0fcd1..7e51b51639e 100644 --- a/htdocs/contrat/index.php +++ b/htdocs/contrat/index.php @@ -52,7 +52,7 @@ $page = GETPOSTISSET('pageplusone') ? (GETPOSTINT('pageplusone') - 1) : GETPOSTI $statut = GETPOST('statut') ? GETPOST('statut') : 1; -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); // Security check $socid = 0; diff --git a/htdocs/don/index.php b/htdocs/don/index.php index 0b0a23a6e3e..08dceca2de1 100644 --- a/htdocs/don/index.php +++ b/htdocs/don/index.php @@ -211,7 +211,7 @@ print ""; print '
'; -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); /* diff --git a/htdocs/eventorganization/eventorganizationindex.php b/htdocs/eventorganization/eventorganizationindex.php index bc7f6b2ead1..030c42952fc 100644 --- a/htdocs/eventorganization/eventorganizationindex.php +++ b/htdocs/eventorganization/eventorganizationindex.php @@ -44,7 +44,9 @@ $langs->loadLangs(array("eventorganization")); $action = GETPOST('action', 'aZ09'); $NBMAX = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); + +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); + $now = dol_now(); // Security check diff --git a/htdocs/expensereport/index.php b/htdocs/expensereport/index.php index 60e0f5be048..df9cab87e53 100644 --- a/htdocs/expensereport/index.php +++ b/htdocs/expensereport/index.php @@ -65,7 +65,7 @@ if (!$sortfield) { } $limit = GETPOSTINT('limit') ? GETPOSTINT('limit') : $conf->liste_limit; -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); // Security check $socid = GETPOSTINT('socid'); diff --git a/htdocs/fichinter/index.php b/htdocs/fichinter/index.php index c61841b6113..85352c807d1 100644 --- a/htdocs/fichinter/index.php +++ b/htdocs/fichinter/index.php @@ -63,7 +63,7 @@ if ($user->isExternalUser()) { // Load $resultboxes $resultboxes = FormOther::getBoxesArea($user, "20"); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); /* diff --git a/htdocs/fourn/class/fournisseur.facture.class.php b/htdocs/fourn/class/fournisseur.facture.class.php index fa7ad0659fa..6798db80dff 100644 --- a/htdocs/fourn/class/fournisseur.facture.class.php +++ b/htdocs/fourn/class/fournisseur.facture.class.php @@ -2898,7 +2898,7 @@ class FactureFournisseur extends CommonInvoice */ public function getNomUrl($withpicto = 0, $option = '', $max = 0, $short = 0, $moretitle = '', $notooltip = 0, $save_lastsearch_value = -1, $addlinktonotes = 0) { - global $langs, $conf, $user, $hookmanager; + global $langs, $user, $hookmanager; $result = ''; diff --git a/htdocs/fourn/commande/index.php b/htdocs/fourn/commande/index.php index d10c428ae72..b85a4aa8571 100644 --- a/htdocs/fourn/commande/index.php +++ b/htdocs/fourn/commande/index.php @@ -48,7 +48,7 @@ $langs->loadLangs(array("suppliers", "orders")); // Initialize a technical object to manage hooks. Note that conf->hooks_modules contains array $hookmanager->initHooks(array('orderssuppliersindex')); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); // Security check $orderid = GETPOST('orderid'); diff --git a/htdocs/fourn/facture/index.php b/htdocs/fourn/facture/index.php index 4a083b0d26f..97639a9108e 100644 --- a/htdocs/fourn/facture/index.php +++ b/htdocs/fourn/facture/index.php @@ -49,7 +49,8 @@ if (!empty($user->socid) && $user->socid > 0) { } // Maximum elements of the tables -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); + $maxDraftCount = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', 500); $maxLatestEditCount = 5; $maxOpenCount = getDolGlobalInt('MAIN_MAXLIST_OVERLOAD', 500); diff --git a/htdocs/hrm/index.php b/htdocs/hrm/index.php index dade725d966..3610006d5a4 100644 --- a/htdocs/hrm/index.php +++ b/htdocs/hrm/index.php @@ -77,7 +77,7 @@ if (!getDolGlobalString('MAIN_INFO_SOCIETE_NOM') || !getDolGlobalString('MAIN_IN $setupcompanynotcomplete = 0; } -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); /* diff --git a/htdocs/modulebuilder/template/mymoduleindex.php b/htdocs/modulebuilder/template/mymoduleindex.php index c05db23c1ce..981c693d430 100644 --- a/htdocs/modulebuilder/template/mymoduleindex.php +++ b/htdocs/modulebuilder/template/mymoduleindex.php @@ -76,7 +76,8 @@ $langs->loadLangs(array("mymodule@mymodule")); $action = GETPOST('action', 'aZ09'); $now = dol_now(); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); + +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); // Security check - Protection if external user $socid = GETPOSTINT('socid'); diff --git a/htdocs/mrp/index.php b/htdocs/mrp/index.php index 1c046fb9f28..6158ece8040 100644 --- a/htdocs/mrp/index.php +++ b/htdocs/mrp/index.php @@ -50,7 +50,7 @@ $langs->loadLangs(array("companies", "mrp")); // Security check $result = restrictedArea($user, 'bom|mrp'); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); /* diff --git a/htdocs/product/index.php b/htdocs/product/index.php index 4b3339f0d59..9f15b008233 100644 --- a/htdocs/product/index.php +++ b/htdocs/product/index.php @@ -86,7 +86,7 @@ if (GETPOST('addbox')) { } } -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); /* diff --git a/htdocs/projet/index.php b/htdocs/projet/index.php index d0219746621..98ee10cf370 100644 --- a/htdocs/projet/index.php +++ b/htdocs/projet/index.php @@ -60,7 +60,7 @@ if ($search_project_user == $user->id) { $sortfield = GETPOST('sortfield', 'aZ09comma'); $sortorder = GETPOST('sortorder', 'aZ09comma'); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); // Security check $socid = 0; diff --git a/htdocs/recruitment/index.php b/htdocs/recruitment/index.php index 614d32f898e..7ecefbf59f2 100644 --- a/htdocs/recruitment/index.php +++ b/htdocs/recruitment/index.php @@ -45,7 +45,7 @@ $langs->loadLangs(array("recruitment", "boxes")); $action = GETPOST('action', 'aZ09'); $NBMAX = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); $now = dol_now(); $socid = GETPOSTINT('socid'); diff --git a/htdocs/ticket/index.php b/htdocs/ticket/index.php index 5336cf54303..7688703bc65 100644 --- a/htdocs/ticket/index.php +++ b/htdocs/ticket/index.php @@ -77,7 +77,7 @@ if (!$user->hasRight('ticket', 'read') && !$user->hasRight('knowledgemanagement' accessforbidden('Not enough permissions'); } -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); $permissiontomanage = ((!getDolGlobalString('MAIN_USE_ADVANCED_PERMS') && $user->hasRight('ticket', 'write')) || (getDolGlobalString('MAIN_USE_ADVANCED_PERMS') && $user->hasRight('expedition', 'ticket', 'manage_advance'))); // What is this permission for ? diff --git a/htdocs/user/home.php b/htdocs/user/home.php index 30d61a6090e..ca88da0bdad 100644 --- a/htdocs/user/home.php +++ b/htdocs/user/home.php @@ -82,7 +82,7 @@ if (GETPOST('addbox')) { } } -$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); +$max = getDolUserInt('MAIN_SIZE_SHORTLIST_LIMIT', getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5)); /* From cd412382ca8b953209b33af974627c67af917194 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 4 Jul 2026 13:36:56 +0200 Subject: [PATCH 051/307] css --- htdocs/projet/index.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/htdocs/projet/index.php b/htdocs/projet/index.php index 98ee10cf370..31146a68b40 100644 --- a/htdocs/projet/index.php +++ b/htdocs/projet/index.php @@ -310,8 +310,10 @@ if ($resql) { print ''; // Label - print ''; + print ''; + print '
'; print dolPrintHTML($projectstatic->title); + print '
'; print ''; // Thirdparty From c160e962069047e44a869a9d20e87e26c9549c01 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 4 Jul 2026 15:02:15 +0200 Subject: [PATCH 052/307] css --- htdocs/theme/eldy/global.inc.php | 2 +- htdocs/theme/eldy/tooltips.inc.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/theme/eldy/global.inc.php b/htdocs/theme/eldy/global.inc.php index ca9452ee003..b070873c9ff 100644 --- a/htdocs/theme/eldy/global.inc.php +++ b/htdocs/theme/eldy/global.inc.php @@ -6249,7 +6249,7 @@ div.ui-tooltip.mytooltip { border-radius: 4px; margin: 2px; font-stretch: condensed; - box-shadow: 0.5px 0.5px 4px 0px rgba(0, 0, 0, 0.5); + box-shadow: 0 0 12px rgba(0, 0, 0, 0.25); filter: progid:DXImageTransform.Microsoft.Shadow(color=#656565, Direction=134, Strength=5); background: var(--tooltipbgcolor) !important; color: var(--tooltipfontcolor); diff --git a/htdocs/theme/eldy/tooltips.inc.css b/htdocs/theme/eldy/tooltips.inc.css index 6f0fbe90fee..8be78b7a16e 100644 --- a/htdocs/theme/eldy/tooltips.inc.css +++ b/htdocs/theme/eldy/tooltips.inc.css @@ -11,7 +11,7 @@ div.ui-tooltip.mytooltip-hover-tooltip { border: solid 1px #BBBBBB; padding: 10px 20px; border-radius: 0; - box-shadow: 0 0 4px grey; + box-shadow: 0 0 12px rgba(0, 0, 0, 0.25); margin: 2px; font-stretch: condensed; line-height: 1.6em; From b0835b1dc88edf431613341d1d0c9eab36e7c0c0 Mon Sep 17 00:00:00 2001 From: Nathan <130627685+nathanpixodeo@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:42:58 +0700 Subject: [PATCH 053/307] fix: keep full product label searchable in combo (#39057) --- htdocs/core/class/html.form.class.php | 36 ++++++++------ test/phpunit/FormTest.php | 72 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php index 525e6e1bbd0..56d0a3d69e7 100644 --- a/htdocs/core/class/html.form.class.php +++ b/htdocs/core/class/html.form.class.php @@ -4042,10 +4042,11 @@ class Form $maxlengtharticle = getDolGlobalInt('PRODUCT_MAX_LENGTH_COMBO', 48); - $label = $objp->label; + $productlabel = $objp->label; if (!empty($objp->label_translated)) { - $label = $objp->label_translated; + $productlabel = $objp->label_translated; } + $label = $productlabel; if (!empty($filterkey) && $filterkey != '') { $label = preg_replace('/(' . preg_quote($filterkey, '/') . ')/i', '$1', $label, 1); } @@ -4121,18 +4122,19 @@ class Form } } - // Set $labeltoshow - $labeltoshow = ''; - $labeltoshow .= $objp->ref; + // Set full plain label for the native
'; print '
'; -print '
'; +print '
'; print 'Select a link "test" or "confirmed" to launch a reparation on the chosen option...'; print '
'; print '
'; - +print '
'; print ''; print ''; -print ''; -print ''; +print ''; +print ''; print ''; print ''; print ''; @@ -318,6 +318,7 @@ foreach ($sections as $section => $options) { } } print '
OptionInformationLaunch testLaunch confirmed
'; +print '
'; print '
'; From 831c241c4454fbd3c54998c4cdddbd996de492ee Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 10 Jul 2026 14:39:11 +0200 Subject: [PATCH 076/307] Debug v24 --- htdocs/install/repair.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/htdocs/install/repair.php b/htdocs/install/repair.php index e5a96ae9d16..dbcc3cb5eba 100644 --- a/htdocs/install/repair.php +++ b/htdocs/install/repair.php @@ -202,10 +202,10 @@ if ($dolibarr_main_db_character_set != 'utf8mb4') { } $sections = [ - 'Standard' => [ + 'Miscellaneous' => [ [ 'name' => 'standard', - 'info' => '' + 'info' => 'Miscellaneous fixes' ] ], 'Modules' => [ @@ -263,7 +263,7 @@ $sections = [ 'info' => 'Repair link between dispatch lines and supplier order lines' ] ], - 'Init data' => [ + 'Init empty data' => [ [ 'name' => 'set_empty_time_spent_amount', 'info' => 'Init empty time spent amount' @@ -282,14 +282,17 @@ $sections = [ 'name' => 'force_collation_from_conf_on_tables', 'info' => 'Force '.$conf->db->character_set.'/'.$conf->db->dolibarr_main_db_collation.' + row=dynamic, for mysql/mariadb only' ] - ], - 'Rebuild sequence' => [ + ] +]; + +if ($db->type == 'pgsql') { + $sections['Rebuild sequence'] = [ [ 'name' => 'rebuild_sequences', 'info' => 'For postgresql only' ] - ] -]; + ]; +} $conf->use_javascript_ajax = 1; From d41fd4193bc807596613101858fe58e87297716f Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Fri, 10 Jul 2026 17:18:43 +0200 Subject: [PATCH 077/307] FIX Odt generation : create missing temp directory (#39105) --- htdocs/includes/odtphp/odf.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/htdocs/includes/odtphp/odf.php b/htdocs/includes/odtphp/odf.php index 4fd29562183..30fa0ee28d6 100644 --- a/htdocs/includes/odtphp/odf.php +++ b/htdocs/includes/odtphp/odf.php @@ -76,6 +76,10 @@ class Odf $this->tmpfile = $this->tmpdir.'/'.$md5uniqid.'.odt'; // We keep .odt extension to allow OpenOffice usage during debug. // A working directory is required for some zip proxy like PclZipProxy + if (in_array($this->config['ZIP_PROXY'], array('PclZipProxy')) && ! is_dir($this->config['PATH_TO_TMP'])) { + $result = mkdir($this->config['PATH_TO_TMP']); + } + // Check the dir has been created if (in_array($this->config['ZIP_PROXY'], array('PclZipProxy')) && ! is_dir($this->config['PATH_TO_TMP'])) { throw new OdfException('Temporary directory '.$this->config['PATH_TO_TMP'].' must exists'); } From e26839247b0b562359baa20a451e85b498f28ad8 Mon Sep 17 00:00:00 2001 From: ATM-NicolasV <92087862+ATM-NicolasV@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:24:07 +0200 Subject: [PATCH 078/307] FIX: situation invoice mode setup uses a 3-value select instead of a yes/no toggle (#39098) INVOICE_USE_SITUATION is a 3-state flag (0=off, 1=cumulative/legacy, 2=progressive; see the comment in admin/invoice.php), but the situation setup page (admin/invoice_situation.php) exposed it through a yes/no toggle. Through ajax_constantonoff the toggle can only write 0 or 1 and deletes the constant when turned off (dolibarr_del_const), so mode 2 could neither be selected nor preserved. Replace the toggle with a 3-value select that writes the literal value via dolibarr_set_const (FormSetup 'select' never calls dolibarr_del_const), with a warning box explaining the two modes. INVOICE_USE_SITUATION_CREDIT_NOTE stays a yes/no toggle (it is a genuine boolean). Co-authored-by: Claude Opus 4.8 (1M context) --- htdocs/admin/invoice_situation.php | 17 +++++++++++++---- htdocs/langs/en_US/bills.lang | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/htdocs/admin/invoice_situation.php b/htdocs/admin/invoice_situation.php index 7f86cb6871c..a1c6240c6a1 100644 --- a/htdocs/admin/invoice_situation.php +++ b/htdocs/admin/invoice_situation.php @@ -71,10 +71,19 @@ $form = new Form($db); $formSetup = new FormSetup($db); -// Setup conf MYMODULE_MYPARAM4 : example of quick define write style -$formSetup->newItem('INVOICE_USE_SITUATION') - ->setAsYesNo() - ->nameText = $langs->trans('UseSituationInvoices'); +// INVOICE_USE_SITUATION is a 3-state flag (0=off, 1=cumulative/legacy, 2=progressive), see admin/invoice.php. +// A yes/no toggle can only write 0 or 1 and deletes the const when turned off (ajax_constantonoff calls +// dolibarr_del_const): mode 2 could not be selected and was silently dropped. Use a 3-value select, which +// writes the literal value via dolibarr_set_const and never deletes the const. +$item = $formSetup->newItem('INVOICE_USE_SITUATION'); +$item->setAsSelect(array( + 0 => $langs->trans('Disabled'), + 1 => $langs->trans('SituationInvoiceModeCumulative'), + 2 => $langs->trans('SituationInvoiceModeProgressive'), +)); +$situationModeHelp = $langs->trans('SituationInvoiceModeHelp') + .'
'.$langs->trans('SituationInvoiceModeWarning').''; +$item->nameText = $langs->trans('SituationInvoiceMode').info_admin($situationModeHelp, 0, 0, 'warning', 'clearboth'); $item = $formSetup->newItem('INVOICE_USE_SITUATION_CREDIT_NOTE') ->setAsYesNo() diff --git a/htdocs/langs/en_US/bills.lang b/htdocs/langs/en_US/bills.lang index 3039dffe4e4..bbca23b2c9c 100644 --- a/htdocs/langs/en_US/bills.lang +++ b/htdocs/langs/en_US/bills.lang @@ -231,6 +231,11 @@ AmountOfBillsHT=Amount of invoices (net of tax) AmountOfBillsByMonthHT=Amount of invoices by month (net of tax) UseSituationInvoices=Allow situation invoice UseSituationInvoicesCreditNote=Allow situation invoice credit note +SituationInvoiceMode=Situation invoice mode +SituationInvoiceModeCumulative=Cumulative mode (legacy) +SituationInvoiceModeProgressive=Progressive mode (recommended) +SituationInvoiceModeHelp=Cumulative mode (value 1) is the legacy implementation, flagged as unstable in the code. Progressive mode (value 2) is the recommended one. +SituationInvoiceModeWarning=Do not change this mode once situation invoices exist: amounts already stored would be interpreted differently. RetainedWarranty=Retained warranty RetainedWarrantyShort=Ret. warranty AllowedInvoiceForRetainedWarranty=Retained warranty usable on the following types of invoices From 7612b0f74d676c54c851f7db9a8f7c2a206879b1 Mon Sep 17 00:00:00 2001 From: Nathan <130627685+nathanpixodeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:28:02 +0700 Subject: [PATCH 079/307] fix: correct import types phpdoc (#39096) --- htdocs/imports/class/import.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/imports/class/import.class.php b/htdocs/imports/class/import.class.php index d8c88bb04f6..9e9f3e7637e 100644 --- a/htdocs/imports/class/import.class.php +++ b/htdocs/imports/class/import.class.php @@ -57,7 +57,7 @@ class Import public $array_import_module; /** - * @var array + * @var array> */ public $array_import_types; From 6f807a46cabaeb4f19a5abdf8257165fffb5de1f Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Fri, 10 Jul 2026 17:33:32 +0200 Subject: [PATCH 080/307] FIX Odt generation : create missing temp directory (#39106) --- htdocs/includes/odtphp/odf.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/htdocs/includes/odtphp/odf.php b/htdocs/includes/odtphp/odf.php index 093080489ea..40ebc15740e 100644 --- a/htdocs/includes/odtphp/odf.php +++ b/htdocs/includes/odtphp/odf.php @@ -121,6 +121,10 @@ class Odf $this->tmpfile = $this->tmpdir.'/'.$md5uniqid.'.odt'; // We keep .odt extension to allow OpenOffice usage during debug. // A working directory is required for some zip proxy like PclZipProxy + if (in_array($this->config['ZIP_PROXY'], array('PclZipProxy')) && ! is_dir($this->config['PATH_TO_TMP'])) { + $result = mkdir($this->config['PATH_TO_TMP']); + } + // Check the dir has been created if (in_array($this->config['ZIP_PROXY'], array('PclZipProxy')) && ! is_dir($this->config['PATH_TO_TMP'])) { throw new OdfException('Temporary directory '.$this->config['PATH_TO_TMP'].' must exists'); } From 2fc3fa906eec8f03f5f7775878556c54f4146767 Mon Sep 17 00:00:00 2001 From: nikube <63295277+nikube@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:36:27 +0200 Subject: [PATCH 081/307] FIX Mass action "Reverse movement" bypasses the MAIN_LIMIT_FOR_MASS_ACTIONS limit (23.0) (#39092) * FIX Mass reverse of stock movements bypasses the MAIN_LIMIT_FOR_MASS_ACTIONS limit The confirm_reverse handler did not check the error raised by the generic mass action limit control (TooManyRecordForMassAction), so selecting more records than the limit still executed the reverse after the error message was displayed. The reverse was also triggered when relaunching a search while the confirmation form was open, because the protection only reset $massaction, not $action. * FIX phan: initialize $error at the start of the confirm_reverse handler The new !$error guard lets phan narrow $error to falsy types inside the block, so the $error++ in the query-error branch was reported as PhanTypeInvalidUnaryOperandIncOrDec. Initialize $error to 0 at the start of the handler (and drop the redundant init inside the loop setup where present). * FIX coding rule: comment on the $action test that only cancels the pending action CodingPhpTest requires a permission check or an explicit comment on every test of $action in the Actions section. --------- Co-authored-by: Nicolas Zaou --- htdocs/product/stock/movement_list.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/htdocs/product/stock/movement_list.php b/htdocs/product/stock/movement_list.php index 0297f201560..87386715f85 100644 --- a/htdocs/product/stock/movement_list.php +++ b/htdocs/product/stock/movement_list.php @@ -265,6 +265,9 @@ if (empty($reshook)) { if (GETPOST('button_removefilter_x', 'alpha') || GETPOST('button_removefilter.x', 'alpha') || GETPOST('button_removefilter', 'alpha') || GETPOST('button_search_x', 'alpha') || GETPOST('button_search.x', 'alpha') || GETPOST('button_search', 'alpha')) { $massaction = ''; // Protection to avoid mass action if we force a new search during a mass action confirmation + if ($action == 'confirm_reverse') { // Test on permission not required here, we only cancel a pending action + $action = 'list'; // Protection to avoid the reverse if we force a new search during the reverse confirmation + } } // Mass actions @@ -631,8 +634,9 @@ if ($action == "transfert_stock" && $permissiontoadd && !$cancel) { } // reverse movement of stock -if ($action == 'confirm_reverse' && $confirm == "yes" && $permissiontoadd) { +if (!$error && $action == 'confirm_reverse' && $confirm == "yes" && $permissiontoadd) { $toselect = array_map('intval', $toselect); + $error = 0; $db->begin(); @@ -644,7 +648,6 @@ if ($action == 'confirm_reverse' && $confirm == "yes" && $permissiontoadd) { if ($resql) { $num = $db->num_rows($resql); $i = 0; - $error =0; while ($i < $num) { $obj = $db->fetch_object($resql); @@ -1163,7 +1166,7 @@ $modelmail = "movementstock"; $objecttmp = new MouvementStock($db); $trackid = 'mov'.$warehouse->id; include DOL_DOCUMENT_ROOT.'/core/tpl/massactions_pre.tpl.php'; -if ($massaction == 'prereverse') { +if ($massaction == 'prereverse' && count($toselect) <= getDolGlobalInt('MAIN_LIMIT_FOR_MASS_ACTIONS', 1000)) { print $form->formconfirm($_SERVER["PHP_SELF"], $langs->trans("ConfirmMassReverse"), $langs->trans("ConfirmMassReverseQuestion", count($toselect)), "confirm_reverse", null, '', 0, 200, 500, 1, 'Yes'); } From edc1d1497c67d7f64d3bb0c65631f7400e32738d Mon Sep 17 00:00:00 2001 From: Vincent Maury Date: Fri, 10 Jul 2026 15:37:11 +0000 Subject: [PATCH 082/307] Add API validate method to boms and mos (#39085) * Add Validate method to MO API * add validate method to BOM API * del trailing spaces --- htdocs/bom/class/api_boms.class.php | 36 +++++++++++++++++++++++++ htdocs/mrp/class/api_mos.class.php | 41 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/htdocs/bom/class/api_boms.class.php b/htdocs/bom/class/api_boms.class.php index 8eefef37428..0d5f6276fb9 100644 --- a/htdocs/bom/class/api_boms.class.php +++ b/htdocs/bom/class/api_boms.class.php @@ -277,6 +277,42 @@ class Boms extends DolibarrApi } } + /** + * Validate BOM + * + * @param int $id BOM ID + * @param int $notrigger 1=Does not execute triggers, 0= execute triggers + * @return Object Object with cleaned properties + * + * @url POST {id}/validate + * + * @throws RestException 304 + * @throws RestException 401 + * @throws RestException 404 + * @throws RestException 500 System error + */ + public function validate($id, $notrigger = 0) + { + if (!DolibarrApiAccess::$user->hasRight('bom', 'write')) { + throw new RestException(403); + } + $result = $this->bom->fetch($id); + if (!$result) { + throw new RestException(404, 'Bom not found'); + } + + $result = $this->bom->validate(DolibarrApiAccess::$user, $notrigger); + if ($result == 0) { + throw new RestException(304, 'Error nothing done. May be object is already validated'); + } + if ($result < 0) { + throw new RestException(500, 'Error when validating BOM: '.$this->bom->error); + } + $result = $this->bom->fetch($id); + + return $this->_cleanObjectDatas($this->bom); + } + /** * Delete bom * diff --git a/htdocs/mrp/class/api_mos.class.php b/htdocs/mrp/class/api_mos.class.php index 87c985a51b5..0fec408001b 100644 --- a/htdocs/mrp/class/api_mos.class.php +++ b/htdocs/mrp/class/api_mos.class.php @@ -266,6 +266,47 @@ class Mos extends DolibarrApi } } + /** + * Validate MO + * + * @param int $id MO ID + * @param int $notrigger 1=Does not execute triggers, 0= execute triggers + * @return Object Object with cleaned properties + * + * @url POST {id}/validate + * + * @throws RestException 304 + * @throws RestException 401 + * @throws RestException 404 + * @throws RestException 500 System error + */ + public function validate($id, $notrigger = 0) + { + if (!DolibarrApiAccess::$user->hasRight('mrp', 'write')) { + throw new RestException(403); + } + + $result = $this->mo->fetch($id); + if (!$result) { + throw new RestException(404, 'MO not found'); + } + + if (!DolibarrApi::_checkAccessToResource('mrp', $this->mo->id, 'mrp_mo')) { + throw new RestException(403, 'Access not allowed for login '.DolibarrApiAccess::$user->login); + } + + $result = $this->mo->validate(DolibarrApiAccess::$user, $notrigger); + if ($result == 0) { + throw new RestException(304, 'Error nothing done. May be object is already validated'); + } + if ($result < 0) { + throw new RestException(500, 'Error when validating MO: '.$this->mo->error); + } + $result = $this->mo->fetch($id); + + return $this->_cleanObjectDatas($this->mo); + } + /** * Delete MO * From 758620d7621f8a35f3808cb8dc9d41c6fd0105cc Mon Sep 17 00:00:00 2001 From: Sylvain Legrand Date: Fri, 10 Jul 2026 18:38:12 +0300 Subject: [PATCH 083/307] fix php warnings (#39081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket::listeContact() (line 2435) normally constructs each contact entry with an 'id' key. However, the three fallback blocks (lines 2932-2944), used when the ticket has no external contacts saved, construct synthetic entries without this key. Further on (line 2972), the email notification code uses if ($info_sendto['id'] == $user->contact_id) to avoid notifying the current user—a direct access without isset(), resulting in Undefined array key "id" for these fallback entries. The value -1 (a sentinel already used in this same file as a "not applicable" value) ensures that it will never correspond to a real $user->contact_id (always ≥ 0), preserving the intended behavior: these synthetic contacts are never mistaken for the current user and are still notified normally. --- htdocs/ticket/class/ticket.class.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/ticket/class/ticket.class.php b/htdocs/ticket/class/ticket.class.php index cdd544b6f7f..cdec793301c 100644 --- a/htdocs/ticket/class/ticket.class.php +++ b/htdocs/ticket/class/ticket.class.php @@ -2932,13 +2932,13 @@ class Ticket extends CommonObject if (is_array($external_contacts) && count($external_contacts) === 0) { if (!empty($object->fk_soc)) { $object->fetch_thirdparty($object->fk_soc); - $array_company = array(array('firstname' => '', 'lastname' => $object->thirdparty->name, 'email' => $object->thirdparty->email, 'libelle' => $langs->transnoentities('Customer'), 'socid' => $object->thirdparty->id)); + $array_company = array(array('id' => -1, 'firstname' => '', 'lastname' => $object->thirdparty->name, 'email' => $object->thirdparty->email, 'libelle' => $langs->transnoentities('Customer'), 'socid' => $object->thirdparty->id)); $external_contacts = array_merge($external_contacts, $array_company); } elseif (empty($object->fk_soc) && !empty($object->origin_replyto)) { - $array_external = array(array('firstname' => '', 'lastname' => $object->origin_replyto, 'email' => $object->origin_replyto, 'libelle' => $langs->transnoentities('Customer'), 'socid' => 0)); + $array_external = array(array('id' => -1, 'firstname' => '', 'lastname' => $object->origin_replyto, 'email' => $object->origin_replyto, 'libelle' => $langs->transnoentities('Customer'), 'socid' => 0)); $external_contacts = array_merge($external_contacts, $array_external); } elseif (empty($object->fk_soc) && !empty($object->origin_email)) { - $array_external = array(array('firstname' => '', 'lastname' => $object->origin_email, 'email' => $object->thirdparty->email, 'libelle' => $langs->transnoentities('Customer'), 'socid' => $object->thirdparty->id)); + $array_external = array(array('id' => -1, 'firstname' => '', 'lastname' => $object->origin_email, 'email' => $object->thirdparty->email, 'libelle' => $langs->transnoentities('Customer'), 'socid' => $object->thirdparty->id)); $external_contacts = array_merge($external_contacts, $array_external); } } From 4785b8db9bd34921baca1fe06c2f8c66a770842a Mon Sep 17 00:00:00 2001 From: Sylvain Legrand Date: Fri, 10 Jul 2026 18:38:36 +0300 Subject: [PATCH 084/307] fix php warnings (#39080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getNextValue($objsoc, $ticket)` is called from `Ticket::getDefaultRef($thirdparty = null)` (htdocs/ticket/class/ticket.class.php:2499), where `$thirdparty` defaults to null—a ticket can legitimately not be linked to a third party. Line 154 directly accessed `$objsoc->code_client` without checking if the object existed, causing `Attempt to read property "code_client"` to be null. I reused the exact same fix already applied by Dolibarr core in the analogous numbering model `mod_task_universal.php:148` (`is_object($objsoc) ? $objsoc->code_client : ''`), to maintain consistency with the existing convention. --- htdocs/core/modules/ticket/mod_ticket_universal.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/modules/ticket/mod_ticket_universal.php b/htdocs/core/modules/ticket/mod_ticket_universal.php index f900c7dc4e0..68ddec03465 100644 --- a/htdocs/core/modules/ticket/mod_ticket_universal.php +++ b/htdocs/core/modules/ticket/mod_ticket_universal.php @@ -151,7 +151,7 @@ class mod_ticket_universal extends ModeleNumRefTicket $entity = getEntity('ticketnumber', 1, $ticket); $date = empty($ticket->datec) ? dol_now() : $ticket->datec; - $numFinal = get_next_value($db, $mask, 'ticket', 'ref', '', $objsoc->code_client, $date, 'next', false, null, $entity); + $numFinal = get_next_value($db, $mask, 'ticket', 'ref', '', (is_object($objsoc) ? $objsoc->code_client : ''), $date, 'next', false, null, $entity); return $numFinal; } From ace44702a83268ca6e8ee80084111503fb959228 Mon Sep 17 00:00:00 2001 From: Sylvain Legrand Date: Fri, 10 Jul 2026 18:40:37 +0300 Subject: [PATCH 085/307] Fix PHP warnings (#39079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this file, four nearly identical SQL blocks feed a loop that runs `$tmpuser->status = $obj->status;`. Three of these blocks (lines 452, 605, 761/780) correctly alias `u.statut` as `status`. The fourth block ("supplier invoice discounts", lines 926-966, which feeds the loop on lines 1010-1021) incorrectly aliased `u.statut` as `user_status`, leaving `$obj->status` undefined → warning `Undefined property: stdClass::$status ...` on line 1021. The correction aligns this alias with the other three blocks. --- htdocs/comm/remx.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/comm/remx.php b/htdocs/comm/remx.php index b11d53b2708..1743f754f2b 100644 --- a/htdocs/comm/remx.php +++ b/htdocs/comm/remx.php @@ -927,7 +927,7 @@ if ($socid > 0) { $sql .= " rc.multicurrency_amount_ht, rc.multicurrency_amount_tva, rc.multicurrency_amount_ttc,"; $sql .= " rc.datec as dc, rc.description, rc.fk_invoice_supplier_line,"; $sql .= " rc.fk_invoice_supplier_source,"; - $sql .= " u.login, u.rowid as user_id, u.statut as user_status, u.firstname, u.lastname, u.photo,"; + $sql .= " u.login, u.rowid as user_id, u.statut as status, u.firstname, u.lastname, u.photo,"; $sql .= " f.rowid as invoiceid, f.ref as ref,"; $sql .= " fa.ref as invoice_source_ref, fa.type as type"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; @@ -947,7 +947,7 @@ if ($socid > 0) { $sql2 .= " rc.multicurrency_amount_ht, rc.multicurrency_amount_tva, rc.multicurrency_amount_ttc,"; $sql2 .= " rc.datec as dc, rc.description, rc.fk_invoice_supplier,"; $sql2 .= " rc.fk_invoice_supplier_source,"; - $sql2 .= " u.login, u.rowid as user_id, u.statut as user_status, u.firstname, u.lastname, u.photo,"; + $sql2 .= " u.login, u.rowid as user_id, u.statut as status, u.firstname, u.lastname, u.photo,"; $sql2 .= " f.rowid as invoiceid, f.ref as ref,"; $sql2 .= " fa.ref as invoice_source_ref, fa.type as type"; $sql2 .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; From 3430d46ba9e11dd9e270570167e259140171a0d0 Mon Sep 17 00:00:00 2001 From: Zakaria Boushaba <48571684+Boushabazakaria@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:42:16 +0100 Subject: [PATCH 086/307] Fix XLSX import: cache column count to avoid re-parsing file on every row (#39075) Co-authored-by: Zakaria Boushaba --- .../core/modules/import/import_xlsx.modules.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/htdocs/core/modules/import/import_xlsx.modules.php b/htdocs/core/modules/import/import_xlsx.modules.php index 21be5c16f76..2b82115663e 100644 --- a/htdocs/core/modules/import/import_xlsx.modules.php +++ b/htdocs/core/modules/import/import_xlsx.modules.php @@ -104,6 +104,11 @@ class ImportXlsx extends ModeleImports */ public $headers; + /** + * @var int + */ + public $countcolumns = 0; // cached column count to avoid re-parsing the file on each row + /** * Constructor @@ -339,9 +344,13 @@ class ImportXlsx extends ModeleImports } $array = array(); - $xlsx = new Xlsx(); - $info = $xlsx->listWorksheetinfo($this->file); - $countcolumns = $info[0]['totalColumns']; + if (empty($this->countcolumns)) { + $xlsx = new Xlsx(); + $info = $xlsx->listWorksheetinfo($this->file); + $this->countcolumns = $info[0]['totalColumns']; + unset($xlsx); + } + $countcolumns = $this->countcolumns; for ($col = 1; $col <= $countcolumns; $col++) { $tmpcell = $this->workbook->getActiveSheet()->getCellByColumnAndRow($col, $this->record); @@ -359,8 +368,6 @@ class ImportXlsx extends ModeleImports } $this->record++; - unset($xlsx); - return $array; } From 0a3ba8e740bcebabdd4dc34ce43de6caeedc60e0 Mon Sep 17 00:00:00 2001 From: Francis Appels Date: Fri, 10 Jul 2026 17:44:53 +0200 Subject: [PATCH 087/307] Revert "NEW: Show bom net needs treeview by line position. (#37459)" (#39083) * Revert "NEW: Show bom net needs treeview by line position. (#37459)" This reverts commit b02f1a4ebfc46f59509ed1633535c27024f36782. * Update changelog and fix stan * fix stan --- ChangeLog | 1 - htdocs/bom/bom_net_needs.php | 78 +++++++++++------------------------- 2 files changed, 24 insertions(+), 55 deletions(-) diff --git a/ChangeLog b/ChangeLog index 33c35923532..06ff0255eac 100644 --- a/ChangeLog +++ b/ChangeLog @@ -159,7 +159,6 @@ NEW: Add mass action to cancel invoices using credit notes (#37852) NEW: Add personal_data flag in extrafields admin UI - next step of #8612 (#38133) NEW: Direct installation for community and free modules on DoliStore (#38357) NEW: Display thirdparty name with ref in supplier orders linked objects (#36952) -NEW: Show bom net needs treeview by line position. (#37459) NEW: Show name_alias in parentheses in thirdparty dropdown lists (#37997) NEW: Thirdparty card +tab with events where thirdparty is attendee/booth (#37743) NEW: Update ticket read date when status changes (#37470) diff --git a/htdocs/bom/bom_net_needs.php b/htdocs/bom/bom_net_needs.php index e06e249d624..346cd67f1aa 100644 --- a/htdocs/bom/bom_net_needs.php +++ b/htdocs/bom/bom_net_needs.php @@ -257,57 +257,33 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea print ''; print ''; - $tablerows = array(); - $position = ''; - $levelposition = ''; - $lineposition = 0; if (count($TChildBom) > 0) { if ($action == 'treeview') { foreach ($TChildBom as $fk_bom => $TProduct) { $repeatChar = ' '; if (!empty($TProduct['bom'])) { - // we make position string in format 'lineposition.bomlevel.childposition' - if (!empty($TProduct['position']) && $TProduct['parentid'] == $object->id) { - // define lineposition - $lineposition = $TProduct['position']; - } - // define lineposition.bomlevel - $position = sprintf('%d.%d', $lineposition, $TProduct['level']); - // memorize level position for products in bom - $levelposition = $position; - if (!empty($TProduct['parentid']) && $TProduct['parentid'] != $object->id && empty($TProduct['product'])) { - // define lineposition.bomlevel.childposition - $position = sprintf('%s.%d', $position, $TProduct['position']); - } $prod = new Product($db); $prod->fetch($TProduct['bom']->fk_product); if ($TProduct['parentid'] != $object->id) { - $tablerows[$position] = ''; + print ''; } else { - $tablerows[$position] = ''; + print ''; } - $tablerows[$position] .= ''.str_repeat($repeatChar, $TProduct['level']).$prod->getNomUrl(1); - $tablerows[$position] .= ' '; - $tablerows[$position] .= img_picto('', 'folder-open'); - $tablerows[$position] .= ''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''.$TProduct['bom']->getNomUrl(1).''; - $tablerows[$position] .= ''.price(price2num($TProduct['qty'], 'MS')).''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''; + print ''.str_repeat($repeatChar, $TProduct['level']).$prod->getNomUrl(1); + print ' '; + print img_picto('', 'folder-open'); + print ''; + print ''; + print ''.$TProduct['bom']->getNomUrl(1).''; + print ''.price(price2num($TProduct['qty'], 'MS')).''; + print ''; + print ''; + print ''; + print ''; + print ''; } if (!empty($TProduct['product'])) { foreach ($TProduct['product'] as $fk_product => $TInfos) { - if (!empty($TInfos['position']) && $fk_bom == $object->id) { - // top level position - $position = $TInfos['position']; - } else { - // sublevel position - $position = sprintf('%s.%d', $levelposition, $TInfos['position']); - } $prod = new Product($db); $prod->fetch($fk_product); $prod->load_virtual_stock(); @@ -315,27 +291,21 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea $prod->stock_reel = 0; } if ($fk_bom != $object->id) { - $tablerows[$position] = ''; // sub bom on same position + print ''; } else { - $tablerows[$position] = ''; + print ''; } - $tablerows[$position] .= ''.str_repeat($repeatChar, (int) $TInfos['level']).$prod->getNomUrl(1).''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''.price(price2num((float) $TInfos['qty'], 'MS')).''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''; - $tablerows[$position] .= ''.price2num($prod->stock_reel, 'MS').''; - $tablerows[$position] .= ''.$prod->stock_theorique.''; - $tablerows[$position] .= ''; + print ''.str_repeat($repeatChar, $TInfos['level']).$prod->getNomUrl(1).''; + print ''; + print ''.price(price2num($TInfos['qty'], 'MS')).''; + print ''; + print ''; + print ''.price2num($prod->stock_reel, 'MS').''; + print ''.$prod->stock_theorique.''; + print ''; } } } - // Sort the rows numeric by position string - ksort($tablerows, SORT_NUMERIC); - // Print the rows - foreach ($tablerows as $position => $row) { - print $row; - } } else { foreach ($TChildBom as $fk_product => $elem) { $prod = new Product($db); From 1992ad49f9edd71c4d3e0204577cae3a9cf6726a Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Fri, 10 Jul 2026 17:45:13 +0200 Subject: [PATCH 088/307] Fix sql injection in sqlfilters API param - reported by sangkilp (#38794) (#39072) Switch four list endpoints that still used the legacy DolibarrApi::_checkFilters + bare preg_replace_callback path to the hardened forgeSQLFromUniversalSearchCriteria() already used by the rest of the same files. The legacy path only validated parenthesis balance and concatenated non-matching text verbatim into the WHERE clause, while the hardened path applies the dummy-criteria syntax check first. Endpoints touched: - htdocs/api/class/api_setup.class.php getListOfRegions - htdocs/api/class/api_setup.class.php getListOfIncoterms - htdocs/api/class/api_setup.class.php getListOfVAT - htdocs/multicurrency/class/api_multicurrencies.class.php index See #38768 for the report. Signed-off-by: Dolicraft Co-authored-by: w3houde Co-authored-by: Dolicraft --- htdocs/api/class/api_setup.class.php | 10 ++++------ .../multicurrency/class/api_multicurrencies.class.php | 7 +++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/htdocs/api/class/api_setup.class.php b/htdocs/api/class/api_setup.class.php index 68b7186c15e..214241f81c8 100644 --- a/htdocs/api/class/api_setup.class.php +++ b/htdocs/api/class/api_setup.class.php @@ -362,11 +362,10 @@ class Setup extends DolibarrApi // Add sql filters if ($sqlfilters) { $errormessage = ''; - if (!DolibarrApi::_checkFilters($sqlfilters, $errormessage)) { + $sql .= forgeSQLFromUniversalSearchCriteria($sqlfilters, $errormessage); + if ($errormessage) { throw new RestException(400, 'Error when validating parameter sqlfilters -> '.$errormessage); } - $regexstring = '\(([^:\'\(\)]+:[^:\'\(\)]+:[^\(\)]+)\)'; - $sql .= " AND (".preg_replace_callback('/'.$regexstring.'/', 'DolibarrApi::_forge_criteria_callback', $sqlfilters).")"; } $sql .= $this->db->order($sortfield, $sortorder); @@ -2289,11 +2288,10 @@ class Setup extends DolibarrApi // Add sql filters if ($sqlfilters) { $errormessage = ''; - if (!DolibarrApi::_checkFilters($sqlfilters, $errormessage)) { + $sql .= forgeSQLFromUniversalSearchCriteria($sqlfilters, $errormessage); + if ($errormessage) { throw new RestException(400, 'Error when validating parameter sqlfilters -> '.$errormessage); } - $regexstring = '\(([^:\'\(\)]+:[^:\'\(\)]+:[^\(\)]+)\)'; - $sql .= " AND (".preg_replace_callback('/'.$regexstring.'/', 'DolibarrApi::_forge_criteria_callback', $sqlfilters).")"; } diff --git a/htdocs/multicurrency/class/api_multicurrencies.class.php b/htdocs/multicurrency/class/api_multicurrencies.class.php index 03c1df80d15..7c760fe3bf4 100644 --- a/htdocs/multicurrency/class/api_multicurrencies.class.php +++ b/htdocs/multicurrency/class/api_multicurrencies.class.php @@ -72,11 +72,10 @@ class MultiCurrencies extends DolibarrApi // Add sql filters if ($sqlfilters) { $errormessage = ''; - if (!DolibarrApi::_checkFilters($sqlfilters, $errormessage)) { - throw new RestException(503, 'Error when validating parameter sqlfilters -> '.$errormessage); + $sql .= forgeSQLFromUniversalSearchCriteria($sqlfilters, $errormessage); + if ($errormessage) { + throw new RestException(400, 'Error when validating parameter sqlfilters -> '.$errormessage); } - $regexstring = '\(([^:\'\(\)]+:[^:\'\(\)]+:[^\(\)]+)\)'; - $sql .= " AND (".preg_replace_callback('/'.$regexstring.'/', 'DolibarrApi::_forge_criteria_callback', $sqlfilters).")"; } $sql .= $this->db->order($sortfield, $sortorder); From 95bcefbb9e25d1e91a860663df426c24810a6dc3 Mon Sep 17 00:00:00 2001 From: Nathan <130627685+nathanpixodeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:46:28 +0700 Subject: [PATCH 089/307] FIX: #39068 Ignore warning text in product search (#39071) --- ChangeLog | 1 + htdocs/core/class/html.form.class.php | 11 +++++++++++ htdocs/core/lib/ajax.lib.php | 6 ++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index 06ff0255eac..3a3987fc063 100644 --- a/ChangeLog +++ b/ChangeLog @@ -242,6 +242,7 @@ FIX: missing "blob:" in the assistant for CSP editor. FIX: option MEMBER_SEARCH_MEMBER_PUBLIC_FORM_CREATE FIX: product price.php: preserve default_vat_code and tva_npr when auto-creating initial product_price row (#38034) FIX: propagate fk_warehouse from BOM/MO to production lines in createProduction() and processBOM() (#38147) +FIX: #39068 Product search combo: ignore supplier price warning text when filtering FIX: selected default value ko on select_produits_fournisseurs_list() FIX: Set default warehouse on order create. (#37815) FIX: Site root missing in $backtopage. (#37804) diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php index 3b84c438822..01302a21724 100644 --- a/htdocs/core/class/html.form.class.php +++ b/htdocs/core/class/html.form.class.php @@ -4689,6 +4689,16 @@ class Form } $outvallabel .= ' - ' . dol_trunc($label, $maxlengtharticle); + $outsearchlabel = implode(' ', array_filter(array( + (string) $objp->ref, + (string) $objp->ref_fourn, + (string) $objp->barcode, + (string) $objp->label, + dol_string_nohtmltag((string) $objp->description) + ), function (string $value): bool { + return $value !== ''; + })); + // Units $optlabel .= $outvalUnits; $outvallabel .= $outvalUnits; @@ -4819,6 +4829,7 @@ class Form } } $optstart .= ' data-description="' . dol_escape_htmltag($objp->description, 0, 1) . '"'; + $optstart .= ' data-search="' . dol_escape_htmltag($outsearchlabel) . '"'; // set $parameters to call hook $outarrayentry = array( diff --git a/htdocs/core/lib/ajax.lib.php b/htdocs/core/lib/ajax.lib.php index 851bab3f36c..3faefd3805f 100644 --- a/htdocs/core/lib/ajax.lib.php +++ b/htdocs/core/lib/ajax.lib.php @@ -521,11 +521,13 @@ function ajax_combobox($htmlname, $events = array(), $minLengthToAutocomplete = if (getDolGlobalString('MAIN_ENABLE_ACCENT_INSENSITIVE_SEARCH')) { // lowercase + remove accents $msg .= ' var term = params.term.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() - var text = (data.text || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() || "";'; + var searchText = data.element ? $(data.element).attr("data-search") : undefined; + var text = (searchText !== undefined ? searchText : (data.text || "")).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() || "";'; } else { // lowercase only (accent kept) $msg .= ' var term = params.term.toLowerCase(); - var text = (data.text || "").toLowerCase();'; + var searchText = data.element ? $(data.element).attr("data-search") : undefined; + var text = (searchText !== undefined ? searchText : (data.text || "")).toLowerCase();'; } $msg .= ' var keywords = term.split(" "); From 52c9dd944c6585bac8aebb6e033cd67765b5f0f3 Mon Sep 17 00:00:00 2001 From: HENRY Florian Date: Fri, 10 Jul 2026 17:48:05 +0200 Subject: [PATCH 090/307] fix: correct naming of file upladed on supplier invoice (#39065) --- htdocs/core/class/fileupload.class.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/htdocs/core/class/fileupload.class.php b/htdocs/core/class/fileupload.class.php index 1354894cf41..0559c500e71 100644 --- a/htdocs/core/class/fileupload.class.php +++ b/htdocs/core/class/fileupload.class.php @@ -73,6 +73,7 @@ class FileUpload $pathname = str_replace('/class', '', $element_prop['classpath']); $filename = dol_sanitizeFileName($element_prop['classfile']); $dir_output = dol_sanitizePathName($element_prop['dir_output']); + $savingDocMask = ''; //print 'fileupload.class.php: element='.$element.' pathname='.$pathname.' filename='.$filename.' dir_output='.$dir_output."\n"; @@ -89,6 +90,11 @@ class FileUpload $object_ref = dol_sanitizeFileName($object->ref); + // 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__'; + } + // 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; @@ -114,6 +120,7 @@ class FileUpload 'script_url' => $_SERVER['PHP_SELF'], 'upload_dir' => $dir_output.'/'.$object_ref.'/', 'upload_url' => DOL_URL_ROOT.'/document.php?modulepart='.$element.'&attachment=1&file=/'.$object_ref.'/', + 'saving_doc_mask' => $savingDocMask, 'param_name' => 'files', // Set the following option to 'POST', if your server does not support // DELETE requests. This is a parameter sent to the client: @@ -439,6 +446,14 @@ class FileUpload if ($validate) { if (dol_mkdir($this->options['upload_dir']) >= 0) { + // add object reference as file name prefix if const MAIN_DISABLE_SUGGEST_REF_AS_PREFIX is not enabled + $fileNameWithoutExt = preg_replace('/\.[^\.]+$/', '', $file->name); + $savingDocMask = $this->options['saving_doc_mask']; + if ($savingDocMask && strpos($savingDocMask, $fileNameWithoutExt) !== 0) { + $fileNameWithPrefix = preg_replace('/__file__/', $file->name, $savingDocMask); + $file->name = $fileNameWithPrefix; + } + $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); From fce834c2fccc3a8c467105ee546ff156bbd34f01 Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Fri, 10 Jul 2026 17:50:27 +0200 Subject: [PATCH 091/307] FIX migration of const inversion STOCK_ALLOW_NEGATIVE_TRANSFER -> STOCK_DISALLOW_NEGATIVE_TRANSFER : do not delete STOCK_ALLOW_NEGATIVE_TRANSFER after inversion. (#39064) --- htdocs/install/mysql/migration/21.0.0-22.0.0.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/install/mysql/migration/21.0.0-22.0.0.sql b/htdocs/install/mysql/migration/21.0.0-22.0.0.sql index 43f2a4faf08..6e9e8ad72b7 100644 --- a/htdocs/install/mysql/migration/21.0.0-22.0.0.sql +++ b/htdocs/install/mysql/migration/21.0.0-22.0.0.sql @@ -243,7 +243,8 @@ UPDATE llx_accounting_account as acc SET acc.centralized = 1 WHERE acc.account_n -- invert constant STOCK_ALLOW_NEGATIVE_TRANSFER because it was automatically set to 1, deleting the user config. INSERT INTO llx_const (name, entity, value, type, visible, note) SELECT DISTINCT 'STOCK_DISALLOW_NEGATIVE_TRANSFER', entity, '1', 'chaine', 0, '' FROM llx_const as c1 WHERE NOT EXISTS (SELECT rowid FROM llx_const as c2 WHERE c2.name = 'STOCK_ALLOW_NEGATIVE_TRANSFER' AND c2.value = '1' AND c2.entity = c1.entity); UPDATE llx_const SET name = 'STOCK_DISALLOW_NEGATIVE_TRANSFER', value = '1' WHERE name = 'STOCK_ALLOW_NEGATIVE_TRANSFER' AND value = '0'; -DELETE FROM llx_const WHERE name = 'STOCK_ALLOW_NEGATIVE_TRANSFER' AND value = '1'; +-- Do not delete this const, otherwise the 'INSERT INTO...' will be triggered on next update +-- DELETE FROM llx_const WHERE name = 'STOCK_ALLOW_NEGATIVE_TRANSFER' AND value = '1'; ALTER TABLE llx_links ADD COLUMN share varchar(128) NULL AFTER objectid; ALTER TABLE llx_links ADD COLUMN share_pass varchar(32) NULL AFTER share; From 1a1eafe74f392acc41a6acaf0d8af69feae12615 Mon Sep 17 00:00:00 2001 From: MDW Date: Fri, 10 Jul 2026 17:50:47 +0200 Subject: [PATCH 092/307] Qual: Update PHPDoc for setAsSelect method (#39061) # Qual: Update PHPDoc for setAsSelect method - Update the type definition of the $fieldOptions parameter in the setAsSelect method to make the color, picto, labelhtml, and note fields optional. --- htdocs/core/class/html.formsetup.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/core/class/html.formsetup.class.php b/htdocs/core/class/html.formsetup.class.php index e06f6490821..2568363d449 100644 --- a/htdocs/core/class/html.formsetup.class.php +++ b/htdocs/core/class/html.formsetup.class.php @@ -1,6 +1,6 @@ - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2026 Frédéric France * Copyright (C) 2026 Alexandre Spangaro * @@ -1745,7 +1745,7 @@ class FormSetupItem /** * Set type of input as a select list. * - * @param ?array $fieldOptions A table of field options + * @param ?array $fieldOptions A table of field options * @return self */ public function setAsSelect($fieldOptions) From d8f9286a25918d1d4df9414d0f0d00e19a28bda9 Mon Sep 17 00:00:00 2001 From: MDW Date: Fri, 10 Jul 2026 17:51:12 +0200 Subject: [PATCH 093/307] Qual: Fix database typo (DoliDb -> DoliDB) (#39060) # Qual: Fix database typo (DoliDb -> DoliDB) The database handler type was updated from `DoliDb` to `DoliDB` in both `bookkeepingtemplate.class.php` and `bookkeepingtemplateline.class.php`. --- htdocs/accountancy/class/bookkeepingtemplate.class.php | 2 +- htdocs/accountancy/class/bookkeepingtemplateline.class.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/accountancy/class/bookkeepingtemplate.class.php b/htdocs/accountancy/class/bookkeepingtemplate.class.php index daa7530f1c0..329e29d8d2f 100644 --- a/htdocs/accountancy/class/bookkeepingtemplate.class.php +++ b/htdocs/accountancy/class/bookkeepingtemplate.class.php @@ -163,7 +163,7 @@ class BookkeepingTemplate extends CommonObject /** * Constructor * - * @param DoliDb $db Database handler + * @param DoliDB $db Database handler */ public function __construct(DoliDB $db) { diff --git a/htdocs/accountancy/class/bookkeepingtemplateline.class.php b/htdocs/accountancy/class/bookkeepingtemplateline.class.php index 11fba2a104c..c6f54b9fdee 100644 --- a/htdocs/accountancy/class/bookkeepingtemplateline.class.php +++ b/htdocs/accountancy/class/bookkeepingtemplateline.class.php @@ -182,7 +182,7 @@ class BookkeepingTemplateLine extends CommonObject /** * Constructor * - * @param DoliDb $db Database handler + * @param DoliDB $db Database handler */ public function __construct(DoliDB $db) { From 113716f2a09f38462011447fefb2daeaa30788bc Mon Sep 17 00:00:00 2001 From: rlaurent-alb Date: Fri, 10 Jul 2026 17:53:44 +0200 Subject: [PATCH 094/307] FIX: Prevent total discount rounding error on standard lines (#39044) * FIX: Prevent total discount rounding error on standard lines Added a missing check in pdfGetLineTotalDiscountAmount() to skip the discount math entirely on lines without a discount. This prevents subprice rounding discrepancies on standard items from incorrectly altering the document's total discount. * FIX: Resolve rounding discrepancy in PDF line total discount calculation Ensured `pdfGetLineTotalDiscountAmount()` applies `price2num()` rounding to the theoretical line total `(subprice * qty)` *before* subtracting `total_ht`. This identically matches the database insertion logic, preventing false 1-cent rounding discrepancies from bleeding into the total document discount pool. --- htdocs/core/lib/pdf.lib.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/htdocs/core/lib/pdf.lib.php b/htdocs/core/lib/pdf.lib.php index 443e82e6c96..9b9f42a131c 100644 --- a/htdocs/core/lib/pdf.lib.php +++ b/htdocs/core/lib/pdf.lib.php @@ -2917,9 +2917,11 @@ function pdfGetLineTotalDiscountAmount($object, $i, $outputlangs, $hidedetails = if (empty($hidedetails) || $hidedetails > 1) { if (empty($multicurrency)) { - return (float) price2num($sign * (($object->lines[$i]->subprice * (float) $object->lines[$i]->qty) - $object->lines[$i]->total_ht), 'MT', 1); + $diff = (float) price2num($sign * $object->lines[$i]->subprice * (float) $object->lines[$i]->qty, 'MT', 1) - $object->lines[$i]->total_ht; + return (float) price2num($diff, 'MT', 1); } else { - return (float) price2num($sign * (($object->lines[$i]->multicurrency_subprice * (float) $object->lines[$i]->qty) - $object->lines[$i]->multicurrency_total_ht), 'MT', 1); + $diff = (float) price2num($sign * $object->lines[$i]->multicurrency_subprice * (float) $object->lines[$i]->qty, 'MT', 1) - $object->lines[$i]->multicurrency_total_ht; + return (float) price2num($diff, 'MT', 1); } } } From ea507289a92da042e26cf4b562114971ecda4931 Mon Sep 17 00:00:00 2001 From: GregcodeOhm Date: Fri, 10 Jul 2026 19:42:17 +0200 Subject: [PATCH 095/307] FIX #39038 Double quote in product label truncates the combo (#39051) In select_produits_list() the product option data-html attribute is built by concatenating $labeltoshowhtml, $outvalUnits and $labeltoshowhtmlprice as raw strings. The variables that carry user text are $labeltoshowhtml (ref, customer ref, barcode and product label) and $outvalUnits (unit short label from the dictionary), so a double quote typed there, for example an inch mark 1/2", closes the data-html="..." attribute early and the rich dropdown (ajaxcombo) shows the name truncated at the quote. $labeltoshowhtmlprice only carries outputs of price() and transnoentities() and cannot contain a double quote. Sanitize the two user variables with dolPrintHTMLForAttribute(), as the stock part of the same attribute already does. The tag 'strong' is passed in the allowothertags parameter for the label so the search highlight added around the typed filter key is kept in the rich label. Base 21.0: the data-html attribute on the product option was introduced in 21.0; on 20.0 the option carried only a class and select2 used the plain option text where a quote is harmless. In develop the code was reworked by PR #39057 which now escapes the whole value with dol_escape_htmltag(), so this fix targets the maintained branches only. Signed-off-by: Gregory Aliot --- htdocs/core/class/html.form.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php index 3135f37a879..c7a3e5430b0 100644 --- a/htdocs/core/class/html.form.class.php +++ b/htdocs/core/class/html.form.class.php @@ -3620,11 +3620,11 @@ class Form } if ($stocktag == 1) { - $opt .= ' class="product_line_stock_ok" data-html="'.$labeltoshowhtml.$outvalUnits.$labeltoshowhtmlprice.dolPrintHTMLForAttribute($labeltoshowhtmlstock).'"'; + $opt .= ' class="product_line_stock_ok" data-html="'.dolPrintHTMLForAttribute($labeltoshowhtml, 0, array('strong')).dolPrintHTMLForAttribute($outvalUnits).$labeltoshowhtmlprice.dolPrintHTMLForAttribute($labeltoshowhtmlstock).'"'; //$opt .= ' class="product_line_stock_ok"'; } if ($stocktag == -1) { - $opt .= ' class="product_line_stock_too_low" data-html="'.$labeltoshowhtml.$outvalUnits.$labeltoshowhtmlprice.dolPrintHTMLForAttribute($labeltoshowhtmlstock).'"'; + $opt .= ' class="product_line_stock_too_low" data-html="'.dolPrintHTMLForAttribute($labeltoshowhtml, 0, array('strong')).dolPrintHTMLForAttribute($outvalUnits).$labeltoshowhtmlprice.dolPrintHTMLForAttribute($labeltoshowhtmlstock).'"'; //$opt .= ' class="product_line_stock_too_low"'; } From 6a8092e8dee853c57805e8fd2aa08e11c252539f Mon Sep 17 00:00:00 2001 From: Pali Date: Fri, 10 Jul 2026 19:45:33 +0200 Subject: [PATCH 096/307] FIX: propagate extrafields from objectsrc in extrafields_add.tpl.php (#39073) --- htdocs/core/tpl/extrafields_add.tpl.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/htdocs/core/tpl/extrafields_add.tpl.php b/htdocs/core/tpl/extrafields_add.tpl.php index 78cd0eaec94..d2c6a2d8689 100644 --- a/htdocs/core/tpl/extrafields_add.tpl.php +++ b/htdocs/core/tpl/extrafields_add.tpl.php @@ -2,6 +2,7 @@ /* Copyright (C) 2014 Maxime Kohlhaas * Copyright (C) 2014 Juanjo Menent * Copyright (C) 2024 Frédéric France + * Copyright (C) 2026 Palisandro David Gamez * * 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 @@ -88,6 +89,21 @@ if (empty($reshook)) { } } + // Propagate extrafields from an origin object (e.g. commande -> expedition) when the + // calling card.php passes its source object via $parameters['objectsrc']. This was + // inline logic in each card.php up to v21.x and was lost during the refactor to this + // shared template in v22.x. Only extrafields defined on the target elementtype are + // effectively displayed (showOptionals filters by target), so there are no side + // effects if origin and target have disjoint extrafield sets. + if (!empty($parameters['objectsrc']) && is_object($parameters['objectsrc']) + && method_exists($parameters['objectsrc'], 'fetch_optionals')) { + $tmpobjectsrc = $parameters['objectsrc']; + '@phan-var-force CommonObject $tmpobjectsrc'; + if ($tmpobjectsrc->fetch_optionals() > 0 && !empty($tmpobjectsrc->array_options)) { + $object->array_options = array_merge($object->array_options, $tmpobjectsrc->array_options); + } + } + print $object->showOptionals($extrafields, 'create', $params); } From 03dd388ef2f247e4044cf07a3d964f6128906dca Mon Sep 17 00:00:00 2001 From: Sylvain Legrand Date: Sat, 11 Jul 2026 15:16:32 +0300 Subject: [PATCH 097/307] fix PHP Warnings (#39111) --- htdocs/core/tpl/list_print_total.tpl.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/tpl/list_print_total.tpl.php b/htdocs/core/tpl/list_print_total.tpl.php index 4dbfdfa2a29..b87c82e8d69 100644 --- a/htdocs/core/tpl/list_print_total.tpl.php +++ b/htdocs/core/tpl/list_print_total.tpl.php @@ -135,7 +135,7 @@ if (isset($totalarray['pos'])) { $i++; if (!empty($totalarray['pos'][$i])) { $fieldname = preg_replace('/[^a-z0-9]/', '', $totalarray['pos'][$i]); - printTotalValCell($totalarray['type'][$i], $sumsarray[$fieldname]); + printTotalValCell($totalarray['type'][$i] ?? '', $sumsarray[$fieldname]); } else { if ($i == 1) { print ''; From 3d0630511f215d2e4d978e7fd10cf1c81666dbc4 Mon Sep 17 00:00:00 2001 From: Sylvain Legrand Date: Sat, 11 Jul 2026 15:18:10 +0300 Subject: [PATCH 098/307] fix PHP Warnings (#39112) --- htdocs/core/class/commonobject.class.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index 33494381787..f6088b69482 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -7205,7 +7205,10 @@ abstract class CommonObject require_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php'; $extrafields = new ExtraFields($this->db); $extrafields->fetch_name_optionals_label($this->table_element); - + if (!isset($extrafields->attributes[$this->table_element]['type'][$key])) { + // Extrafield not defined for this object type: nothing to update + return 0; + } $value = $this->array_options["options_".$key]; $attributeKey = $key; From 217e32c6c1d4d24d083cfe7b486cf614023e9944 Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Sat, 11 Jul 2026 21:19:44 +0900 Subject: [PATCH 099/307] FIX: undefined $objcanvas/$socid in societe thirdparty canvas templates (#39116) Canvas::display_canvas() (htdocs/core/class/canvas.class.php) includes these templates from inside a method and only imports $db,$conf,$langs,$user, $canvas,$form,$formfile via 'global'. $objcanvas and $socid are never brought into that scope, so they were undefined at runtime (the templates already reconstruct the thirdparty object from $GLOBALS['objcanvas']). - Derive $socid from the in-scope thirdparty object ($soc->id / $object->id), fixing the empty socid used for $filedir/$urlsource/showdocuments(). - Replace the bare $objcanvas->control->object with the already-derived $soc (company) / $object (individual). For the company template $socid was previously masked from PHPStan by a '@var int $socid' docblock; it is now actually defined. --- htdocs/societe/canvas/company/tpl/card_view.tpl.php | 3 ++- htdocs/societe/canvas/individual/tpl/card_view.tpl.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/htdocs/societe/canvas/company/tpl/card_view.tpl.php b/htdocs/societe/canvas/company/tpl/card_view.tpl.php index 88ae1d28665..aad660b75c5 100644 --- a/htdocs/societe/canvas/company/tpl/card_view.tpl.php +++ b/htdocs/societe/canvas/company/tpl/card_view.tpl.php @@ -34,6 +34,7 @@ if (empty($conf) || !is_object($conf)) { $soc = $GLOBALS['objcanvas']->control->object; +$socid = $soc->id; print "\n"; @@ -291,7 +292,7 @@ $urlsource = $_SERVER["PHP_SELF"]."?socid=".$socid; $genallowed = $user->hasRight('societe', 'lire'); $delallowed = $user->hasRight('societe', 'creer'); -print $formfile->showdocuments('company', $socid, $filedir, $urlsource, $genallowed, $delallowed, '', 0, 0, 0, 28, 0, '', 0, '', $objcanvas->control->object->default_lang); +print $formfile->showdocuments('company', $socid, $filedir, $urlsource, $genallowed, $delallowed, '', 0, 0, 0, 28, 0, '', 0, '', $soc->default_lang); ?> diff --git a/htdocs/societe/canvas/individual/tpl/card_view.tpl.php b/htdocs/societe/canvas/individual/tpl/card_view.tpl.php index d1834d663f2..5cb4153cadc 100644 --- a/htdocs/societe/canvas/individual/tpl/card_view.tpl.php +++ b/htdocs/societe/canvas/individual/tpl/card_view.tpl.php @@ -35,6 +35,7 @@ if (empty($conf) || !is_object($conf)) { $object = $GLOBALS['objcanvas']->control->object; +$socid = $object->id; print "\n"; @@ -219,7 +220,7 @@ $urlsource = $_SERVER["PHP_SELF"]."?socid=".$socid; $genallowed = $user->hasRight('societe', 'lire'); $delallowed = $user->hasRight('societe', 'creer'); -print $formfile->showdocuments('company', $socid, $filedir, $urlsource, $genallowed, $delallowed, '', 0, 0, 0, 28, 0, '', 0, '', $objcanvas->control->object->default_lang); +print $formfile->showdocuments('company', $socid, $filedir, $urlsource, $genallowed, $delallowed, '', 0, 0, 0, 28, 0, '', 0, '', $object->default_lang); ?> From 1aeec05d8091041ef294b5e2cfc6c6d3647f52cd Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Mon, 13 Jul 2026 06:57:42 +0200 Subject: [PATCH 100/307] Fix: MCP server propagates authenticated service user to tools (#38294) The MCP server (htdocs/ai/server/mcp_server.php) loads a service user from AI_MCP_USER_ID but does not expose it as the PHP global $user. Several tool classes call 'global $user' to identify the caller -- they therefore see no authenticated user in HTTP MCP context (no PHP web session exists) and return 'User not authenticated' even though authentication via X-API-Key/Bearer has already succeeded. This breaks roughly 40% of the MCP tools shipped by the module when called from external clients (Claude Desktop Custom Connectors, Claude Code CLI, MCP Inspector, custom scripts). Two complementary fixes applied: 1. mcp_server.php: after successfully loading the service user, assign it to $GLOBALS['user'] and the local $user variable. This acts as a safety net for any tool (current or third-party) that relies on the legacy 'global $user' pattern. 2. Three tool classes harmonize their constructor signatures with the McpHandler contract -- new $className($db, $user, $conf): - ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php) - ToolInvoices (htdocs/ai/tools/invoices.class.php) - ToolReports (htdocs/ai/tools/reports.class.php) These now accept $user via constructor (matching the pattern already used in ToolThirdParty, ToolCategories, ToolProducts) and store it as $this->user. Methods that previously called 'global $user' (execute, validateInvoice, payInvoice) now use the injected $this->user, with a fallback to the global for web-session contexts. Tested with: - create_other_document (proposal, supplier_invoice): works - search_invoice, validate_invoice, pay_invoice: works - get_sales_report, get_purchase_report: works - AI Assistant web UI: unchanged, no regression --- htdocs/ai/server/mcp_server.php | 9 ++++++ htdocs/ai/tools/crud_objects.class.php | 33 +++++++++++++++++----- htdocs/ai/tools/invoices.class.php | 38 ++++++++++++++++++++++++-- htdocs/ai/tools/reports.class.php | 11 +++++++- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/htdocs/ai/server/mcp_server.php b/htdocs/ai/server/mcp_server.php index e70caa4810f..f5bba5964ea 100644 --- a/htdocs/ai/server/mcp_server.php +++ b/htdocs/ai/server/mcp_server.php @@ -2,6 +2,7 @@ /* Copyright (C) 2026 Laurent Destailleur * Copyright (C) 2026 Nick Fragoulis * Copyright (C) 2026 MDW + * Copyright (C) 2026 Jose Martinez * * 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 @@ -131,6 +132,14 @@ if ($userId > 0) { if ($result > 0) { $serviceUser->loadRights(); + // Promote the service user to the global $user so MCP tools that + // legitimately rely on the `global $user` pattern (Dolibarr core + // convention) see an authenticated user. Without this, there is + // no PHP web session in HTTP MCP context and any tool reading + // `global $user` would treat the request as unauthenticated even + // though authentication via X-API-Key/Bearer succeeded above. + global $user; + $user = $serviceUser; } else { http_response_code(500); echo json_encode([ diff --git a/htdocs/ai/tools/crud_objects.class.php b/htdocs/ai/tools/crud_objects.class.php index 657f48fd9d4..da8f5dda046 100644 --- a/htdocs/ai/tools/crud_objects.class.php +++ b/htdocs/ai/tools/crud_objects.class.php @@ -2,6 +2,7 @@ /* Copyright (C) 2026 Laurent Destailleur * Copyright (C) 2026 Nick Fragoulis * Copyright (C) 2026 MDW + * Copyright (C) 2026 Jose Martinez * * 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 @@ -37,11 +38,22 @@ class ToolCrudObjects extends McpTool /** * Constructor * + * Aligned with McpHandler's instantiation contract: new $className($db, $user, $conf). + * Accepting $user via DI allows this tool to work in HTTP MCP context where no PHP + * web session exists. Some sibling tool classes (ToolThirdParty, ToolCategories, + * ToolProducts) already use this signature; this aligns ToolCrudObjects with them. + * * @param DoliDB $db Database handler + * @param User|null $user Service user provided by McpHandler (from AI_MCP_USER_ID) + * @param Conf|null $conf Dolibarr config (optional) */ - public function __construct(DoliDB $db) + public function __construct(DoliDB $db, $user = null, $conf = null) { $this->db = $db; + $this->user = $user; + if ($conf !== null) { + $this->conf = $conf; + } } @@ -322,13 +334,20 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t */ public function execute(string $name, array $args) { - global $user, $langs, $conf, $mysoc; + global $langs, $conf, $mysoc; - // Ensure $this->user is the authenticated global user - $this->user = $user; - - if (!$user->id) { - return ["error" => "User not authenticated."]; + // Use the user injected via constructor (McpHandler). Fall back to global $user + // when running in a web session context (e.g. AI Assistant) where DI is not used. + // is_object() is used instead of empty() because the parent McpTool declares $user + // with a non-nullable type hint, which makes PHPStan flag empty($this->user) as + // unreachable code. + if (!is_object($this->user) || empty($this->user->id)) { + global $user; + if (is_object($user) && !empty($user->id)) { + $this->user = $user; + } else { + return ["error" => "User not authenticated."]; + } } if (!is_object($mysoc) || empty($mysoc->id)) { diff --git a/htdocs/ai/tools/invoices.class.php b/htdocs/ai/tools/invoices.class.php index d4e1ec79342..62cba455307 100644 --- a/htdocs/ai/tools/invoices.class.php +++ b/htdocs/ai/tools/invoices.class.php @@ -1,6 +1,7 @@ * Copyright (C) 2026 Nick Fragoulis + * Copyright (C) 2026 Jose Martinez * * 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,11 +41,36 @@ class ToolInvoices extends McpTool /** * Constructor * + * Aligned with McpHandler's instantiation contract: new $className($db, $user, $conf). + * * @param DoliDB $db Database handler + * @param User|null $user Service user provided by McpHandler (from AI_MCP_USER_ID) + * @param Conf|null $conf Dolibarr config (optional) */ - public function __construct(DoliDB $db) + public function __construct(DoliDB $db, $user = null, $conf = null) { $this->db = $db; + $this->user = $user; + if ($conf !== null) { + $this->conf = $conf; + } + } + + /** + * Return the authenticated user, preferring DI ($this->user) and falling back to global $user. + * In HTTP MCP context there is no PHP web session — only the service user injected via DI. + * + * @return User|null + */ + private function getUser() + { + // is_object() instead of empty() to avoid PHPStan flagging empty() on a + // non-nullable typed parent property as unreachable. + if (is_object($this->user) && !empty($this->user->id)) { + return $this->user; + } + global $user; + return is_object($user) && !empty($user->id) ? $user : null; } /** @@ -312,7 +338,10 @@ class ToolInvoices extends McpTool */ private function validateInvoice($args) { - global $user; + $user = $this->getUser(); + if ($user === null) { + return ["error" => "User not authenticated."]; + } $invoice = $this->findInvoice($args['invoice']); if (is_array($invoice)) { return $invoice; @@ -351,7 +380,10 @@ class ToolInvoices extends McpTool */ private function payInvoice($args) { - global $user; + $user = $this->getUser(); + if ($user === null) { + return ["error" => "User not authenticated."]; + } $invoice = $this->findInvoice($args['invoice']); if (is_array($invoice)) { return $invoice; diff --git a/htdocs/ai/tools/reports.class.php b/htdocs/ai/tools/reports.class.php index 3cff4cb8065..6510c86cabb 100644 --- a/htdocs/ai/tools/reports.class.php +++ b/htdocs/ai/tools/reports.class.php @@ -1,6 +1,7 @@ * Copyright (C) 2026 Nick Fragoulis + * Copyright (C) 2026 Jose Martinez * * 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,11 +41,19 @@ class ToolReports extends McpTool /** * Constructor * + * Aligned with McpHandler's instantiation contract: new $className($db, $user, $conf). + * * @param DoliDB $db Database handler + * @param User|null $user Service user provided by McpHandler (from AI_MCP_USER_ID) + * @param Conf|null $conf Dolibarr config (optional) */ - public function __construct(DoliDB $db) + public function __construct(DoliDB $db, $user = null, $conf = null) { $this->db = $db; + $this->user = $user; + if ($conf !== null) { + $this->conf = $conf; + } } /** From d02749d15b30096d5813287b1544a90822fc8148 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 10:02:23 +0200 Subject: [PATCH 101/307] css --- htdocs/theme/eldy/dropdown.inc.php | 1 + 1 file changed, 1 insertion(+) diff --git a/htdocs/theme/eldy/dropdown.inc.php b/htdocs/theme/eldy/dropdown.inc.php index b79ee6d9b16..672f641fc20 100644 --- a/htdocs/theme/eldy/dropdown.inc.php +++ b/htdocs/theme/eldy/dropdown.inc.php @@ -332,6 +332,7 @@ a.top-menu-dropdown-link { #topmenu-tool, #topmenu-global-search-dropdown, +#topmenu-ai-dropdown, #topmenu-quickadd-dropdown, #topmenu-bookmark-dropdown, #topmenu-uploadfile-dropdown, From 5ec93d9c94984f9c2abdab170dac4c66194376aa Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 10:35:52 +0200 Subject: [PATCH 102/307] Debug v24 --- htdocs/blockedlog/lib/securitycore.lib.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/blockedlog/lib/securitycore.lib.php b/htdocs/blockedlog/lib/securitycore.lib.php index 7495f1551a6..4323c0360c8 100644 --- a/htdocs/blockedlog/lib/securitycore.lib.php +++ b/htdocs/blockedlog/lib/securitycore.lib.php @@ -71,7 +71,7 @@ function dolEncrypt($chain, $key = '', $ciphering = '', $forceseed = '', $obfusc } $reg = array(); - if (preg_match('/^(dolobfuscationv1[^:]+|dolcrypt):([^:]+):(.+)$/', $chain, $reg)) { + if (preg_match('/^(dolobfuscation|dolcrypt)[^:]*:([^:]+):(.+)$/', $chain, $reg)) { // The $chain is already an encrypted string return $chain; } @@ -158,17 +158,17 @@ function dolDecrypt($chain, $key = '', $patterntotest = '') } // New method - if (preg_match('/^dol[^:]+:([^:]+):(.+)$/', $chain, $reg)) { + if (preg_match('/^(dolobfuscation|dolcrypt)[^:]*:([^:]+):(.+)$/', $chain, $reg)) { // Do not enable this log, except during debug //dol_syslog("We try to decrypt the chain: ".$chain, LOG_DEBUG); - $ciphering = $reg[1]; + $ciphering = $reg[2]; if (function_exists('openssl_decrypt')) { if (empty($key)) { dol_syslog("Error dolDecrypt decrypt key is empty", LOG_WARNING); return $chain; } - $tmpexplode = explode(':', $reg[2]); + $tmpexplode = explode(':', $reg[3]); if (!empty($tmpexplode[1])) { $data = $tmpexplode[1]; $iv = $tmpexplode[0]; From 80ace0d0cbbb108ab61d30a852c9772e91b2272b Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 10:50:17 +0200 Subject: [PATCH 103/307] Complete template issue --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 57628ed8ac5..6c8ac975245 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -22,6 +22,8 @@ body: label: Dolibarr Version description: Affected Dolibarr version(s) placeholder: 19.0, develop, ... + validations: + required: true - type: input id: environment-php @@ -40,6 +42,8 @@ body: attributes: label: Steps to reproduce the behavior and expected behavior description: Verbose description + validations: + required: true - type: textarea id: files From f6b4073c3f45ec4a9cdc192174fdab11da2687d9 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 11:37:27 +0200 Subject: [PATCH 104/307] Fix fatal error --- htdocs/core/lib/website2.lib.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/core/lib/website2.lib.php b/htdocs/core/lib/website2.lib.php index ce582efbc82..eae2a335841 100644 --- a/htdocs/core/lib/website2.lib.php +++ b/htdocs/core/lib/website2.lib.php @@ -430,8 +430,8 @@ function dolSavePageContent($filetpl, Website $object, WebsitePage $objectpage, $tplcontent .= '$tmp = preg_replace("/^/ms", "", $tmp);'."\n"; } if (strpos($objectpage->content, 'define("__SEO_PAGE_TITLE__"') !== false) { - $tplcontent .= '$tmp = preg_replace("/^.*?<\/title>/ms", "<title>" . dolPrintHTMLForAttribute(constant("__SEO_PAGE_TITLE__"), 1) . "", $tmp);'."\n"; - $tplcontent .= '$tmp = preg_replace("/^/ms", "", $tmp);'."\n"; + $tplcontent .= '$tmp = preg_replace("/^.*?<\/title>/ms", "<title>" . dolPrintHTMLForAttribute(defined("__SEO_PAGE_TITLE__") ? constant("__SEO_PAGE_TITLE__") : "", 1) . "", $tmp);'."\n"; + $tplcontent .= '$tmp = preg_replace("/^/ms", "", $tmp);'."\n"; } if (strpos($objectpage->content, 'define("__SEO_PAGE_DESC__"') !== false) { $tplcontent .= '$tmp = preg_replace("/^/ms", "", $tmp);'."\n"; From 95701131bc817c0a22de84447a09888a82eac564 Mon Sep 17 00:00:00 2001 From: minimexat Date: Mon, 13 Jul 2026 11:45:45 +0200 Subject: [PATCH 105/307] FIX: use generic online signature token fallback (#39138) * FIX: Use generic online signature token fallback * FIX: Avoid nullable source in signature token lookup * FIX: Initialize online signature message * FIX: Print online signature error directly --------- Co-authored-by: f-hoedl --- ChangeLog | 1 + htdocs/public/onlinesign/newonlinesign.php | 11 ++++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/ChangeLog b/ChangeLog index 3a3987fc063..5428d157fde 100644 --- a/ChangeLog +++ b/ChangeLog @@ -167,6 +167,7 @@ NEW: Add delivery date in supplier order linked object block (#38351) NEW: A lot of look and feel enhancement in the immutable log pages. NEW: Enhance the tools to check file integrity. NEW: The OAuth login with Google stay on the requested page after the redirect instead of reaching the home page. +FIX: Use generic online signature security token for unlisted source verifier. NEW: Add shipment online url to substitution array NEW: ModuleBuilder - selectable object tabs (+ fixes for multi-object generation) NEW: Add hook getTicketMessageEmailFrom to override sender in Ticket::sendTicketMessageByEmail diff --git a/htdocs/public/onlinesign/newonlinesign.php b/htdocs/public/onlinesign/newonlinesign.php index 750a3a949df..f615b08f33b 100644 --- a/htdocs/public/onlinesign/newonlinesign.php +++ b/htdocs/public/onlinesign/newonlinesign.php @@ -97,7 +97,6 @@ $ref = $REF = GETPOST("ref", 'alpha'); $urlok = ''; $urlko = ''; - if ($source == '') { $source = 'proposal'; } @@ -155,6 +154,8 @@ if ($source == 'proposal') { $securekeyseed = getDolGlobalString('FICHINTER_ONLINE_SIGNATURE_SECURITY_TOKEN', $defaultsalt); } elseif ($source == 'societe_rib') { $securekeyseed = getDolGlobalString('SOCIETE_RIB_ONLINE_SIGNATURE_SECURITY_TOKEN', $defaultsalt); +} else { + $securekeyseed = getDolGlobalString(dol_strtoupper((string) $source).'_ONLINE_SIGNATURE_SECURITY_TOKEN', $defaultsalt); } if (!dol_verifyHash($securekeyseed.$type.$ref.(isModEnabled('multicompany') ? $entity : ''), $SECUREKEY, 'hash')) { httponly_accessforbidden('Bad value for securitykey. Value provided '.dol_escape_htmltag($SECUREKEY).' does not match expected value for ref='.dol_escape_htmltag($ref), 403, 1); @@ -686,12 +687,8 @@ if ($source == 'proposal') { $parameters = array('source' => $source); $reshook = $hookmanager->executeHooks('addFormSign', $parameters, $object, $action); // Note that $action and $object may have been modified by hook -if (!$found && !$mesg) { - $mesg = $langs->transnoentitiesnoconv("ErrorBadParameters"); -} - -if ($mesg) { - print '
'.dol_escape_htmltag($mesg).'
'."\n"; +if (!$found) { + print '
'.dol_escape_htmltag($langs->transnoentitiesnoconv("ErrorBadParameters")).'
'."\n"; } print ''."\n"; From 2cd4aa3abb1ba3207a00d0b236cd496425207c42 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 11:48:42 +0200 Subject: [PATCH 106/307] Doc --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 059c640e385..e2a9fbdfafb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,7 @@ If possible: - Change the global architecture of existing modules - Delete code without justification and a comment explaining why - Add external dependencies (Composer packages, JS libraries) without prior validation +- Modify the `ChangeLog` file (this file will be updated by the maintainer during the release process) --- From 0726bf945a6ed96da47c3374bdb13695dea57a57 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 11:59:49 +0200 Subject: [PATCH 107/307] Typo --- htdocs/api/class/api_emailtemplates.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/api/class/api_emailtemplates.class.php b/htdocs/api/class/api_emailtemplates.class.php index 86205906f62..624fd76cb88 100644 --- a/htdocs/api/class/api_emailtemplates.class.php +++ b/htdocs/api/class/api_emailtemplates.class.php @@ -123,7 +123,7 @@ class EmailTemplates extends DolibarrApi * @throws RestException 404 * @throws RestException 500 */ - public function deleteByLAbel($label) + public function deleteByLabel($label) { $allowaccess = $this->_checkAccessRights('lire'); if (!$allowaccess) { From 049851bc31ed602dc8c066e36b5b053b48ba9dea Mon Sep 17 00:00:00 2001 From: minimexat Date: Mon, 13 Jul 2026 12:01:16 +0200 Subject: [PATCH 108/307] FIX: require invoice right for order mass invoice action (#39139) * FIX: Require invoice right for order mass invoice * Update list.php * Update ChangeLog --------- Co-authored-by: f-hoedl Co-authored-by: Laurent Destailleur --- htdocs/commande/list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/commande/list.php b/htdocs/commande/list.php index d1493e44a1f..98ede89955e 100644 --- a/htdocs/commande/list.php +++ b/htdocs/commande/list.php @@ -411,7 +411,7 @@ if (empty($reshook)) { $month = ""; include DOL_DOCUMENT_ROOT.'/core/actions_massactions.inc.php'; - if ($massaction == 'confirm_createbills') { // Create bills from orders. + if ($massaction == 'confirm_createbills' && $user->hasRight('facture', 'creer')) { // Create bills from orders. $orders = GETPOST('toselect', 'array:int'); $createbills_onebythird = GETPOSTINT('createbills_onebythird'); $validate_invoices = GETPOSTINT('validate_invoices'); From 6b71832a441cadea855868247fe1bda5e20da2a5 Mon Sep 17 00:00:00 2001 From: minimexat Date: Mon, 13 Jul 2026 12:02:09 +0200 Subject: [PATCH 109/307] FIX: escape cron command attributes (#39126) * FIX: escape cron command attributes * Update ChangeLog --------- Co-authored-by: f-hoedl Co-authored-by: Laurent Destailleur --- htdocs/cron/card.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/cron/card.php b/htdocs/cron/card.php index 988306b1639..16aaad35453 100644 --- a/htdocs/cron/card.php +++ b/htdocs/cron/card.php @@ -402,7 +402,7 @@ if (($action == "create") || ($action == "edit")) { print ''; print $langs->trans('CronArgs').""; - print ' '; + print ' '; print ""; print ""; print $form->textwithpicto('', $langs->trans("CronArgsHelp"), 1, 'help'); @@ -411,7 +411,7 @@ if (($action == "create") || ($action == "edit")) { print ''; print $langs->trans('CronCommand').""; - print ' '; + print ' '; print ""; print ""; print $form->textwithpicto('', $langs->trans("CronCommandHelp"), 1, 'help'); From f96a4af9f3730126bd6301d8ef2ca04e6022fee3 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 12:04:47 +0200 Subject: [PATCH 110/307] Doc --- AGENTS.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2a9fbdfafb..070e899949b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,8 +65,7 @@ Before writing any code, the agent **must**: - Use Dolibarr database functions exclusively — never use PDO or MySQLi directly - In pages: use global `$db` - In classes: use `$this->db` -- ✅ Always escape user inputs -- ✅ SQL forged by PHP must escape fields with `db->escape()`, `db->sanitize()`, or by casting values to `(int)` or `(float)` +- ✅ SQL forged by PHP must escaped fields with `db->escape()`, `db->sanitize()`, or by casting values to `(int)` or `(float)` - ✅ Always use `$db->query()` followed by `$db->fetch_object()` or `$db->fetch_array()` to retrieve results - ✅ SQL scripts for table and index creation must be placed in `htdocs/install/mysql/tables/` (see existing files for examples) - ❌ Never run SQL queries inside loops (avoid N+1 problem — use JOINs or batch queries instead) @@ -126,7 +125,7 @@ If possible: - Always validate user inputs (`GET`, `POST`) via `GETPOST()` with a type parameter - Prevent SQL injection (use `db->escape()` or cast into `(int)` or `(float)`) -- Prevent XSS injection (use `dolPrintHTML()`, `dolPrintHTMLForAttribute()`, ...) +- Prevent XSS injection by escaping HTML output (use `dolPrintHTML()`, `dolPrintHTMLForAttribute()`) - Always include Dolibarr CSRF tokens in POST forms: `` --- From 9105fa48132f670ea2d95511080bf7874693b649 Mon Sep 17 00:00:00 2001 From: minimexat Date: Mon, 13 Jul 2026 12:05:08 +0200 Subject: [PATCH 111/307] FIX: escape session list fields (#39129) * FIX: Escape session list fields * FIX: Cast nullable session fields before escaping * Update ChangeLog with new features and fixes --------- Co-authored-by: f-hoedl Co-authored-by: Laurent Destailleur --- htdocs/admin/tools/listsessions.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/admin/tools/listsessions.php b/htdocs/admin/tools/listsessions.php index a5ced26531b..13d1dab89e2 100644 --- a/htdocs/admin/tools/listsessions.php +++ b/htdocs/admin/tools/listsessions.php @@ -157,7 +157,7 @@ if ($savehandler == 'files') { print ''; // Login - print ''.$sessionentry['login'].''; + print ''.dol_escape_htmltag($sessionentry['login']).''; // ID print ''; @@ -207,7 +207,7 @@ if ($savehandler == 'files') { print ''; // Login - print ''.$sessionentry['login'].''; + print ''.dol_escape_htmltag($sessionentry['login']).''; // ID print ''; @@ -228,10 +228,10 @@ if ($savehandler == 'files') { print ''.$sessionentry['age'].''; // Remote IP - print ''.$sessionentry['remote_ip'].''; + print ''.dol_escape_htmltag((string) $sessionentry['remote_ip']).''; // User Agent - print ''.$sessionentry['user_agent'].''; + print ''.dol_escape_htmltag((string) $sessionentry['user_agent']).''; print ' '; print "\n"; $i++; From 1b4f92b54301e5bcc1cc992b9fad9cbf93f01ef0 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 12:13:35 +0200 Subject: [PATCH 112/307] Fix warning --- htdocs/core/website.inc.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/htdocs/core/website.inc.php b/htdocs/core/website.inc.php index f84dbce31b8..922255151f5 100644 --- a/htdocs/core/website.inc.php +++ b/htdocs/core/website.inc.php @@ -251,7 +251,11 @@ if ($_SERVER['PHP_SELF'] != DOL_URL_ROOT.'/website/index.php') { // If we browsi $sql = "SELECT wp.rowid, wp.lang, wp.pageurl, wp.fk_page"; $sql .= " FROM ".MAIN_DB_PREFIX."website_page as wp"; $sql .= " WHERE wp.fk_website = ".((int) $website->id); - $sql .= " AND (wp.fk_page = ".((int) $pageid)." OR wp.rowid = ".((int) $pageid); + if (!empty($pageid)) { + $sql .= " AND (wp.fk_page = ".((int) $pageid)." OR wp.rowid = ".((int) $pageid); + } else { + $sql .= " AND (0"; + } if (is_object($websitepage) && $websitepage->fk_page > 0) { $sql .= " OR wp.fk_page = ".((int) $websitepage->fk_page)." OR wp.rowid = ".((int) $websitepage->fk_page); } From 1d5acc6268674084520ba3fd44b95b292d5b2b31 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 13:17:41 +0200 Subject: [PATCH 113/307] FIX bad dol_eval test when method is wrong --- htdocs/core/class/menubase.class.php | 2 +- htdocs/core/lib/functions.lib.php | 2 +- htdocs/core/menus/standard/eldy.lib.php | 4 ++-- test/phpunit/FunctionsLibTest.php | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/htdocs/core/class/menubase.class.php b/htdocs/core/class/menubase.class.php index 2b9d18fa4fe..d1abc4af54d 100644 --- a/htdocs/core/class/menubase.class.php +++ b/htdocs/core/class/menubase.class.php @@ -679,7 +679,7 @@ class Menubase $tmpcond = preg_replace('/\$leftmenu\s*==\s*["\'a-zA-Z_]+/', '1==1', $tmpcond); // Force the part of condition on leftmenu to true } $perms = verifCond($tmpcond); - //print "verifCond rowid=".$menu['rowid']." ".$tmpcond.":".$perms."
\n"; + //var_dump($menu['rowid'].' - '.$menu['titre'].' - '.$menu['perms'].' => '.$tmpcond.":".$perms); } // Define $enabled diff --git a/htdocs/core/lib/functions.lib.php b/htdocs/core/lib/functions.lib.php index c7a869862c8..fd4d21c4cb2 100644 --- a/htdocs/core/lib/functions.lib.php +++ b/htdocs/core/lib/functions.lib.php @@ -11927,7 +11927,7 @@ function verifCond($strToEvaluate, $onlysimplestring = '1') // On string syntax error, dol_eval may return a string that start with 'Bad call of ...' or 'Bad string syntax to evaluate...' !!! //var_dump($strToEvaluate, $rep); - $rights = (bool) $rep && (!is_string($rep) || strpos($rep, 'Bad call of') === false || strpos($rep, 'Bad string syntax to evaluate') === false); + $rights = (bool) $rep && (!is_string($rep) || (strpos($rep, 'Exception during') === false && strpos($rep, 'Bad call of') === false && strpos($rep, 'Bad string syntax to evaluate') === false)); //var_dump($rights); } return $rights; diff --git a/htdocs/core/menus/standard/eldy.lib.php b/htdocs/core/menus/standard/eldy.lib.php index 24368fd9c5d..8bb19eb34bd 100644 --- a/htdocs/core/menus/standard/eldy.lib.php +++ b/htdocs/core/menus/standard/eldy.lib.php @@ -883,7 +883,7 @@ function print_left_eldy_menu($db, $menu_array_before, $menu_array_after, &$tabM get_left_menu_members($mainmenu, $newmenu, $usemenuhider, $leftmenu, $type_user); } - // Add personalized menus and modules menus + // Add personalized menus (from database setup) and modules menus //var_dump($newmenu->liste); // $menuArbo = new Menubase($db, 'eldy'); $newmenu = $menuArbo->menuLeftCharger($newmenu, $mainmenu, $leftmenu, (empty($user->socid) ? 0 : 1), 'eldy', $tabMenu); @@ -908,7 +908,7 @@ function print_left_eldy_menu($db, $menu_array_before, $menu_array_after, &$tabM //var_dump($newmenu->liste); // Build final $menu_array = $menu_array_before +$newmenu->liste + $menu_array_after - //var_dump($menu_array_before);exit; + //var_dump($menu_array_after);exit; $menu_array = $newmenu->liste; if (is_array($menu_array_before)) { diff --git a/test/phpunit/FunctionsLibTest.php b/test/phpunit/FunctionsLibTest.php index 32cba14e609..d6192e3d08e 100644 --- a/test/phpunit/FunctionsLibTest.php +++ b/test/phpunit/FunctionsLibTest.php @@ -1371,6 +1371,21 @@ class FunctionsLibTest extends CommonClassTest } } + /** + * testVerifCondOnMethods + * + * @return void + */ + public function testVerifCondOnMethods() + { + $a = verifCond('$user->hasRight("facture", "read")'); + $this->assertTrue($a); + + $a = verifCond('$user->hasMethodKo("facture", "read")'); + $this->assertFalse($a); + } + + /** * testGetDefaultTva * From 3e95b7057cc8bd0eaedb59428bfc82accbc3cc52 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 13:39:25 +0200 Subject: [PATCH 114/307] Fix bad picto on not allowed left menu --- htdocs/core/menus/standard/eldy.lib.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/htdocs/core/menus/standard/eldy.lib.php b/htdocs/core/menus/standard/eldy.lib.php index 8bb19eb34bd..5926d7bafd0 100644 --- a/htdocs/core/menus/standard/eldy.lib.php +++ b/htdocs/core/menus/standard/eldy.lib.php @@ -1063,7 +1063,12 @@ function print_left_eldy_menu($db, $menu_array_before, $menu_array_after, &$tabM print ''; if ($action == 'delete') { - print $form->formconfirm($_SERVER["PHP_SELF"].'?&id='.urlencode($numsondage), $langs->trans("RemovePoll"), $langs->trans("ConfirmRemovalOfPoll", $id), 'delete_confirm', '', '', 1); + print $form->formconfirm($_SERVER["PHP_SELF"].'?&id='.urlencode($numsondage), $langs->trans("RemovePoll"), $langs->trans("ConfirmRemovalOfPoll", $numsondage), 'delete_confirm', '', '', 1); } diff --git a/htdocs/opensurvey/results.php b/htdocs/opensurvey/results.php index 0dbaf6794fe..739b2c1da95 100644 --- a/htdocs/opensurvey/results.php +++ b/htdocs/opensurvey/results.php @@ -528,7 +528,7 @@ print ''; // Expire date print '
'.$langs->trans('ExpireDate').''; if ($action == 'edit') { - print $form->selectDate($expiredate ? $expiredate : $object->date_fin, 'expire', 0, 0, 0, '', 1, 0); + print $form->selectDate(!empty($expiredate) ? $expiredate : $object->date_fin, 'expire', 0, 0, 0, '', 1, 0); } else { print dol_print_date($object->date_fin, 'day'); if ($object->date_fin && $object->date_fin < dol_now() && $object->status == Opensurveysondage::STATUS_VALIDATED) { diff --git a/htdocs/projet/card.php b/htdocs/projet/card.php index 54d7d8d1dc4..20c723ac6b2 100644 --- a/htdocs/projet/card.php +++ b/htdocs/projet/card.php @@ -1808,7 +1808,7 @@ if ($action == 'create' && $user->hasRight('projet', 'creer')) { print '
'; print '
'; - print ''; + print '
'; print ''; print getTitleFieldOfList('Ref', 0, $_SERVER["PHP_SELF"], '', '', '', '', '', '', '', 1); print getTitleFieldOfList('Title', 0, $_SERVER["PHP_SELF"], '', '', '', '', '', '', '', 1); diff --git a/htdocs/reception/card.php b/htdocs/reception/card.php index e475ae00b06..a19f1e194f7 100644 --- a/htdocs/reception/card.php +++ b/htdocs/reception/card.php @@ -716,7 +716,7 @@ if (empty($reshook)) { $newlang = GETPOST('lang_id', 'aZ09'); } if (getDolGlobalInt('MAIN_MULTILANGS') && empty($newlang)) { - $newlang = $reception->thirdparty->default_lang; + $newlang = $object->thirdparty->default_lang; } if (!empty($newlang)) { $outputlangs = new Translate("", $conf); From 6f0bc8720911cba055d5b182345af094e4814b10 Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Mon, 13 Jul 2026 23:11:51 +0900 Subject: [PATCH 129/307] Qual: Fix PHPStan always true/false errors breaking develop CI (#39114) The full PHPStan analysis that runs on push to develop (and *.0 branches) reported 5 new 'always true/false' errors, not caught by the per-PR diff analysis and not yet in the baseline: - htdocs/document.php: is_numeric() on the int-cast $entity is always true; the redundant check is removed. - htdocs/salaries/stats/index.php: $mode is hardcoded to 'statistics', so the three view-switch button state comparisons are constant; replaced with their literal results (1/1/2) and removed the now-unused $mode. - htdocs/theme/md/style.css.php: method_exists($menumanager, 'showmenu') is always true since $menumanager is a MenuManager; the redundant check is removed. Behaviour is unchanged. --- htdocs/document.php | 2 +- htdocs/salaries/stats/index.php | 7 +++---- htdocs/theme/md/style.css.php | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/htdocs/document.php b/htdocs/document.php index e4f57bd3a18..d41e789d11c 100644 --- a/htdocs/document.php +++ b/htdocs/document.php @@ -81,7 +81,7 @@ if ((isset($_GET["modulepart"]) && $_GET["modulepart"] == 'medias')) { // be the one of this entity. // Do not use GETPOST here, function is not defined and define must be done before including main.inc.php $entity = (!empty($_GET['entity']) ? (int) $_GET['entity'] : (!empty($_POST['entity']) ? (int) $_POST['entity'] : 0)); -if (is_numeric($entity) && $entity > 0) { +if ($entity > 0) { // An entity was forced on param, so we force the constant to allow master.inc.php to use this entity if not already logged. // It has no effect if already logged. define("DOLENTITY", $entity); diff --git a/htdocs/salaries/stats/index.php b/htdocs/salaries/stats/index.php index a5fd59a217d..e792c2bcbcc 100644 --- a/htdocs/salaries/stats/index.php +++ b/htdocs/salaries/stats/index.php @@ -104,12 +104,11 @@ $massactionbutton = ''; $num = -1; $nbtotalofrecords = $langs->trans("Statistics"); $limit = 0; -$mode = 'statistics'; $newcardbutton = ''; -$newcardbutton .= dolGetButtonTitle($langs->trans('ViewList'), '', 'fa fa-bars imgforviewmode', DOL_URL_ROOT.'/salaries/list.php?mode=common'.preg_replace('/(&|\?)*mode=[^&]+/', '', $param), '', ($mode == 'common' ? 2 : 1), array('morecss' => 'reposition')); -$newcardbutton .= dolGetButtonTitle($langs->trans('ViewKanban'), '', 'fa fa-th-list imgforviewmode', DOL_URL_ROOT.'/salaries/list.php?mode=kanban'.preg_replace('/(&|\?)*mode=[^&]+/', '', $param), '', ($mode == 'kanban' ? 2 : 1), array('morecss' => 'reposition')); -$newcardbutton .= dolGetButtonTitle($langs->trans('Statistics'), '', 'fa fa-chart-bar imgforviewmode', DOL_URL_ROOT.'/salaries/stats/index.php?'.preg_replace('/(&|\?)*(mode|groupby)=[^&]+/', '', $param), '', ($mode == 'statistics' ? 2 : 1), array('morecss' => 'reposition')); +$newcardbutton .= dolGetButtonTitle($langs->trans('ViewList'), '', 'fa fa-bars imgforviewmode', DOL_URL_ROOT.'/salaries/list.php?mode=common'.preg_replace('/(&|\?)*mode=[^&]+/', '', $param), '', 1, array('morecss' => 'reposition')); +$newcardbutton .= dolGetButtonTitle($langs->trans('ViewKanban'), '', 'fa fa-th-list imgforviewmode', DOL_URL_ROOT.'/salaries/list.php?mode=kanban'.preg_replace('/(&|\?)*mode=[^&]+/', '', $param), '', 1, array('morecss' => 'reposition')); +$newcardbutton .= dolGetButtonTitle($langs->trans('Statistics'), '', 'fa fa-chart-bar imgforviewmode', DOL_URL_ROOT.'/salaries/stats/index.php?'.preg_replace('/(&|\?)*(mode|groupby)=[^&]+/', '', $param), '', 2, array('morecss' => 'reposition')); $newcardbutton .= dolGetButtonTitleSeparator(); $newcardbutton .= dolGetButtonTitle($langs->trans('NewSalary'), '', 'fa fa-plus-circle', $url, '', $permissiontoadd); diff --git a/htdocs/theme/md/style.css.php b/htdocs/theme/md/style.css.php index 6f4effd74ab..a5698098562 100644 --- a/htdocs/theme/md/style.css.php +++ b/htdocs/theme/md/style.css.php @@ -360,7 +360,7 @@ $colortext = implode(',', colorStringToArray($colortext)); $colortextlink = implode(',', colorStringToArray($colortextlink)); // @phan-suppress-next-line PhanRedefinedClassReference -$nbtopmenuentries = (is_object($menumanager) && method_exists($menumanager, 'showmenu')) ? $menumanager->showmenu('topnb') : 0; +$nbtopmenuentries = is_object($menumanager) ? $menumanager->showmenu('topnb') : 0; $nbtopmenuentriesreal = $nbtopmenuentries; if ($conf->browser->layout == 'phone') { $nbtopmenuentries = max($nbtopmenuentries, 10); From 1f217c564c779ca46e2cc06e4ffc59e03211894f Mon Sep 17 00:00:00 2001 From: minimexat Date: Mon, 13 Jul 2026 16:13:51 +0200 Subject: [PATCH 130/307] FIX: enforce document upload access checks (#39125) * FIX: enforce document upload access checks * Update ChangeLog for version 24.0.0 --------- Co-authored-by: f-hoedl Co-authored-by: Laurent Destailleur --- htdocs/api/class/api_documents.class.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/htdocs/api/class/api_documents.class.php b/htdocs/api/class/api_documents.class.php index 7192376b44c..fdb736a3b42 100644 --- a/htdocs/api/class/api_documents.class.php +++ b/htdocs/api/class/api_documents.class.php @@ -1124,6 +1124,9 @@ class Documents extends DolibarrApi $relativefile = $tmpreldir.dol_sanitizeFileName((string) $object->ref); } $tmp = dol_check_secure_access_document($modulepart, $relativefile, $entity, DolibarrApiAccess::$user, $ref, 'write'); + if (empty($tmp['accessallowed'])) { + throw new RestException(403, 'Access not allowed to upload file into this directory'); + } $upload_dir = $tmp['original_file']; // No dirname here, tmp['original_file'] is already the dir because dol_check_secure_access_document was called with param original_file that is only the dir /*} else { if (!DolibarrApiAccess::$user->hasRight('ecm', 'upload')) { @@ -1147,6 +1150,9 @@ class Documents extends DolibarrApi if ($modulepart != 'ecm') { $relativefile = $subdir; $tmp = dol_check_secure_access_document($modulepart, $relativefile, $entity, DolibarrApiAccess::$user, '', 'write'); + if (empty($tmp['accessallowed'])) { + throw new RestException(403, 'Access not allowed to upload file into this directory'); + } $upload_dir = $tmp['original_file']; // No dirname here, tmp['original_file'] is already the dir because dol_check_secure_access_document was called with param original_file that is only the dir } else { if (!DolibarrApiAccess::$user->hasRight('ecm', 'upload')) { From 57a47746c755cea9ffcf4819ee42ab8f9f55a913 Mon Sep 17 00:00:00 2001 From: Nathan <130627685+nathanpixodeo@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:14:22 +0700 Subject: [PATCH 131/307] FIX #39084 Use service ref label on service creation (#39097) --- htdocs/langs/en_US/products.lang | 1 + htdocs/langs/fr_FR/products.lang | 1 + htdocs/product/card.php | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/htdocs/langs/en_US/products.lang b/htdocs/langs/en_US/products.lang index 97986e9da45..540ee6b976d 100644 --- a/htdocs/langs/en_US/products.lang +++ b/htdocs/langs/en_US/products.lang @@ -1,5 +1,6 @@ # Dolibarr language file - Source file is en_US - products ProductRef=Product ref. +ServiceRef=Service ref. ProductLabel=Product label ProductLabelTranslated=Translated product label ProductDescription=Product description diff --git a/htdocs/langs/fr_FR/products.lang b/htdocs/langs/fr_FR/products.lang index dd0e1e8bde4..82c9fd13aae 100644 --- a/htdocs/langs/fr_FR/products.lang +++ b/htdocs/langs/fr_FR/products.lang @@ -1,5 +1,6 @@ # Dolibarr language file - Source file is en_US - products ProductRef=Réf. produit +ServiceRef=Réf. service ProductLabel=Libellé produit ProductLabelTranslated=Libellé produit traduit ProductDescription=Description du produit diff --git a/htdocs/product/card.php b/htdocs/product/card.php index ebee01408ed..e1d1a4d1bd4 100644 --- a/htdocs/product/card.php +++ b/htdocs/product/card.php @@ -157,6 +157,7 @@ if ($result > 0) { $object = new Product($db); $object->type = $type; // so test later to fill $usercancxxx is correct +$refLabelKey = ($type == Product::TYPE_SERVICE ? 'ServiceRef' : 'ProductRef'); // fetch optionals attributes and labels $extrafields->fetch_name_optionals_label($object->table_element); @@ -531,7 +532,7 @@ if (empty($reshook)) { } if (empty($ref)) { if (!getDolGlobalString('PRODUCT_GENERATE_REF_AFTER_FORM')) { - setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentities('ProductRef')), null, 'errors'); + setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentities($refLabelKey)), null, 'errors'); $action = "create"; $error++; } @@ -1516,7 +1517,7 @@ if (is_object($objcanvas) && $objcanvas->displayCanvasExists($canvasdisplayactio /** @var ModeleProductCode $modCodeProduct */ $tmpcode = $modCodeProduct->getNextValue($object, $type); } - print ''; + print ''; print ''; print ''; print ''; From edc19593c9d013345a17e70ec4f2a68afe34144a Mon Sep 17 00:00:00 2001 From: GregcodeOhm Date: Tue, 14 Jul 2026 00:42:54 +0200 Subject: [PATCH 136/307] FIX phan and phpstan errors on 21.0 static analysis (#39152) Two errors were surfaced on the 21.0 branch by the automated pullmerge from the 18.0 to 20.0 branches, and they make the phan and phpstan CI jobs fail on every open pull request: - htdocs/takepos/invoice.php: dol_htmloutput_errors() second argument must be an array, not null (phpstan). - htdocs/societe/class/societe.class.php: the string parameter $remise must be cast to float before the multiplication, like the surrounding lines already do (phan PhanTypeInvalidLeftOperandOfNumericOp). Signed-off-by: Gregory Aliot Co-authored-by: Gregory Aliot --- htdocs/societe/class/societe.class.php | 4 ++-- htdocs/takepos/invoice.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/societe/class/societe.class.php b/htdocs/societe/class/societe.class.php index a4f1c4fe5cc..b52e95c8657 100644 --- a/htdocs/societe/class/societe.class.php +++ b/htdocs/societe/class/societe.class.php @@ -2613,7 +2613,7 @@ class Societe extends CommonObject $discount->amount_ht = price2num((float) $remise / (1 + (float) $vatrate / 100), 'MT'); $discount->amount_tva = price2num((float) $discount->amount_ttc - (float) $discount->amount_ht, 'MT'); - $discount->multicurrency_amount_ttc = price2num($remise * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_amount_ttc = price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_ht = price2num(((float) $remise / (1 + (float) $vatrate / 100)) * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_tva = price2num(((float) $discount->amount_ttc - (float) $discount->amount_ht) * (float) $discount->multicurrency_tx, 'MT'); } else { @@ -2621,7 +2621,7 @@ class Societe extends CommonObject $discount->amount_tva = price2num((float) $remise * (float) $vatrate / 100, 'MT'); $discount->amount_ttc = price2num((float) $discount->amount_ht + (float) $discount->amount_tva, 'MT'); - $discount->multicurrency_amount_ht = price2num($remise * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_amount_ht = price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_tva = price2num(((float) $remise * (float) $vatrate / 100) * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_ttc = price2num(((float) $discount->amount_ht + (float) $discount->amount_tva) * (float) $discount->multicurrency_tx, 'MT'); } diff --git a/htdocs/takepos/invoice.php b/htdocs/takepos/invoice.php index a6c56f052bc..6770a3d254b 100644 --- a/htdocs/takepos/invoice.php +++ b/htdocs/takepos/invoice.php @@ -211,7 +211,7 @@ if ($reshook < 0) { // (delete, quantity, price, discount). Adding a line, a free zone or a note is gated by // the "run" permission elsewhere and must stay available to a plain cashier (#38949). if (in_array($action, array('deleteline', 'updateqty', 'updateprice', 'updatereduction', 'update_reduction_global')) && !$user->hasRight('takepos', 'editlines')) { - dol_htmloutput_errors($langs->trans("NotEnoughPermissions", "TakePos"), null, 1); + dol_htmloutput_errors($langs->trans("NotEnoughPermissions", "TakePos"), array(), 1); $action = ''; } From 7c43affbdb8ec321ee0b417a32ef2c8d3dd19736 Mon Sep 17 00:00:00 2001 From: MDW Date: Tue, 14 Jul 2026 00:43:23 +0200 Subject: [PATCH 137/307] fix: Remove extra closing parenthesis in user note permission check (#39150) Removed an extra closing parenthesis in the user note permission check to fix a syntax error. Also added a new copyright line for 2026 and updated the parameters array formatting for consistency. --- htdocs/user/note.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/htdocs/user/note.php b/htdocs/user/note.php index ca623862a77..6985fe3724b 100644 --- a/htdocs/user/note.php +++ b/htdocs/user/note.php @@ -3,6 +3,7 @@ * Copyright (C) 2004-2015 Laurent Destailleur * Copyright (C) 2005-2015 Regis Houssin * Copyright (C) 2024-2026 Frédéric France + * Copyright (C) 2026 MDW * * 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 @@ -62,7 +63,7 @@ if (($object->id != $user->id) && (!$user->hasRight("user", "user", "read"))) { if ($object->id == $user->id) { $permissionnote = $user->hasRight("user", "self", "write"); // Used by the include of actions_setnotes.inc.php } else { - $permissionnote = $user->hasRight("user", "user", "write")); // Used by the include of actions_setnotes.inc.php + $permissionnote = $user->hasRight("user", "user", "write"); // Used by the include of actions_setnotes.inc.php } // Security check @@ -81,7 +82,7 @@ $result = restrictedArea($user, 'user', $id, 'user&user', $feature2); /* * Actions */ -$parameters = array('id'=>$socid); +$parameters = array('id' => $socid); $reshook = $hookmanager->executeHooks('doActions', $parameters, $object, $action); // Note that $action and $object may have been modified by some hooks if ($reshook < 0) { setEventMessages($hookmanager->error, $hookmanager->errors, 'errors'); From 8f85bf6b0ddaf3714a47b45e243f5f5e19fb03b2 Mon Sep 17 00:00:00 2001 From: GregcodeOhm Date: Tue, 14 Jul 2026 00:42:54 +0200 Subject: [PATCH 138/307] FIX phan and phpstan errors on 21.0 static analysis (#39152) Two errors were surfaced on the 21.0 branch by the automated pullmerge from the 18.0 to 20.0 branches, and they make the phan and phpstan CI jobs fail on every open pull request: - htdocs/takepos/invoice.php: dol_htmloutput_errors() second argument must be an array, not null (phpstan). - htdocs/societe/class/societe.class.php: the string parameter $remise must be cast to float before the multiplication, like the surrounding lines already do (phan PhanTypeInvalidLeftOperandOfNumericOp). Signed-off-by: Gregory Aliot Co-authored-by: Gregory Aliot --- htdocs/societe/class/societe.class.php | 4 ++-- htdocs/takepos/invoice.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/societe/class/societe.class.php b/htdocs/societe/class/societe.class.php index 293e5e5d623..a3b1da6bbeb 100644 --- a/htdocs/societe/class/societe.class.php +++ b/htdocs/societe/class/societe.class.php @@ -2514,7 +2514,7 @@ class Societe extends CommonObject $discount->amount_ht = price2num((float) $remise / (1 + (float) $vatrate / 100), 'MT'); $discount->amount_tva = price2num((float) $discount->amount_ttc - (float) $discount->amount_ht, 'MT'); - $discount->multicurrency_amount_ttc = price2num($remise * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_amount_ttc = price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_ht = price2num(((float) $remise / (1 + (float) $vatrate / 100)) * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_tva = price2num(((float) $discount->amount_ttc - (float) $discount->amount_ht) * (float) $discount->multicurrency_tx, 'MT'); } else { @@ -2522,7 +2522,7 @@ class Societe extends CommonObject $discount->amount_tva = price2num((float) $remise * (float) $vatrate / 100, 'MT'); $discount->amount_ttc = price2num((float) $discount->amount_ht + (float) $discount->amount_tva, 'MT'); - $discount->multicurrency_amount_ht = price2num($remise * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_amount_ht = price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_tva = price2num(((float) $remise * (float) $vatrate / 100) * (float) $discount->multicurrency_tx, 'MT'); $discount->multicurrency_amount_ttc = price2num(((float) $discount->amount_ht + (float) $discount->amount_tva) * (float) $discount->multicurrency_tx, 'MT'); } diff --git a/htdocs/takepos/invoice.php b/htdocs/takepos/invoice.php index 17b7e466126..5c241de7cd8 100644 --- a/htdocs/takepos/invoice.php +++ b/htdocs/takepos/invoice.php @@ -202,7 +202,7 @@ if ($reshook < 0) { // (delete, quantity, price, discount). Adding a line, a free zone or a note is gated by // the "run" permission elsewhere and must stay available to a plain cashier (#38949). if (in_array($action, array('deleteline', 'updateqty', 'updateprice', 'updatereduction', 'update_reduction_global')) && !$user->hasRight('takepos', 'editlines')) { - dol_htmloutput_errors($langs->trans("NotEnoughPermissions", "TakePos"), null, 1); + dol_htmloutput_errors($langs->trans("NotEnoughPermissions", "TakePos"), array(), 1); $action = ''; } From ff2adb5b84294525eac7d610841cbe00e6f4c54d Mon Sep 17 00:00:00 2001 From: MDW Date: Tue, 14 Jul 2026 13:49:56 +0200 Subject: [PATCH 139/307] Qual: Update Phan baseline.txt (#39170) --- dev/tools/phan/baseline.txt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/dev/tools/phan/baseline.txt b/dev/tools/phan/baseline.txt index 3eddf4abafb..374872ef75c 100644 --- a/dev/tools/phan/baseline.txt +++ b/dev/tools/phan/baseline.txt @@ -11,7 +11,7 @@ return [ // # Issue statistics: // PhanUndeclaredProperty : 370+ occurrences // PhanTypeMismatchArgument : 25+ occurrences - // PhanUndeclaredGlobalVariable : 25+ occurrences + // PhanUndeclaredGlobalVariable : 20+ occurrences // PhanTypeMismatchProperty : 15+ occurrences // PhanTypeInvalidDimOffset : 10+ occurrences // PhanTypeMismatchDimFetch : 10+ occurrences @@ -103,7 +103,6 @@ return [ 'htdocs/core/tpl/objectline_view.tpl.php' => ['PhanUndeclaredProperty'], 'htdocs/core/tpl/passwordreset.tpl.php' => ['PhanUndeclaredGlobalVariable'], 'htdocs/core/tpl/resource_view.tpl.php' => ['PhanUndeclaredProperty'], - 'htdocs/core/triggers/interface_99_modAgenda_ActionsAuto.class.php' => ['PhanUndeclaredProperty'], 'htdocs/delivery/class/delivery.class.php' => ['PhanUndeclaredProperty'], 'htdocs/emailcollector/class/emailcollector.class.php' => ['PhanUndeclaredProperty'], 'htdocs/eventorganization/class/conferenceorboothattendee.class.php' => ['PhanUndeclaredMethod', 'PhanUndeclaredProperty'], @@ -139,7 +138,6 @@ return [ 'htdocs/loan/payment/payment.php' => ['PhanUndeclaredProperty'], 'htdocs/mrp/tpl/linkedobjectblock.tpl.php' => ['PhanUndeclaredProperty'], 'htdocs/multicurrency/class/multicurrency.class.php' => ['PhanTypeExpectedObjectPropAccess'], - 'htdocs/opensurvey/results.php' => ['PhanUndeclaredGlobalVariable'], 'htdocs/partnership/class/partnership.class.php' => ['PhanUndeclaredProperty'], 'htdocs/partnership/core/modules/partnership/mod_partnership_advanced.php' => ['PhanUndeclaredProperty'], 'htdocs/partnership/partnership_card.php' => ['PhanUndeclaredGlobalVariable'], @@ -161,7 +159,6 @@ return [ 'htdocs/product/stock/stocktransfer/class/stocktransferline.class.php' => ['PhanUndeclaredProperty'], 'htdocs/product/stock/tpl/stockcorrection.tpl.php' => ['PhanUndeclaredProperty'], 'htdocs/projet/admin/project.php' => ['PhanTypeMismatchArgumentProbablyReal'], - 'htdocs/projet/card.php' => ['PhanUndeclaredGlobalVariable'], 'htdocs/projet/tasks.php' => ['PhanTypeMismatchArgument'], 'htdocs/projet/tasks/time.php' => ['PhanTypeInvalidDimOffset', 'PhanUndeclaredProperty'], 'htdocs/projet/tasks/tpl/linkedobjectblock.tpl.php' => ['PhanUndeclaredProperty'], @@ -171,7 +168,7 @@ return [ 'htdocs/public/project/viewandvote.php' => ['PhanUndeclaredGlobalVariable'], 'htdocs/public/recruitment/view.php' => ['PhanUndeclaredGlobalVariable'], 'htdocs/public/webportal/tpl/menu.tpl.php' => ['PhanUndeclaredProperty'], - 'htdocs/reception/card.php' => ['PhanUndeclaredGlobalVariable', 'PhanUndeclaredProperty'], + 'htdocs/reception/card.php' => ['PhanUndeclaredProperty'], 'htdocs/reception/class/reception.class.php' => ['PhanUndeclaredProperty'], 'htdocs/reception/list.php' => ['PhanUndeclaredProperty'], 'htdocs/recruitment/class/recruitmentcandidature.class.php' => ['PhanUndeclaredProperty'], From 9ecec7b2486300e4aa63172ed870b838be72e17c Mon Sep 17 00:00:00 2001 From: MDW Date: Tue, 14 Jul 2026 13:50:39 +0200 Subject: [PATCH 140/307] Qual: Update logToCheckStyle (now extracts PHP Lint error msg) (#39169) # Qual: Update logToCheckStyle (now extracts PHP Lint error msg) logToCheckStyle was updated to extract PHP Lint (php -l) errors. So they will show in the summary report and as annotations. --- .github/workflows/pre-commit.yml | 2 +- .github/workflows/windows-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 8098dc970c9..3ab7757a732 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -157,7 +157,7 @@ jobs: ls -l ~/.cache/pre-commit/ - name: Convert Raw Log to Annotations - uses: mdeweerd/logToCheckStyle@v2025.11.2 + uses: mdeweerd/logToCheckStyle@v2026.7.1 if: ${{ failure() }} with: in: ${{ env.RAW_LOG }} diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 5215886e9ab..a1a42ceecaa 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -192,7 +192,7 @@ jobs: for /f "tokens=2 delims==" %%A in ('doskey /m:err') do EXIT /B %%A - name: Convert Raw Log to Annotations - uses: mdeweerd/logToCheckStyle@v2025.11.2 + uses: mdeweerd/logToCheckStyle@v2026.7.1 if: ${{ failure() }} with: in: ${{ env.PHPUNIT_LOG }} From c8ba7f4696b049bd220969a1c1c1744b2a323a16 Mon Sep 17 00:00:00 2001 From: Zakaria Boushaba <48571684+Boushabazakaria@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:51:21 +0100 Subject: [PATCH 141/307] FIX #37775 Improve extra fields merge when creating invoice from template (#39173) * FIX Invoice creation from template ignores extra fields set in form * Fix Strict comparison using === between mixed and null will always evaluate to false --------- Co-authored-by: Zakaria Boushaba --- htdocs/compta/facture/class/facture.class.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/htdocs/compta/facture/class/facture.class.php b/htdocs/compta/facture/class/facture.class.php index c1d0e0f80be..5f75f6c28bf 100644 --- a/htdocs/compta/facture/class/facture.class.php +++ b/htdocs/compta/facture/class/facture.class.php @@ -619,12 +619,11 @@ class Facture extends CommonInvoice $this->note_private = trim($this->note_private); $this->note_private = dol_concatdesc($this->note_private, $langs->trans("GeneratedFromRecurringInvoice", $_facrec->ref)); - // Keep POSTed extrafields if the caller already populated array_options - // (htdocs/compta/facture/card.php:1235 calls setOptionalsFromPost before - // create()). Falling back to the template values when nothing was POSTed - // preserves cron behavior for auto-generated recurring invoices (#37775). - if (empty($this->array_options)) { - $this->array_options = $_facrec->array_options; + // Use template extra fields as fallback only - form values (already set) take precedence + foreach ($_facrec->array_options as $key => $val) { + if (!isset($this->array_options[$key]) || $this->array_options[$key] === '') { + $this->array_options[$key] = $val; + } } if (!$this->mode_reglement_id) { From eb998bbc676572eb744809199c73a521ed928444 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Tue, 14 Jul 2026 23:57:05 +0200 Subject: [PATCH 142/307] Update agents setup files --- AGENTS.md => .claude/AGENTS.md | 0 .claude/skills/add-unit-test/SKILL.md | 86 +++++++++++++++++++++++++++ .claude/skills/code-review/SKILL.md | 40 +++++++++++++ 3 files changed, 126 insertions(+) rename AGENTS.md => .claude/AGENTS.md (100%) create mode 100644 .claude/skills/add-unit-test/SKILL.md create mode 100644 .claude/skills/code-review/SKILL.md diff --git a/AGENTS.md b/.claude/AGENTS.md similarity index 100% rename from AGENTS.md rename to .claude/AGENTS.md diff --git a/.claude/skills/add-unit-test/SKILL.md b/.claude/skills/add-unit-test/SKILL.md new file mode 100644 index 00000000000..8aa5a965199 --- /dev/null +++ b/.claude/skills/add-unit-test/SKILL.md @@ -0,0 +1,86 @@ +--- +name: add-unit-test +description: Add a unit test to test functions or methods. +license: MIT +user-invocable: true +allowed-tools: + - read_file + - write_file + - grep +--- + + +# Skill: Add a Unit Test for Dolibarr + +## When to use this skill + +Use this skill whenever the user asks to create, modify, or complete a **PHP unit test** for the Dolibarr ERP/CRM project. + +The goal is to produce a unit test that follows Dolibarr conventions and integrates cleanly into the existing PHPUnit test suite. + +## General Rules + +- Follow the coding style already used in files into directory test/phpunit/ +- Modify the minimum amount of existing code. +- Prefer adding new test methods instead of modifying existing ones. +- Tests must be deterministic and independent. +- Avoid dependencies on external services. +- Clean up every object created during the test. + +## Test Location + +Locate the most appropriate existing PHPUnit test file in test/phpunit. + +If no suitable test exists, create one in: + +``` +test/phpunit/ +``` + +using the naming convention: + +``` +FeatureTest.php +``` + + +## Naming + +A test method should describe the expected behavior. + +Examples: + +```php +public function testCreateObject() +public function testDeleteObject() +public function testFetchReturnsExpectedValues() +public function testInvalidInputThrowsException() +``` + +## Assertions + +Prefer specific assertions. + +Good: + +```php +$this->assertTrue($result); +$this->assertFalse($result); +$this->assertEquals($expected, $actual); +$this->assertCount(3, $array); +$this->assertNull($value); +$this->assertNotNull($value); +``` + +Avoid generic assertions when a more precise one exists. + + +## Output + +When generating code: + +- provide only the relevant PHP code +- preserve the existing file formatting +- do not rewrite unrelated methods +- explain briefly what is being tested + diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md new file mode 100644 index 00000000000..19b9b5624eb --- /dev/null +++ b/.claude/skills/code-review/SKILL.md @@ -0,0 +1,40 @@ +--- +name: code-review +description: Reivew code and fix bad practices +license: MIT +user-invocable: true +allowed-tools: + - read_file + - write_file + - grep +--- + + +# Skill: Review code for Dolibarr practices and fix it + +## When to use this skill + +Use this skill whenever the user asks to review code to fix bad practices or to update code to match good practices. + + +## General Rules + +- Follow the coding style already used in files in module builder template in htdocs/modulebuilder/templates +- Modify the minimum amount of existing code. + + +## Rules + +- Use PSR-12 coding style except tabulation that must use TAB characters and not SPACES. +- Remove all spaces at end of lines + + +## Output + +When generating code: + +- provide only the relevant PHP code +- preserve the existing file formatting +- do not rewrite unrelated methods +- explain briefly what is being tested + From 9d5c3a33b93ea3e5c62877edf7dc4b6bcdd82f3d Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 00:03:17 +0200 Subject: [PATCH 143/307] Doc --- .claude/skills/code-review/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 19b9b5624eb..feab0199a11 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -27,6 +27,7 @@ Use this skill whenever the user asks to review code to fix bad practices or to - Use PSR-12 coding style except tabulation that must use TAB characters and not SPACES. - Remove all spaces at end of lines +- Rewrite all code that is not in english ## Output From 45c13ab4ef5003da9b8cbd67f89ab9bff1c2aa18 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 00:06:18 +0200 Subject: [PATCH 144/307] Doc --- .claude/skills/code-review/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index feab0199a11..b1c82d3bbec 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: code-review -description: Reivew code and fix bad practices +description: Review code and fix bad practices license: MIT user-invocable: true allowed-tools: From d8a61e52008be561681344cf68146122daecedf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Charl=C3=A8ne=20Benke?= <1179011+defrance@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:36:11 +0200 Subject: [PATCH 145/307] Add 'notnull' attribute to fk_statut field (#39179) generate warning error on list --- htdocs/webportal/class/webportalficheinter.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/webportal/class/webportalficheinter.class.php b/htdocs/webportal/class/webportalficheinter.class.php index 2b8ebc64b05..5dbcecab0e8 100644 --- a/htdocs/webportal/class/webportalficheinter.class.php +++ b/htdocs/webportal/class/webportalficheinter.class.php @@ -65,7 +65,7 @@ class WebPortalFicheinter extends Fichinter 'datee' => array('type' => 'date', 'label' => 'WebPortalInterDateEnd', 'enabled' => 1, 'visible' => 2, 'position' => 62), 'datet' => array('type' => 'date', 'label' => 'WebPortalInterDateClose', 'enabled' => 1, 'visible' => 2, 'position' => 63), 'description' => array('type' => 'text', 'label' => 'Description', 'enabled' => 1, 'visible' => 2, 'position' => 80), - 'fk_statut' => array('type' => 'smallint(6)', 'label' => 'Status', 'enabled' => 1, 'visible' => 2, 'position' => 500, 'arrayofkeyval' => self::ARRAY_STATUS_LABEL), + 'fk_statut' => array('type' => 'smallint(6)', 'label' => 'Status', 'enabled' => 1, 'visible' => 2, 'notnull' => 1, 'position' => 500, 'arrayofkeyval' => self::ARRAY_STATUS_LABEL), ); /** From 10492c5c81f6e50a9dacd6abe0c2c53abdf3b64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Charl=C3=A8ne=20Benke?= <1179011+defrance@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:36:38 +0200 Subject: [PATCH 146/307] Add 'ficheinter' to module security tests (#39180) --- htdocs/webportal/controllers/document.controller.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/webportal/controllers/document.controller.class.php b/htdocs/webportal/controllers/document.controller.class.php index 6e651e595c4..8f7f23fa83a 100644 --- a/htdocs/webportal/controllers/document.controller.class.php +++ b/htdocs/webportal/controllers/document.controller.class.php @@ -194,7 +194,7 @@ class DocumentController extends Controller ) { if (isModEnabled($moduleName) && isset($conf->{$moduleName}->multidir_output[$entity])) { // List of module supported in security tests (others are forbidden if not security test to check that document is owned by company is done) - if (in_array($moduleName, array('facture', 'invoice', 'commande', 'order', 'propal', 'ticket'))) { + if (in_array($moduleName, array('facture', 'invoice', 'commande', 'order', 'propal', 'ticket', 'ficheinter'))) { $sql = "SELECT rowid, src_object_id, src_object_type FROM ".MAIN_DB_PREFIX.'ecm_files'; $sql .= " WHERE filename = '".$this->db->escape(basename($original_file))."'"; $sql .= " AND filepath = '".$this->db->escape(basename($tmparray['dir_output']).'/'.dirname($original_file))."'"; From a366cee3920d2f0eb1b44d98beb5a9ebb4898464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Charl=C3=A8ne=20Benke?= <1179011+defrance@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:37:05 +0200 Subject: [PATCH 147/307] create categorie_fichinter-fichinter not run (#39177) --- htdocs/core/modules/modFicheinter.class.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/htdocs/core/modules/modFicheinter.class.php b/htdocs/core/modules/modFicheinter.class.php index f8cd2a3f030..401c990e017 100644 --- a/htdocs/core/modules/modFicheinter.class.php +++ b/htdocs/core/modules/modFicheinter.class.php @@ -4,7 +4,8 @@ * Copyright (C) 2004 Sebastien Di Cintio * Copyright (C) 2004 Benoit Mortier * Copyright (C) 2005-2012 Regis Houssin - * Copyright (C) 2026 Frédéric France + * Copyright (C) 2026 Frédéric France + * Copyright (C) 2026 Charlene Benke * * 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 @@ -236,6 +237,11 @@ class modFicheinter extends DolibarrModules { global $conf; + $result = $this->_load_tables('/install/mysql/', 'fichinter'); + if ($result < 0) { + return -1; // Do not activate module if error 'not allowed' returned when loading module SQL queries (the _load_table run sql with run_sql with the error allowed parameter set to 'default') + } + // Permissions $this->remove($options); From 03319e88c97a93e83818f11aa4e889513e902fc3 Mon Sep 17 00:00:00 2001 From: MDW Date: Wed, 15 Jul 2026 00:38:28 +0200 Subject: [PATCH 148/307] Qual: Improve some CodingPhpTest notices with line numbers (#39176) * Qual: Improve a notice with line numbers * Qual: Fix PHPStan ci when no files are to be analyzed --- .github/workflows/phpstan.yml | 3 ++- test/phpunit/CodingPhpTest.php | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 6b788114546..b597b678c68 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -21,6 +21,7 @@ env: gh_event: ${{ inputs.gh_event || github.event_name }} CACHE_KEY_PART: ${{ ( inputs.gh_event == 'pull_request' || github.event_name == 'pull_request' ) && format('{0}-{1}', github.base_ref, github.head_ref) || github.ref_name }} GITHUB_JSON: ${{ toJSON(github) }} # Helps in debugging Github Action + DUMMY_FILE: htdocs/theme/md/progress.inc.php # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job @@ -113,7 +114,7 @@ jobs: fi echo phpstan analyse --no-progress --error-format=checkstyle --memory-limit 7G -a dev/build/phpstan/bootstrap_action.php ${ALL_CHANGED_FILES} - phpstan analyse --no-progress --error-format=checkstyle --memory-limit 7G -a dev/build/phpstan/bootstrap_action.php ${ALL_CHANGED_FILES} | tee _stan.xml | cs2pr --graceful-warnings + phpstan analyse --no-progress --error-format=checkstyle --memory-limit 7G -a dev/build/phpstan/bootstrap_action.php "${{ env.DUMMY_FILE }}" ${ALL_CHANGED_FILES} | tee _stan.xml | cs2pr --graceful-warnings fi # continue-on-error: true diff --git a/test/phpunit/CodingPhpTest.php b/test/phpunit/CodingPhpTest.php index 75ee03393bf..65bec3fd9b5 100644 --- a/test/phpunit/CodingPhpTest.php +++ b/test/phpunit/CodingPhpTest.php @@ -1,7 +1,7 @@ * Copyright (C) 2023 Alexandre Janniaux - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 Frédéric France * * This program is free software; you can redistribute it and/or modify @@ -112,7 +112,8 @@ class CodingPhpTest extends CommonClassTest )); */ $returnlist = array_map(function ($value) { - return array($value); }, $filesarray); + return array($value); + }, $filesarray); // To process only 1 file, uncomment this /* @@ -386,29 +387,31 @@ class CodingPhpTest extends CommonClassTest // Check that forged sql string is using ' instead of " as string PHP quotes $ok = true; $matches = array(); - preg_match_all('/\$sql \.= \'\s*VALUES.*\$/i', $filecontent, $matches, PREG_SET_ORDER); + $lines = array(); + preg_match_all('/(\$sql \.= \'\s*VALUES)(.*)\$/i', $filecontent, $matches, PREG_SET_ORDER); foreach ($matches as $key => $val) { //if ($val[1] != '\'"' && $val[1] != '\'\'') { var_dump($matches); $ok = false; - break; + $lines[] = self::reportAndGetLine($val[0].$val[2], $filecontent, $report_filepath, "Use \" not ' to forge SQL query (\$sql = \"SELECT \".\$somevar...) in `{$val[1]}{$val[2]}...`)"); //} //if ($reg[0] != 'db') $ok=false; } //print __METHOD__." Result for checking we don't have non escaped string in sql requests for file ".$file."\n"; - $this->assertTrue($ok, 'Found a forged SQL string that mix on same line the use of \' for PHP string and PHP variables in file '.$file['relativename'].' Use " to forge PHP string like this: $sql = "SELECT ".$myvar...'); + $this->assertTrue($ok, 'Found a forged SQL string that mix on same line the use of \' for PHP string and PHP variables in file '.$file['relativename'].' Use " to forge PHP string like this: $sql = "SELECT ".$myvar... Lines:'.implode(',', $lines)); //exit; // Check that forged sql string is using ' instead of " as string PHP quotes $ok = true; $matches = array(); - preg_match_all('/\$sql \.?= \'SELECT.*\$/', $filecontent, $matches, PREG_SET_ORDER); + $lines = array(); + preg_match_all('/(\$sql \.?= \'SELECT)(.*)\$/', $filecontent, $matches, PREG_SET_ORDER); foreach ($matches as $key => $val) { var_dump($matches); $ok = false; - break; + $lines[] = self::reportAndGetLine($val[0].$val[2], $filecontent, $report_filepath, "Use \" not ' to forge SQL query (\$sql = \"SELECT \".\$somevar...) in `{$val[1]}{$val[2]}...`)"); } - $this->assertTrue($ok, 'Found a forged SQL string that mix on same line the use of \' for PHP string and PHP variables in file '.$file['relativename'].' Use " to forge PHP string like this: $sql = "SELECT ".$myvar...'); + $this->assertTrue($ok, 'Found a forged SQL string that mix on same line the use of \' for PHP string and PHP variables in file '.$file['relativename'].' Use " to forge PHP string like this: $sql = "SELECT ".$myvar...'.implode(',', $lines)); // Check bad casting on forge sql // with $sql = "..." . $... From e8227696b491c1078867839eca4922bddf9b2869 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:39:16 +0200 Subject: [PATCH 149/307] PHPStan > Update baseline (#39174) Co-authored-by: Dolibot --- dev/build/phpstan/phpstan-baseline.neon | 66 ------------------------- 1 file changed, 66 deletions(-) diff --git a/dev/build/phpstan/phpstan-baseline.neon b/dev/build/phpstan/phpstan-baseline.neon index 0968e6da4ca..d4173b6888f 100644 --- a/dev/build/phpstan/phpstan-baseline.neon +++ b/dev/build/phpstan/phpstan-baseline.neon @@ -5640,12 +5640,6 @@ parameters: count: 1 path: ../../../htdocs/loan/class/loanschedule.class.php - - - message: '#^Variable \$object might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/loan/payment/card.php - - message: '#^Negated boolean expression is always true\.$#' identifier: booleanNot.alwaysTrue @@ -6222,12 +6216,6 @@ parameters: count: 4 path: ../../../htdocs/multicurrency/multicurrency_rate.php - - - message: '#^Variable \$id might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/opensurvey/card.php - - message: '#^Left side of && is always true\.$#' identifier: booleanAnd.leftAlwaysTrue @@ -6252,12 +6240,6 @@ parameters: count: 1 path: ../../../htdocs/opensurvey/results.php - - - message: '#^Variable \$expiredate might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/opensurvey/results.php - - message: '#^Left side of && is always false\.$#' identifier: booleanAnd.leftAlwaysFalse @@ -6798,12 +6780,6 @@ parameters: count: 1 path: ../../../htdocs/product/stock/stocktransfer/stocktransfer_card.php - - - message: '#^Variable \$result might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/product/stock/stocktransfer/stocktransfer_contact.php - - message: '#^Right side of && is always true\.$#' identifier: booleanAnd.rightAlwaysTrue @@ -6894,12 +6870,6 @@ parameters: count: 1 path: ../../../htdocs/projet/card.php - - - message: '#^Variable \$morecss might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/projet/card.php - - message: '#^Call to function method_exists\(\) with \$this\(Project\) and ''getLibStatut'' will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -7476,18 +7446,6 @@ parameters: count: 1 path: ../../../htdocs/public/onlinesign/newonlinesign.php - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: ../../../htdocs/public/onlinesign/newonlinesign.php - - - - message: '#^Variable \$mesg might not be defined\.$#' - identifier: variable.undefined - count: 2 - path: ../../../htdocs/public/onlinesign/newonlinesign.php - - message: '#^Left side of && is always true\.$#' identifier: booleanAnd.leftAlwaysTrue @@ -7944,12 +7902,6 @@ parameters: count: 1 path: ../../../htdocs/reception/card.php - - - message: '#^Variable \$reception might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/reception/card.php - - message: '#^Call to function method_exists\(\) with \$this\(Reception\) and ''getLibStatut'' will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -8238,24 +8190,6 @@ parameters: count: 1 path: ../../../htdocs/societe/canvas/company/tpl/card_view.tpl.php - - - message: '#^Variable \$objcanvas might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/societe/canvas/company/tpl/card_view.tpl.php - - - - message: '#^Variable \$objcanvas might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: ../../../htdocs/societe/canvas/individual/tpl/card_view.tpl.php - - - - message: '#^Variable \$socid might not be defined\.$#' - identifier: variable.undefined - count: 3 - path: ../../../htdocs/societe/canvas/individual/tpl/card_view.tpl.php - - message: '#^Call to function is_numeric\(\) with 0\|1\|2\|3\|4\|5\|6\|7\|8\|9 will always evaluate to true\.$#' identifier: function.alreadyNarrowedType From 5c6e4f486d32df9f2a4092743649d59b199f6dec Mon Sep 17 00:00:00 2001 From: TowyTowy <85077986+TowyTowy@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:40:03 +0200 Subject: [PATCH 150/307] FIX dol_string_nohtmltag() skips double-space collapsing when string starts with a double space (#39175) The double-space removal loop guarded its condition with the truthiness of strpos(): while (strpos($temp, " ")) { strpos() returns int 0 when the first double space is at position 0 of the string, and 0 is falsy, so the loop body never runs. As a result NO double spaces are collapsed at all in that string, including interior ones. This happens for any input that begins with two spaces, e.g. text whose leading "

" is turned into two leading spaces by the removelinefeed step just above. The trailing trim() hides the leading spaces but the interior double spaces survive, producing wrong output (e.g. "Total amount due" instead of "Total amount due"). Fix by comparing against false, the standard PHP idiom used elsewhere in this codebase. Strings that do not start with a double space were already handled correctly, so behaviour is unchanged for them. Also corrected the neighbouring comment ("double quotes" -> "double spaces"). Signed-off-by: TowyTowy Co-authored-by: Claude --- htdocs/core/lib/functions.lib.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/core/lib/functions.lib.php b/htdocs/core/lib/functions.lib.php index 014a238a350..72fdb6dfb5d 100644 --- a/htdocs/core/lib/functions.lib.php +++ b/htdocs/core/lib/functions.lib.php @@ -8248,9 +8248,9 @@ function dol_string_nohtmltag($stringtoclean, $removelinefeed = 1, $pagecodeto = $temp = str_replace(array("\r\n", "\r", "\n"), " ", $temp); } - // And double quotes + // And double spaces if ($removedoublespaces) { - while (strpos($temp, " ")) { + while (strpos($temp, " ") !== false) { $temp = str_replace(" ", " ", $temp); } } From fe1386fa2d26ccf28f6fcf36a15db65f33cdd2cb Mon Sep 17 00:00:00 2001 From: w3houde Date: Wed, 15 Jul 2026 05:41:18 +0700 Subject: [PATCH 151/307] Fix #38860 build valid ldap:// URI for scheme-less host on PHP 8.3+ (#39167) Since PHP 8.3 ldap_connect() takes a single URI argument. connectBind() concatenated host and port as "host:port", which is a malformed URI for a scheme-less host (localhost, 192.168.0.2) since the host is then parsed as the URI scheme, so the following bind returns false. It also appended ":port" to an already qualified ldaps:// URI. Prefix scheme-less hosts with ldap:// and leave ldap(s):// URIs untouched. Signed-off-by: Dolicraft --- htdocs/core/class/ldap.class.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/htdocs/core/class/ldap.class.php b/htdocs/core/class/ldap.class.php index 9b76c34da1f..295554b2d93 100644 --- a/htdocs/core/class/ldap.class.php +++ b/htdocs/core/class/ldap.class.php @@ -358,7 +358,10 @@ class Ldap dol_syslog(get_class($this)."::connectBind serverPing true, we try ldap_connect to ".$host, LOG_DEBUG); } if (version_compare(PHP_VERSION, '8.3.0', '>=')) { - $uri = $host.':'.$this->serverPort; + // Since PHP 8.3, ldap_connect() expects a single URI argument. A scheme-less + // host (ex: localhost, 192.168.0.2) must be turned into a valid ldap:// URI, + // otherwise the host is parsed as the URI scheme and the later bind fails. + $uri = preg_match('/^ldaps?:\/\//i', $host) ? $host : 'ldap://'.$host.':'.$this->serverPort; $this->connection = ldap_connect($uri); } else { $this->connection = ldap_connect($host, $this->serverPort); @@ -371,7 +374,7 @@ class Ldap dol_syslog(get_class($this)."::connectBind serverPing false, we try ldap_connect to ".$host, LOG_DEBUG); } if (version_compare(PHP_VERSION, '8.3.0', '>=')) { - $uri = $host.':'.$this->serverPort; + $uri = preg_match('/^ldaps?:\/\//i', $host) ? $host : 'ldap://'.$host.':'.$this->serverPort; $this->connection = ldap_connect($uri); } else { $this->connection = ldap_connect($host, $this->serverPort); From 61899bb8cd9b0482ef24d0e61087f810ae2ce59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Aliot?= Date: Wed, 15 Jul 2026 00:44:37 +0200 Subject: [PATCH 152/307] FIX #39113 CSV export: decode HTML entities in header labels (#39151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extrafield labels are stored HTML encoded in llx_extrafields.label because they are saved through Translate::trans(), which runs the string through htmlentities(). write_title() wrote the label as-is, so the CSV header line showed raw entities (Données instead of Données) while the data cells were fine. With a ; separator the trailing ; of each entity also wrapped the label in double quotes. Decode entities and remove HTML with dol_string_nohtmltag() before csvClean(), consistent with the TSV and Excel export drivers. For the derived _label column of Select extrafields, append the suffix before csvClean() so the column stays valid CSV when the decoded label contains the separator or a quote. Signed-off-by: Gregory Aliot Co-authored-by: Gregory Aliot --- htdocs/core/modules/export/exportcsv.class.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/htdocs/core/modules/export/exportcsv.class.php b/htdocs/core/modules/export/exportcsv.class.php index 1e263a67284..b9e6c567c6a 100644 --- a/htdocs/core/modules/export/exportcsv.class.php +++ b/htdocs/core/modules/export/exportcsv.class.php @@ -210,6 +210,11 @@ class ExportCsv extends ModeleExports $newvalue = $array_export_fields_label[$code]; + // Label may be stored HTML encoded (for example extrafield labels saved through Translate::trans()), + // so decode entities and remove HTML to keep the header consistent with the data cells. + $newvalue = dol_string_nohtmltag($newvalue); + $decodedlabel = $newvalue; + // Clean data and add encloser if required (depending on value of USE_STRICT_CSV_RULES) include_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; $newvalue = csvClean($newvalue, $outputlangs->charset_output, $this->separator); @@ -218,7 +223,9 @@ class ExportCsv extends ModeleExports $typefield = isset($array_types[$code]) ? $array_types[$code] : ''; if (preg_match('/^Select:/i', $typefield) && $typefield = substr($typefield, 7)) { - $selectlabel[$code."_label"] = $newvalue."_label"; + // Append the "_label" suffix before cleaning so the derived column stays valid CSV + // even when the decoded label contains the separator or a quote. + $selectlabel[$code."_label"] = csvClean($decodedlabel."_label", $outputlangs->charset_output, $this->separator); } } From ef6631e9bd5ec4b8cec0e88f1796d3d10dad02ec Mon Sep 17 00:00:00 2001 From: w3houde Date: Wed, 15 Jul 2026 05:52:28 +0700 Subject: [PATCH 153/307] Fix #38963 enforce website write right on legacy filemanager (#39160) The legacy FCKeditor file manager connector (config.inc.php, also included by browser/default/browser.php and connectors/php/connector.php) only called main.inc.php and never checked any module right on 21.0 and 22.0, so any authenticated user, even one with no permission, could reach the connector, browse the medias directory and upload files. The permission check already present on 23.0 and develop was never backported. This adds the same guard so a non-admin without website write right is refused. Reported by Abderrahmane Aksoum. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/core/filemanagerdol/connectors/php/config.inc.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/htdocs/core/filemanagerdol/connectors/php/config.inc.php b/htdocs/core/filemanagerdol/connectors/php/config.inc.php index 0704d51f615..bef7ef1b815 100644 --- a/htdocs/core/filemanagerdol/connectors/php/config.inc.php +++ b/htdocs/core/filemanagerdol/connectors/php/config.inc.php @@ -36,6 +36,7 @@ require_once '../../../../main.inc.php'; /** * @var Conf $conf + * @var User $user * * @var string $dolibarr_main_data_root * @var string $dolibarr_main_url_root @@ -49,6 +50,13 @@ if ($pos == '/') { //define('DOL_URL_ROOT', $pos); $entity = ((!empty($_SESSION['dol_entity']) && $_SESSION['dol_entity'] > 1) ? $_SESSION['dol_entity'] : null); +// This connector browses and writes into the medias directory, so it must be +// restricted the same way as on the newer branches. Without this check any +// authenticated user (even with no module right) could reach the file manager. +if (empty($user->admin) && !$user->hasRight('website', 'write')) { + accessforbidden('Need to be admin or having write permission on website module'); +} + // SECURITY: You must explicitly enable this "connector". (Set it to "true"). // WARNING: don't just set "$Config['Enabled'] = true ;", you must be sure that only // authenticated users can access this file or use some kind of session checking. From f267603a7cf22586b36abf149f51c58ca337e23c Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 00:56:44 +0200 Subject: [PATCH 154/307] Fix mailmap file --- .claude/skills/add-unit-test/SKILL.md | 9 +++++++++ .claude/skills/code-review/SKILL.md | 9 +++++++++ .mailmap | 15 ++++++++++----- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.claude/skills/add-unit-test/SKILL.md b/.claude/skills/add-unit-test/SKILL.md index 8aa5a965199..ffb14fb96ed 100644 --- a/.claude/skills/add-unit-test/SKILL.md +++ b/.claude/skills/add-unit-test/SKILL.md @@ -18,6 +18,15 @@ Use this skill whenever the user asks to create, modify, or complete a **PHP uni The goal is to produce a unit test that follows Dolibarr conventions and integrates cleanly into the existing PHPUnit test suite. + +## Inputs + +The user request should contain, when available: + +- a name of the function to make the unit test for +- or the method of the class name to make the unit test for + + ## General Rules - Follow the coding style already used in files into directory test/phpunit/ diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index b1c82d3bbec..fc85fc7f932 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -17,6 +17,15 @@ allowed-tools: Use this skill whenever the user asks to review code to fix bad practices or to update code to match good practices. +## Inputs + +The user request should contain, when available: + +- a module name +- or a directory name +- or a file name + + ## General Rules - Follow the coding style already used in files in module builder template in htdocs/modulebuilder/templates diff --git a/.mailmap b/.mailmap index 7fb0a8c7f30..32d2219ba0d 100644 --- a/.mailmap +++ b/.mailmap @@ -4,11 +4,13 @@ # # This files allows to have several profiles (the second part) matched to the same name/email (the first part). # -Laurent Destailleur Laurent Destailleur -Laurent Destailleur eldy -Laurent Destailleur Laurent Destailleur -Laurent Destailleur eldy10 -Laurent Destailleur Laurent Destailleur +Laurent Destailleur Laurent Destailleur +Laurent Destailleur eldy +Laurent Destailleur Laurent Destailleur +Laurent Destailleur eldy10 +Laurent Destailleur Laurent Destailleur +Laurent Destailleur Laurent Destailleur +Laurent Destailleur Laurent Destailleur Regis Houssin Regis Houssin Regis Houssin Régis Houssin Juanjo Menent simnandez @@ -18,12 +20,15 @@ Juanjo Menent juanjo Juanjo Menent juanjo Marcos García de La Fuente Marcos García Marcos García de La Fuente Marcos García +Mohamed DAOUD Mohamed DAOUD +Mohamed DAOUD Mohamed DAOUD Florian Henry fhenry Florian Henry Florian HENRY Florian Henry FHenry Florian Henry Florian HENRY Florian Henry FHenry Florian Henry HENRY Florian +Lucas Marcouiller Hystepik Alexandre Spangaro aspangaro Alexandre Spangaro Spangaro Alexandre Alexandre Spangaro Alexandre SPANGARO From 675b5bf1bc517646b3de6605ece0b46f24a66dcf Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 00:59:45 +0200 Subject: [PATCH 155/307] Doc --- .claude/skills/code-review/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index fc85fc7f932..b82913625c4 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -36,7 +36,8 @@ The user request should contain, when available: - Use PSR-12 coding style except tabulation that must use TAB characters and not SPACES. - Remove all spaces at end of lines -- Rewrite all code that is not in english +- Rewrite in english all code comments that are not in english +- Scan files for security vulnerabilities ## Output From 79242074f51521f0d3b1eb342857958324f71367 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 01:20:38 +0200 Subject: [PATCH 156/307] Backport ci optimizations --- .github/workflows/phpstan.yml | 38 +++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 2b1055d10ff..9f5a91b7925 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -37,7 +37,7 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # Get PHP and addons - name: Setup PHP id: setup-php @@ -52,7 +52,7 @@ jobs: # Restore old cache - name: Restore phpstan cache id: cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: ./.github/tmp key: phpstan-cache-${{ matrix.php-version }}-${{ env.CACHE_KEY_PART }}-${{ @@ -63,8 +63,8 @@ jobs: phpstan-cache-${{ matrix.php-version }}-${{ github.base_ref }}- phpstan-cache-${{ matrix.php-version }}- - - name: Show debug into - run: cd ./.github/tmp && ls -al + - name: Show debug info + run: ls -al ./.github/tmp || echo "Cache directory not found (first run)" # Another method to get the list of changed files # It sets the variable steps.changed-php.outputs.all_changed_files for other steps @@ -89,28 +89,44 @@ jobs: # Run PHPStan - name: Run PHPStan id: phpstan - if: "! cancelled() && steps.changed-php.outputs.any_changed == 'true'" + # Proceed when: + # - the action is not cancelled + # AND + # - the branch is an integration branch (no merge), or, + # - the merge from branch contains 'phpstan_full', or, + # - there are changes in PHP files. + if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || endsWith(github.ref_name, '.0') || contains(github.head_ref, 'phpstan_full') || steps.changed-php.outputs.any_changed == 'true') }} env: ALL_CHANGED_FILES: ${{ steps.changed-php.outputs.all_changed_files }} # shellcheck disable=2086 run: | # shellcheck disable=2086 - > /tmp/phpstan-files.txt - echo "$ALL_CHANGED_FILES" >> /tmp/phpstan-files.txt - cat /tmp/phpstan-files.txt - phpstan -vvv analyse --error-format=checkstyle --memory-limit 7G -a dev/build/phpstan/bootstrap_action.php ${ALL_CHANGED_FILES} | tee _stan.xml | cs2pr --graceful-warnings + if [ "${{ github.ref_name }}" == "develop" ] || [[ "${{ github.ref_name }}" == *.0 ]]|| [[ "${{ github.head_ref }}" == *"phpstan_full"* ]] ; then + # Run phpstan on all files in integration branch + phpstan analyse --no-progress --error-format=checkstyle --memory-limit 7G -a dev/build/phpstan/bootstrap_action.php | tee _stan.xml | cs2pr --graceful-warnings + else + PHP_FILES=$(echo "$ALL_CHANGED_FILES" | tr ' ' '\n' | grep -E '\.php$' || true) + echo "PHP_FILES=$PHP_FILES" + if [ -z "$PHP_FILES" ]; then + echo "No PHP files to analyse. Skipping PHPStan." + exit 0 + fi + + echo phpstan analyse --no-progress --error-format=checkstyle --memory-limit 7G -a dev/build/phpstan/bootstrap_action.php ${ALL_CHANGED_FILES} + phpstan analyse --no-progress --error-format=checkstyle --memory-limit 7G -a dev/build/phpstan/bootstrap_action.php "${{ env.DUMMY_FILE }}" ${ALL_CHANGED_FILES} | tee _stan.xml | cs2pr --graceful-warnings + fi # continue-on-error: true # Save cache - name: Save phpstan cache - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 if: ${{ success() || ( ! cancelled() && steps.cache.outputs.cache-hit != 'true' ) }} with: path: ./.github/tmp key: phpstan-cache-${{ matrix.php-version }}-${{ env.CACHE_KEY_PART }}-${{ github.run_id }} - name: Provide phpstan log as artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: ${{ always() }} with: name: phpstan-srcrt From 349c283a4a56dfe3231fb7024cb947ae43b9865b Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 01:22:24 +0200 Subject: [PATCH 157/307] Backport ci optimizations --- .github/workflows/phan.yml | 51 ++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/workflows/phan.yml b/.github/workflows/phan.yml index 228f8d37c3a..4807d1a9d08 100644 --- a/.github/workflows/phan.yml +++ b/.github/workflows/phan.yml @@ -33,7 +33,7 @@ jobs: github.repository == 'Dolibarr/dolibarr' || github.event.schedule == false steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # Another method to get the list of changed files # It sets the variable steps.changed-php.outputs.all_changed_files for other steps @@ -49,17 +49,52 @@ jobs: with: php-version: 8.2 coverage: none # disable xdebug, pcov - tools: phan:5.5.2 + extensions: ast + tools: cs2pr:1.8.6,phan:5.5.2 + - name: Run Phan analysis - if: "! cancelled() && steps.changed-php.outputs.any_changed == 'true'" + # Proceed when: + # - the action is not cancelled + # AND + # - the branch is an integration branch (no merge), or, + # - the merge from branch contains 'phan_full', or, + # - there are changes in PHP files. + # Note: --output-mode=github does not provide file:line, so using checkstyle and cs2pr + if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || endsWith(github.ref_name, '.0') || contains(github.head_ref, 'phan_full') || steps.changed-php.outputs.phan_changed == 'true') }} env: ALL_CHANGED_FILES: ${{ steps.changed-php.outputs.all_changed_files }} + PHAN_CHANGED_FILES: ${{ steps.changed-php.outputs.phan_changed_files }} # shellcheck disable=2086 + FILE_CHANGED_LIST: /tmp/phan-changed.lst run: | # shellcheck disable=2086 - > /tmp/phpstan-files.txt - for f in $ALL_CHANGED_FILES; do echo "$f" >> /tmp/phan-files.txt; done - cat /tmp/phan-files.txt - echo phan --file-list /tmp/phan-files.txt $PHAN_QUICK -k "$PHAN_CONFIG" -B "$PHAN_BASELINE" --analyze-twice --minimum-target-php-version "$PHAN_MIN_PHP" --output-mode=github - phan --file-list /tmp/phan-files.txt $PHAN_QUICK -k "$PHAN_CONFIG" -B "$PHAN_BASELINE" --analyze-twice --minimum-target-php-version "$PHAN_MIN_PHP" --output-mode=github + if [ "${{ github.ref_name }}" == "develop" ] || [[ "${{ github.ref_name }}" == *.0 ]]|| [[ "${{ github.head_ref }}" == *"phan_full"* ]] ; then + echo phan $PHAN_QUICK -k "$PHAN_CONFIG" -B "$PHAN_BASELINE" --analyze-twice --minimum-target-php-version "$PHAN_MIN_PHP" --output-mode=checkstyle -o _phan.xml + phan $PHAN_QUICK -k "$PHAN_CONFIG" -B "$PHAN_BASELINE" --analyze-twice --minimum-target-php-version "$PHAN_MIN_PHP" --output-mode=checkstyle -o _phan.xml + else + echo -n "" > "$FILE_CHANGED_LIST" + for f in $PHAN_CHANGED_FILES; do echo "$f" >> "$FILE_CHANGED_LIST"; done + # Must exclude same files as in phan configuration + grep -v -E '^htdocs/(custom/|.*/canvas/.*/tpl/.*.tpl.php|admin/tools/ui/|includes/(nusoap/|restler/|stripe/)|conf/conf.php)$' "$FILE_CHANGED_LIST"> "$FILE_CHANGED_LIST".tmp || true + mv "$FILE_CHANGED_LIST".tmp "$FILE_CHANGED_LIST" + if [ ! -s "$FILE_CHANGED_LIST" ] ; then + echo "All changed files are excluded for phan" + else + echo phan --file-list "$FILE_CHANGED_LIST" $PHAN_QUICK -k "$PHAN_CONFIG" -B "$PHAN_BASELINE" --analyze-twice --minimum-target-php-version "$PHAN_MIN_PHP" --output-mode=checkstyle -o _phan.xml + phan --file-list "$FILE_CHANGED_LIST" $PHAN_QUICK -k "$PHAN_CONFIG" -B "$PHAN_BASELINE" --analyze-twice --minimum-target-php-version "$PHAN_MIN_PHP" --output-mode=checkstyle -o _phan.xml + fi + fi + + - name: Add results to PR as Github notices + if: ${{ always() }} + run: | + cs2pr --prepend-filename --prepend-source --notices-as-warnings _phan.xml + + - name: Provide phan log as artifact + uses: actions/upload-artifact@v7 + if: ${{ always() }} + with: + name: phan-srcrt + path: ${{ github.workspace }}/_phan.xml + retention-days: 2 From b97959e5f07e8420430f33a03783ec78b0c4ef66 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 01:27:33 +0200 Subject: [PATCH 158/307] Doc --- htdocs/core/class/ldap.class.php | 1 - 1 file changed, 1 deletion(-) diff --git a/htdocs/core/class/ldap.class.php b/htdocs/core/class/ldap.class.php index 768bf1bcb1d..ae2e4b41ebe 100644 --- a/htdocs/core/class/ldap.class.php +++ b/htdocs/core/class/ldap.class.php @@ -312,7 +312,6 @@ class Ldap * Use this->server, this->serverPort, this->ldapProtocolVersion, this->serverType, this->searchUser, this->searchPassword * After return, this->connection and $this->bind are defined * - * @see connect_bind renamed * @return int if KO: <0 || if bind anonymous: 1 || if bind auth: 2 */ public function connectBind() From 4ddbfd977f18f4a732c62babde115b009d5b6a73 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 02:34:01 +0200 Subject: [PATCH 159/307] Fix regressions --- htdocs/core/class/discount.class.php | 14 +++++++------- test/phpunit/DiscountTest.php | 22 +++++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/htdocs/core/class/discount.class.php b/htdocs/core/class/discount.class.php index 6b973bd0d20..822ac3c96a1 100644 --- a/htdocs/core/class/discount.class.php +++ b/htdocs/core/class/discount.class.php @@ -937,18 +937,19 @@ class DiscountAbsolute extends CommonObject * * @param float $amount Currency amount to process (with or without tax, depending on $amount_type) * @param int<0,1> $amount_type 0 = Amount includes no taxes, 1 = Amount includes all the taxes - * @param float $tva_tx TVA (main tax) percentage (>1) - * @param float $localtax1_tx localtax1 percentage (>1) - * @param float $localtax2_tx localtax2 percentage (>1) + * @param float $tva_tx TVA (main tax) percentage (0-100) + * @param float $localtax1_tx localtax1 percentage (0-100) + * @param float $localtax2_tx localtax2 percentage (0-100) * @param int<0,1> $localtax1_type localtax1 type * @param int<0,1> $localtax2_type localtax2 type * @return int OK = >= 0, KO = < 0 */ public function generateFromAmount($amount, $amount_type, $tva_tx, $localtax1_tx, $localtax2_tx, $localtax1_type = 1, $localtax2_type = 1) { + $tva_tx_pct = (float) ($tva_tx / 100); $localtax1_tx_pct = (float) ($localtax1_tx / 100); $localtax2_tx_pct = (float) ($localtax2_tx / 100); - $tva_tx_pct = (float) ($tva_tx / 100); + $err = 0; // Test if localtax type is 2, 4 or 6 (compond tax calculation) @@ -957,7 +958,6 @@ class DiscountAbsolute extends CommonObject if ($amount_type == 1) { // TTC - $this->total_ttc = price2num($amount, 'MT'); if (!$localtax1_type2 && !$localtax2_type2) { @@ -987,11 +987,11 @@ class DiscountAbsolute extends CommonObject // HT $this->total_ht = price2num($amount, 'MT'); $this->total_tva = price2num((float) $this->total_ht * $tva_tx_pct, 'MT'); - $this->total_ttc = price2num((float) $this->total_ht + (float) $this->total_tva, 'MT'); + //$this->total_ttc = price2num((float) $this->total_ht + (float) $this->total_tva + ..., 'MT'); // Is set later $this->multicurrency_total_ht = price2num($amount * (float) $this->multicurrency_tx, 'MT'); $this->multicurrency_total_tva = price2num(((float) $amount * $tva_tx_pct) * (float) $this->multicurrency_tx, 'MT'); - $this->multicurrency_total_ttc = price2num(((float) $this->total_ht + (float) $this->total_tva) * (float) $this->multicurrency_tx, 'MT'); + //$this->multicurrency_total_ttc = price2num(((float) $this->total_ht + (float) $this->total_tva + ...) * (float) $this->multicurrency_tx, 'MT'); // Is set later } // For backward compatibility diff --git a/test/phpunit/DiscountTest.php b/test/phpunit/DiscountTest.php index e6a88432ed3..1f102650941 100644 --- a/test/phpunit/DiscountTest.php +++ b/test/phpunit/DiscountTest.php @@ -229,11 +229,11 @@ class DiscountTest extends CommonClassTest $localobject->generateFromAmount($amount, ($price_base == 'HT' ? 0 : 1), $vat_tx, $localtax1_tx, $localtax2_tx, $localtax1_type, $localtax2_type); // @phan-suppress-next-line PhanUndeclaredMethod - $this->assertEquals($ex_total_ht, $localobject->amount_ht); + $this->assertEquals($ex_total_ht, $localobject->total_ht); // @phan-suppress-next-line PhanUndeclaredMethod - $this->assertEquals($ex_total_ttc, $localobject->amount_ttc); + $this->assertEquals($ex_total_ttc, $localobject->total_ttc); // @phan-suppress-next-line PhanUndeclaredMethod - $this->assertEquals($ex_total_tva, $localobject->amount_tva); + $this->assertEquals($ex_total_tva, $localobject->total_tva); // @phan-suppress-next-line PhanUndeclaredMethod $this->assertEquals($ex_total_localtax1, price2num($localobject->total_localtax1, 'MT')); // @phan-suppress-next-line PhanUndeclaredMethod @@ -254,14 +254,14 @@ class DiscountTest extends CommonClassTest { // array(amount, vat_tx, localtax1_tx, localtax1_type, localtax2_tx, localtax2_type, price_base, ex_total_tva, ex_total_localtax1, ex_total_localtax2, ex_total_ttc, ex_total_ht), return array( - array(1234,5,9.975,1,0,0,'HT',61.7,123.09,0,1418.79,1234), - array(1418.79,5,9.975,1,0,0,'',61.7,123.09,0,1418.79,1234), - array(1234,5,9.975,1,4,1,'HT',61.7,123.09,49.36,1468.15,1234), - array(1468.15,5,9.975,1,4,1,'',61.7,123.09,49.36,1468.15,1234), - array(1234,5,9.975,2,0,0,'HT',61.7,129.25,0,1424.95,1234), - array(1424.95,5,9.975,2,0,0,'',61.7,129.25,0,1424.95,1234), - array(1234,5,9.975,2,4,2,'HT',61.7,129.25,57,1481.94,1234), - array(1481.94,5,9.975,2,4,2,'',61.7,129.25,57,1481.94,1234) + array(1234, 5, 9.975, 1, 0, 0, 'HT', 61.7, 123.09, 0, 1418.79, 1234), + array(1418.79, 5, 9.975, 1, 0, 0, '', 61.7, 123.09, 0, 1418.79, 1234), + array(1234, 5, 9.975, 1, 4, 1, 'HT', 61.7, 123.09, 49.36, 1468.15, 1234), + array(1468.15, 5, 9.975, 1, 4, 1, '', 61.7, 123.09, 49.36, 1468.15, 1234), + array(1234, 5, 9.975, 2, 0, 0,'HT', 61.7, 129.25, 0, 1424.95, 1234), + array(1424.95, 5, 9.975, 2, 0, 0, '', 61.7, 129.25, 0, 1424.95, 1234), + array(1234, 5, 9.975, 2, 4, 2,'HT', 61.7, 129.25, 57, 1481.94, 1234), + array(1481.94, 5, 9.975, 2, 4, 2, '', 61.7, 129.25, 57, 1481.94, 1234) ); } From a1c2470021aa61085e05d19af8d5ec21f1bee115 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 13:04:32 +0200 Subject: [PATCH 160/307] Debug v24 --- htdocs/admin/mails_templates.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/htdocs/admin/mails_templates.php b/htdocs/admin/mails_templates.php index 9861f1b4d9b..6acfd7d3fc7 100644 --- a/htdocs/admin/mails_templates.php +++ b/htdocs/admin/mails_templates.php @@ -776,6 +776,9 @@ if ($sortorder) { if ($sortfield) { $paramwithsearch .= '&sortfield='.urlencode($sortfield); } +if ($limit) { + $paramwithsearch .= '&limit='.urlencode($limit); +} if (GETPOST('from', 'alpha')) { $paramwithsearch .= '&from='.urlencode(GETPOST('from', 'alpha')); } @@ -813,7 +816,6 @@ if ($action != 'create') { if (!empty($user->admin) && (empty($_SESSION['leftmenu']) || $_SESSION['leftmenu'] != 'email_templates')) { print load_fiche_titre($title, '', $titlepicto); } else { - //print load_fiche_titre($title, $newcardbutton, $titlepicto); print_barre_liste($title, $page, $_SERVER["PHP_SELF"], $param, $sortfield, $sortorder, $massactionbutton, $num, $nbtotalofrecords, 'tools', 0, $newcardbutton, '', $limit, 'limit', 0, 1); } @@ -1173,11 +1175,11 @@ foreach ($fieldlist as $field => $value) { if ($sortfieldtouse == 'type_template') { $sortfieldtouse .= ',lang,position,label'; } - print getTitleFieldOfList($valuetoshow, 0, $_SERVER["PHP_SELF"], $sortfieldtouse, ($page ? 'page='.$page.'&' : ''), $param, '', $sortfield, $sortorder, $css.' '); + print getTitleFieldOfList($valuetoshow, 0, $_SERVER["PHP_SELF"], $sortfieldtouse, ($page ? 'page='.$page.'&' : ''), $paramwithsearch, '', $sortfield, $sortorder, $css.' '); } } -print getTitleFieldOfList($langs->trans("Status"), 0, $_SERVER["PHP_SELF"], "active", ($page ? 'page='.$page.'&' : ''), $param, '', $sortfield, $sortorder, 'center '); +print getTitleFieldOfList($langs->trans("Status"), 0, $_SERVER["PHP_SELF"], "active", ($page ? 'page='.$page.'&' : ''), $paramwithsearch, '', $sortfield, $sortorder, 'center '); // Action column if (!$conf->main_checkbox_left_column) { print getTitleFieldOfList(''); From 7624ab0b0a5830912e90a7d246c66d251b1620a1 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 19:31:58 +0200 Subject: [PATCH 161/307] Doc --- htdocs/blockedlog/versionmod.inc.php | 4 ++-- htdocs/langs/en_US/blockedlog.lang | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/blockedlog/versionmod.inc.php b/htdocs/blockedlog/versionmod.inc.php index 3eb8be1d1b5..e8aa54e3cce 100644 --- a/htdocs/blockedlog/versionmod.inc.php +++ b/htdocs/blockedlog/versionmod.inc.php @@ -24,7 +24,7 @@ // The version of the POS system (Immutable Log system) // Can be 3.0.0-beta when not yet certified // 3.0.0 once certified -define('DOLCERT_VERSION', '3.0.0-beta'); +define('DOLCERT_VERSION', '2.0.0'); // 1 was used for beta version candidate for certification, or for stable version that has been certified. @@ -32,7 +32,7 @@ define('DOLCERT_VERSION', '3.0.0-beta'); // 0 was used for old version, for version not certified but compliant with the law by using an attestation of an IT provider that guarantee // the the software is compliant. WARNING: In this case, you must find an IT company that give you the attestation of conformity. if (!defined('CERTIF_LNE')) { - define('CERTIF_LNE', '2'); + define('CERTIF_LNE', '0'); } diff --git a/htdocs/langs/en_US/blockedlog.lang b/htdocs/langs/en_US/blockedlog.lang index 4122a80ddad..68778a6daae 100644 --- a/htdocs/langs/en_US/blockedlog.lang +++ b/htdocs/langs/en_US/blockedlog.lang @@ -64,7 +64,7 @@ LNECertifiedPOSSystem=LNE Certified POS system LNECandidatePOSSystem=POS system candidate for the LNE certification LNECertifiedVersionFR=The version %s of module Immutable Logs is a certified version. More information on https://www.dolibarr.org/certifications.php LNECandidateVersionForCertificationFR=The version %s of module Immutable Logs is a candidate version to become a LNE certified version. Not yet certified. -NotCertifiedVersionFR=This version %s of application is technically ready to be compliant with the french VAT anti-fraud law (check any update of the law of your country), but is NOT the certified version, so you MUST get the compliant statement from an IT company to be allowed to use this software to collect payments from private individuals (ask your reseller or integrator to obtain this statement of compliance). +NotCertifiedVersionFR=This version %s of application is technically ready to be compliant with the french VAT anti-fraud law (check any update of the law of your country). If you collect payments from private individuals and you are elligible to VAT, you can't use this software without a compliance statement ("French attestation"). So You MUST ask your reseller, integrator or hosting company to obtain this statement of compliance (Note: A list of companies that provide the statement for their customers may be available on https://partners.dolibarr.org). InvoiceGeneratedWithLNECertifiedPOSSystem=Invoice generated with a LNE certified POS system InvoiceGeneratedWithLNECandidatePOSSystem=Invoice generated with a candidate for the LNE certified POS system TotalTTCIfInvoiceSeeCompleteDataForDetail=Event amount. Total including tax if invoice. See the column "complete data" for details. @@ -137,7 +137,7 @@ NeedAThirdPartyStatement=You need a third-party statement to be compliant with l ForPeriodAndFilters=For the period and filters AddtionalInformation=Additional technical information StatusValid=Valid -NotCertified=Not certified (see statement below) +NotCertified=Need a compliance statement (see note below) ## logTypes logBILL_DELETE=Customer invoice logically deleted logBILL_PAYED=Customer invoice paid From 40e7493eacdc810ee1940616fe20f9876a444e8a Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 20:31:48 +0200 Subject: [PATCH 162/307] Debug v24 --- htdocs/core/class/commonobject.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index f073c6acf23..9ab5d24404e 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -1342,7 +1342,7 @@ abstract class CommonObject return -2; } - if ($this->restrictiononfksoc && property_exists($this, 'socid') && !empty($this->socid) && !$user->hasRight('societe', 'client', 'voir')) { + if ($this->restrictiononfksoc && property_exists($this, 'socid') && !empty($this->socid) && $user->id > 0 && !$user->hasRight('societe', 'client', 'voir')) { $sql_allowed_contacts = 'SELECT COUNT(*) as cnt FROM '.$this->db->prefix().'societe_commerciaux as sc'; $sql_allowed_contacts .= ' WHERE sc.fk_soc = '.(int) $this->socid; $sql_allowed_contacts .= ' AND sc.fk_user = '.(int) $user->id; From ca789645fc8264e0d27bfe4e994393aa7efac622 Mon Sep 17 00:00:00 2001 From: MDW Date: Wed, 15 Jul 2026 20:40:45 +0200 Subject: [PATCH 163/307] Fix: Use AI provider agnostic .agents, update commit exclusion: develop and version branches (#39189) * qual: Update branch protection rules in AGENTS.md Updated the branch protection rules in AGENTS.md to reflect the new policy of never committing directly to the `develop` branch or version branches matching the regex `^\d+\.\d+$`. * Fix: Rename .claude directory to .agents Renamed the .claude directory and its contents to .agents. .agents is AI provider agnostic. --- {.claude => .agents}/AGENTS.md | 2 +- {.claude => .agents}/skills/add-unit-test/SKILL.md | 0 {.claude => .agents}/skills/code-review/SKILL.md | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename {.claude => .agents}/AGENTS.md (98%) rename {.claude => .agents}/skills/add-unit-test/SKILL.md (100%) rename {.claude => .agents}/skills/code-review/SKILL.md (100%) diff --git a/.claude/AGENTS.md b/.agents/AGENTS.md similarity index 98% rename from .claude/AGENTS.md rename to .agents/AGENTS.md index 070e899949b..a1d66837061 100644 --- a/.claude/AGENTS.md +++ b/.agents/AGENTS.md @@ -15,7 +15,7 @@ Every modification must respect: - ❌ Do not break compatibility of PHP functions and methods - ❌ Do not introduce external dependencies without validation - ❌ Separate page actions in the `/* Actions */` section of the PHP code and the rendering part in the `/* Views */` section -- ❌ Never commit directly to `main` or `master` branch +- ❌ Never commit directly to `develop` or version branch matching regex `^\d+\.\d+$` - ❌ Never use PHP native curl functions to call a GET or POST URL, but use instead the Dolibarr function getURLContent() - ✅ Use Dolibarr hooks whenever possible - ✅ Respect existing naming conventions diff --git a/.claude/skills/add-unit-test/SKILL.md b/.agents/skills/add-unit-test/SKILL.md similarity index 100% rename from .claude/skills/add-unit-test/SKILL.md rename to .agents/skills/add-unit-test/SKILL.md diff --git a/.claude/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md similarity index 100% rename from .claude/skills/code-review/SKILL.md rename to .agents/skills/code-review/SKILL.md From b67c468eaf73e8b611f2e266e30fc5564acb1f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Aliot?= Date: Wed, 15 Jul 2026 20:41:31 +0200 Subject: [PATCH 164/307] FIX #37026 Chart of accounts export drops root and cross-chart accounts (#39159) * FIX #37026 Chart of accounts export drops root and cross-chart accounts The "Chart of accounts" export dataset joined the parent account with an old style comma join plus aa2.active = 1 and ac.pcg_version = aa2.fk_pcg_version. That inner join silently dropped every account that has no parent (root accounts), whose parent is inactive, or whose parent belongs to another chart. The last case is common with a custom chart derived from a base one (the derived accounts keep their base parents), which is what the reporter saw: only the self consistent base chart came through instead of the active custom chart. Use an explicit INNER JOIN on accounting_system for the chart and a LEFT JOIN on the parent account keyed on the parent rowid only, so accounts are exported whatever their parent is. On a standard install this brings the export from 3087 to 3171 accounts (84 root or cross-chart accounts were missing). Signed-off-by: Gregory Aliot * Update modAccounting.class.php * Complete the sentence left dangling in the parent join comment Signed-off-by: Gregory Aliot --------- Signed-off-by: Gregory Aliot Co-authored-by: Gregory Aliot Co-authored-by: Laurent Destailleur --- htdocs/core/modules/modAccounting.class.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/htdocs/core/modules/modAccounting.class.php b/htdocs/core/modules/modAccounting.class.php index a630b1f9fd3..ff926618558 100644 --- a/htdocs/core/modules/modAccounting.class.php +++ b/htdocs/core/modules/modAccounting.class.php @@ -250,10 +250,11 @@ class modAccounting extends DolibarrModules $this->export_sql_start[$r] = 'SELECT DISTINCT '; $this->export_sql_end[$r] = ' FROM '.MAIN_DB_PREFIX.'accounting_account as aa'; - $this->export_sql_end[$r] .= ' ,'.MAIN_DB_PREFIX.'accounting_system as ac'; - $this->export_sql_end[$r] .= ' ,'.MAIN_DB_PREFIX.'accounting_account as aa2'; - $this->export_sql_end[$r] .= ' WHERE ac.pcg_version = aa.fk_pcg_version AND aa.entity IN ('.getEntity('accounting').')'; - $this->export_sql_end[$r] .= ' AND aa2.rowid = aa.account_parent AND aa2.active = 1 AND ac.pcg_version = aa2.fk_pcg_version AND aa2.entity IN ('.getEntity('accounting').')'; + $this->export_sql_end[$r] .= ' INNER JOIN '.MAIN_DB_PREFIX.'accounting_system as ac ON ac.pcg_version = aa.fk_pcg_version'; + // LEFT JOIN on the parent account: keep accounts that have no parent (root accounts) or whose parent is + // inactive or belongs to another chart (common when a custom chart is derived from a base one). + $this->export_sql_end[$r] .= ' LEFT JOIN '.MAIN_DB_PREFIX.'accounting_account as aa2 ON aa2.rowid = aa.account_parent AND aa2.entity IN ('.getEntity('accounting').')'; + $this->export_sql_end[$r] .= ' WHERE aa.entity IN ('.getEntity('accounting').')'; // Imports From acf69ceeeef4e706d8aedc58574c89f28cdf4337 Mon Sep 17 00:00:00 2001 From: TowyTowy <85077986+TowyTowy@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:42:56 +0200 Subject: [PATCH 165/307] FIX getWeekNumbersOfMonth() drops the last week when the month ends on a Monday (#39185) getWeekNumbersOfMonth() looped with "for ($day = 1; $day < $nb_days; ...)", so the last day of the month was never inspected. When that last day is a Monday it starts a new ISO week that no earlier day of the month belongs to, so its week number was silently missing from the returned array. This surfaces in the project "time spent per month" view (htdocs/projet/activity/permonth.php), which builds one column per week from this list: for such months the final week column is dropped. Over 2015-2035, 35 months are affected (e.g. 2015-08 returned weeks 31-35 but omitted 36, the week of Monday 2015-08-31). Fix: iterate up to and including the last day ($day <= $nb_days). Signed-off-by: TowyTowy Co-authored-by: Claude --- htdocs/core/lib/date.lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/lib/date.lib.php b/htdocs/core/lib/date.lib.php index 6d4585c41c9..d2d88114820 100644 --- a/htdocs/core/lib/date.lib.php +++ b/htdocs/core/lib/date.lib.php @@ -1178,7 +1178,7 @@ function getWeekNumbersOfMonth($month, $year) { $nb_days = cal_days_in_month(CAL_GREGORIAN, $month, $year); $TWeek = array(); - for ($day = 1; $day < $nb_days; $day++) { + for ($day = 1; $day <= $nb_days; $day++) { $week_number = getWeekNumber($day, $month, $year); $TWeek[$week_number] = $week_number; } From af57d3cc4601c598b05639c2667dac107f4d8f98 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Wed, 15 Jul 2026 20:47:56 +0200 Subject: [PATCH 166/307] Fix type --- htdocs/societe/class/societe.class.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/htdocs/societe/class/societe.class.php b/htdocs/societe/class/societe.class.php index d8c3d3bf9d5..7a97f71092d 100644 --- a/htdocs/societe/class/societe.class.php +++ b/htdocs/societe/class/societe.class.php @@ -2761,21 +2761,21 @@ class Societe extends CommonObject $discount->multicurrency_tx = $this->multicurrency_tx; if ($price_base_type == 'TTC') { - $discount->total_ttc = price2num($remise, 'MT'); - $discount->total_ht = price2num((float) $remise / (1 + (float) $vatrate / 100), 'MT'); - $discount->total_tva = price2num((float) $discount->total_ttc - (float) $discount->total_ht, 'MT'); + $discount->total_ttc = (float) price2num($remise, 'MT'); + $discount->total_ht = (float) price2num((float) $remise / (1 + (float) $vatrate / 100), 'MT'); + $discount->total_tva = (float) price2num((float) $discount->total_ttc - (float) $discount->total_ht, 'MT'); - $discount->multicurrency_total_ttc = price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); - $discount->multicurrency_total_ht = price2num(((float) $remise / (1 + (float) $vatrate / 100)) * (float) $discount->multicurrency_tx, 'MT'); - $discount->multicurrency_total_tva = price2num(((float) $discount->total_ttc - (float) $discount->total_ht) * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_total_ttc = (float) price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_total_ht = (float) price2num(((float) $remise / (1 + (float) $vatrate / 100)) * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_total_tva = (float) price2num(((float) $discount->total_ttc - (float) $discount->total_ht) * (float) $discount->multicurrency_tx, 'MT'); } else { - $discount->total_ht = price2num($remise, 'MT'); - $discount->total_tva = price2num((float) $remise * (float) $vatrate / 100, 'MT'); - $discount->total_ttc = price2num((float) $discount->total_ht + (float) $discount->total_tva, 'MT'); + $discount->total_ht = (float) price2num($remise, 'MT'); + $discount->total_tva = (float) price2num((float) $remise * (float) $vatrate / 100, 'MT'); + $discount->total_ttc = (float) price2num((float) $discount->total_ht + (float) $discount->total_tva, 'MT'); - $discount->multicurrency_total_ht = price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); - $discount->multicurrency_total_tva = price2num(((float) $remise * (float) $vatrate / 100) * (float) $discount->multicurrency_tx, 'MT'); - $discount->multicurrency_total_ttc = price2num(((float) $discount->total_ht + (float) $discount->total_tva) * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_total_ht = (float) price2num((float) $remise * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_total_tva = (float) price2num(((float) $remise * (float) $vatrate / 100) * (float) $discount->multicurrency_tx, 'MT'); + $discount->multicurrency_total_ttc = (float) price2num(((float) $discount->total_ht + (float) $discount->total_tva) * (float) $discount->multicurrency_tx, 'MT'); } // For backward compatibility From 17b3dd2c045677de801e4f83f6c9af4685b4656a Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:14:45 +0200 Subject: [PATCH 167/307] CI --- dev/build/makepack-dolibarr.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev/build/makepack-dolibarr.php b/dev/build/makepack-dolibarr.php index 4eb3c9192e2..29086e91120 100755 --- a/dev/build/makepack-dolibarr.php +++ b/dev/build/makepack-dolibarr.php @@ -230,6 +230,8 @@ if (preg_match("/define\('DOL_VERSION',\s*'([\d\.a-z\-]+)'\)/i", $filefuncConten $PROJVERSION = $matches[1]; } if (empty($PROJVERSION)) { + $DOL_MAJOR_VERSION = 'notfound'; + $DOL_MINOR_VERSION = 'notfound'; if (preg_match("/define\('DOL_MAJOR_VERSION',\s*'([\d\.a-z\-]+)'\)/i", $filefuncContent, $matches)) { $DOL_MAJOR_VERSION = $matches[1]; } From d99840c7594177b3821b3c2d62fb6062f34fde97 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:20:39 +0200 Subject: [PATCH 168/307] ci --- dev/build/generate_filelist_xml.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dev/build/generate_filelist_xml.php b/dev/build/generate_filelist_xml.php index d28e08c6bfb..93bf16c964f 100755 --- a/dev/build/generate_filelist_xml.php +++ b/dev/build/generate_filelist_xml.php @@ -106,7 +106,7 @@ while ($i < $argc) { $includeconstants[$tmp[0]][$tmp[1]] = $tmp[2]; } if (!empty($result["buildzip"])) { - $buildzip = 1; + $buildzip = $result["buildzip"]; } $i++; } @@ -124,7 +124,7 @@ $savrelease = $release; $tmpver = explode('-', $release, 2); if ($tmpver[0] == 'auto') { $release = DOL_VERSION; - if (!empty($tmpver[1]) && $tmpver[0] == 'auto') { + if (!empty($tmpver[1])) { $release .= '-'.$tmpver[1]; } } @@ -132,7 +132,7 @@ if ($tmpver[0] == 'auto') { $tmpver = explode('-', $checklock, 2); if ($tmpver[0] == 'auto') { $checklock = DOL_VERSION; - if (!empty($tmpver[1]) && $tmpver[0] == 'auto') { + if (!empty($tmpver[1])) { $checklock .= '-'.$tmpver[1]; } } @@ -399,12 +399,12 @@ foreach ($arrayofunalterablefiles as $entry) { exit(1); } if ($newdir != $dir) { - if ($needtoclose) { - if ($release) { - fputs($fp, ' '."\n"); - } - $needtoclose = 0; + //if ($needtoclose) { + if ($release) { + fputs($fp, ' '."\n"); } + $needtoclose = 0; + //} if ($release) { fputs($fp, ' '."\n"); } From 4c63b7a5e3be33705b0857b2646a1755a22151b1 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:26:08 +0200 Subject: [PATCH 169/307] ci --- htdocs/core/class/html.formsetup.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/class/html.formsetup.class.php b/htdocs/core/class/html.formsetup.class.php index 126ab45f0e9..0905c245308 100644 --- a/htdocs/core/class/html.formsetup.class.php +++ b/htdocs/core/class/html.formsetup.class.php @@ -1581,7 +1581,7 @@ class FormSetupItem /** * Set type of input as a simple title. No data to store * - * @param ?array $fieldOptions A table of field options + * @param ?array $fieldOptions A table of field options * @return self */ public function setAsSelect($fieldOptions) From 1f779ba604884fbb2d51354e519447bacdaf8ddc Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:32:50 +0200 Subject: [PATCH 170/307] ci --- dev/build/makepack-dolibarr.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/dev/build/makepack-dolibarr.php b/dev/build/makepack-dolibarr.php index 29086e91120..326376305c9 100755 --- a/dev/build/makepack-dolibarr.php +++ b/dev/build/makepack-dolibarr.php @@ -271,7 +271,7 @@ if (strpos($newbuild, '-') === false) { $newbuild .= '-0.4'; // finale (fedora) } $REL1 = preg_replace('/-.*$/', '', $newbuild); -if ($RPMSUBVERSION === 'auto') { +if ($RPMSUBVERSION === 'auto') { // @phpstan-ignore-line $RPMSUBVERSION = preg_replace('/^.*-/', '', $newbuild); } $FILENAMETGZ2 = "$PROJECT-$MAJOR.$MINOR.$REL1"; @@ -316,10 +316,10 @@ for ($i = 1; $i < $argc; $i++) { } // Force output dir if env vars are defined -if ($ENVDESTIBETARC && preg_match('/[a-z]/i', $BUILD)) { +if ($ENVDESTIBETARC && preg_match('/[a-z]/i', $BUILD)) { // @phpstan-ignore-line $DESTI = $ENVDESTIBETARC; } -if ($ENVDESTISTABLE && preg_match('/^[0-9]+$/', $BUILD)) { +if ($ENVDESTISTABLE && preg_match('/^[0-9]+$/', $BUILD)) { // @phpstan-ignore-line $DESTI = $ENVDESTISTABLE; } @@ -351,12 +351,12 @@ if ($target) { $targetUpper = strtoupper($target); if ($targetUpper === 'ALL') { foreach ($LISTETARGET as $key) { - if ($key !== 'SNAPSHOT' && $key !== 'SF' && $key !== 'ASSO') { + if ($key !== 'SNAPSHOT' && $key !== 'SF' && $key !== 'ASSO') { // @phpstan-ignore-line $CHOOSEDTARGET[$key] = 1; } } } - if ($targetUpper !== 'ALL' && $targetUpper !== 'SF' && $targetUpper !== 'ASSO') { + if ($targetUpper !== 'ALL' && $targetUpper !== 'SF' && $targetUpper !== 'ASSO') { // @phpstan-ignore-line $CHOOSEDTARGET[$targetUpper] = 1; } if ($targetUpper === 'SF') { @@ -401,7 +401,7 @@ if ($target) { } elseif ($NUM_SCRIPT === '0') { $CHOOSEDTARGET['-CHKSUM'] = 1; foreach ($LISTETARGET as $key) { - if ($key !== 'SNAPSHOT' && $key !== 'ASSO' && $key !== 'SF') { + if ($key !== 'SNAPSHOT' && $key !== 'ASSO' && $key !== 'SF') { // @phpstan-ignore-line $CHOOSEDTARGET[$key] = 1; } } @@ -497,7 +497,7 @@ foreach ($CHOOSEDTARGET as $tgt => $val) { ksort($CHOOSEDPUBLISH); foreach ($CHOOSEDPUBLISH as $tgt => $val) { - if ($val < 0) { continue; } + if ($val < 0) { continue; } // @phpstan-ignore-line if ($tgt === 'ASSO') { $nbofpublishneedchangelog++; } if ($tgt === 'SF') { $nbofpublishneedchangelog++; $nbofpublishneedtag++; } $nboftargetok++; @@ -623,7 +623,7 @@ if ($nboftargetok) { // ======================================================================== if ($nboftargetneedbuildroot) { - if (!$copyalreadydone) { + if (!$copyalreadydone) { // @phpstan-ignore-line echo "Creation of a buildroot used for all packages\n"; echo "Delete directory $BUILDROOT\n"; @@ -1299,7 +1299,7 @@ if ($nboftargetok) { ksort($CHOOSEDPUBLISH); foreach ($CHOOSEDPUBLISH as $tgt => $val) { - if ($val < 0) { continue; } + if ($val < 0) { continue; } // @phpstan-ignore-line echo "\nList of files to publish (BUILD=$BUILD)\n"; From a4993a5ec47cd0c613d20b1988e4f44e1dc71093 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:42:08 +0200 Subject: [PATCH 171/307] Try to restore ci like before --- .github/workflows/phpstan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 9f5a91b7925..c204d63f2f3 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -95,7 +95,7 @@ jobs: # - the branch is an integration branch (no merge), or, # - the merge from branch contains 'phpstan_full', or, # - there are changes in PHP files. - if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || endsWith(github.ref_name, '.0') || contains(github.head_ref, 'phpstan_full') || steps.changed-php.outputs.any_changed == 'true') }} + if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || contains(github.head_ref, 'phpstan_full') || steps.changed-php.outputs.any_changed == 'true') }} env: ALL_CHANGED_FILES: ${{ steps.changed-php.outputs.all_changed_files }} # shellcheck disable=2086 From 53481619455d48458477be798730ac52bf779a9a Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:44:45 +0200 Subject: [PATCH 172/307] Revert "Try to restore ci like before" This reverts commit a4993a5ec47cd0c613d20b1988e4f44e1dc71093. --- .github/workflows/phpstan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index c204d63f2f3..9f5a91b7925 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -95,7 +95,7 @@ jobs: # - the branch is an integration branch (no merge), or, # - the merge from branch contains 'phpstan_full', or, # - there are changes in PHP files. - if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || contains(github.head_ref, 'phpstan_full') || steps.changed-php.outputs.any_changed == 'true') }} + if: ${{ ! cancelled() && (github.ref_name == 'develop' || github.ref_name == 'refs/heads/develop' || endsWith(github.ref_name, '.0') || contains(github.head_ref, 'phpstan_full') || steps.changed-php.outputs.any_changed == 'true') }} env: ALL_CHANGED_FILES: ${{ steps.changed-php.outputs.all_changed_files }} # shellcheck disable=2086 From 5358fca80c8c935ddda43d48a390d6da54da6fd0 Mon Sep 17 00:00:00 2001 From: w3houde Date: Thu, 16 Jul 2026 05:46:21 +0700 Subject: [PATCH 173/307] Backport paymentok payment_intent session bypass to 23.0 (partial for #39154) (#39168) * Fix #39154 bypass paymentok session check when returning from Stripe When STRIPE_USE_INTENT_WITH_AUTOMATIC_CONFIRMATION is set to 2, Stripe confirmPayment() redirects the customer directly to paymentok.php and appends payment_intent, payment_intent_client_secret and redirect_status to the return url, but not the internal paymentoksessioncode. When the Dolibarr session cookie is not preserved across the cross-site redirect, the strict session-code check rejected valid payments with 'Stripe payment verification failed: TRANSACTIONID is not set or session key does not match', even though the payment succeeded and was recorded by the IPN webhook. Skip the paymentoksessioncode check when payment_intent is present in GET. Security is preserved: the PaymentIntent is then retrieved from the Stripe API and its amount, currency and status are verified. Backport of the develop fix (PR #38577) to the 23.0 maintenance branch. Signed-off-by: Dolicraft * Fix #39154 use !GETPOST instead of empty(GETPOST) per review Signed-off-by: Dolicraft --------- Signed-off-by: Dolicraft Co-authored-by: Dolicraft Co-authored-by: Laurent Destailleur --- htdocs/public/payment/paymentok.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/htdocs/public/payment/paymentok.php b/htdocs/public/payment/paymentok.php index 8865645a4fb..b1e83079a61 100644 --- a/htdocs/public/payment/paymentok.php +++ b/htdocs/public/payment/paymentok.php @@ -397,7 +397,9 @@ if (isModEnabled('stripe') && $paymentmethod === 'stripe') { } // Check we are coming from the newpaymentpage - if (GETPOST('paymentoksessioncode') !== $_SESSION['paymentoksessioncode']) { + // Bypass session check when returning from Stripe confirmPayment() (mode STRIPE_USE_INTENT_WITH_AUTOMATIC_CONFIRMATION=2) + // In that case, payment_intent is passed in GET by Stripe and PaymentIntent::retrieve() below acts as verification + if (!GETPOST('payment_intent', 'alphanohtml') && GETPOST('paymentoksessioncode') !== $_SESSION['paymentoksessioncode']) { $error++; $errmsg = 'Attempted direct access to the paymentok page without a valid session.'; dol_syslog($errmsg, LOG_ERR, 0, '_payment'); From 070fbcdd28e744f4b83751b2bda37c87e4264ff5 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:57:15 +0200 Subject: [PATCH 174/307] Fix regression --- htdocs/fourn/class/paiementfourn.class.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/htdocs/fourn/class/paiementfourn.class.php b/htdocs/fourn/class/paiementfourn.class.php index bfb158a3532..af58de7c5c2 100644 --- a/htdocs/fourn/class/paiementfourn.class.php +++ b/htdocs/fourn/class/paiementfourn.class.php @@ -207,13 +207,11 @@ class PaiementFourn extends Paiement $invoice_multicurrency_code = $this->multicurrency_code[$key] ?? ''; $invoice_multicurrency_tx = $this->multicurrency_tx[$key] ?? ''; if (empty($invoice_multicurrency_code) || empty($invoice_multicurrency_tx)) { - $tmparray = MultiCurrency::getInvoiceRate($key, 'facture_fourn'); - if ($tmparray !== false) { - if (empty($invoice_multicurrency_code)) { - $invoice_multicurrency_code = $tmparray['invoice_multicurrency_code']; - } + $rate = MultiCurrency::getInvoiceRate($key, 'facture_fourn'); + if ($rate !== false) { if (empty($invoice_multicurrency_tx)) { - $invoice_multicurrency_tx = $tmparray['invoice_multicurrency_tx']; + //$invoice_multicurrency_tx = $tmparray['invoice_multicurrency_tx']; + $invoice_multicurrency_tx = $rate; } } } From c7dbdd0338273608bed4e194c49a9ca5deabf986 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 00:59:28 +0200 Subject: [PATCH 175/307] Restore good code after merge of a regression --- htdocs/fourn/class/paiementfourn.class.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/htdocs/fourn/class/paiementfourn.class.php b/htdocs/fourn/class/paiementfourn.class.php index b80028d4e1a..7b9426790c8 100644 --- a/htdocs/fourn/class/paiementfourn.class.php +++ b/htdocs/fourn/class/paiementfourn.class.php @@ -210,11 +210,13 @@ class PaiementFourn extends Paiement $invoice_multicurrency_code = $this->multicurrency_code[$key] ?? ''; $invoice_multicurrency_tx = $this->multicurrency_tx[$key] ?? ''; if (empty($invoice_multicurrency_code) || empty($invoice_multicurrency_tx)) { - $rate = MultiCurrency::getInvoiceRate($key, 'facture_fourn'); - if ($rate !== false) { + $tmparray = MultiCurrency::getInvoiceRate($key, 'facture_fourn'); + if ($tmparray !== false) { + if (empty($invoice_multicurrency_code)) { + $invoice_multicurrency_code = $tmparray['invoice_multicurrency_code']; + } if (empty($invoice_multicurrency_tx)) { - //$invoice_multicurrency_tx = $tmparray['invoice_multicurrency_tx']; - $invoice_multicurrency_tx = $rate; + $invoice_multicurrency_tx = $tmparray['invoice_multicurrency_tx']; } } } From da6ef26c8e32411e5cabc39d89ae4349c79ebc52 Mon Sep 17 00:00:00 2001 From: ATM-NicolasV <92087862+ATM-NicolasV@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:03:35 +0200 Subject: [PATCH 176/307] Fix empty SET clause in insertExtraFields UPDATE when no target column matches (#39184) When an object is created from another element whose extrafields do not exist on the target element (e.g. an invoice created from a shipment: the source line extrafields are copied into array_options but none match facturedet), the top-level empty($this->array_options) guard passes but the filtered column set is empty. When an extrafields row already exists for the object, the UPDATE branch then built "UPDATE llx_..._extrafields SET WHERE fk_object = X" with an empty SET clause, producing a MariaDB syntax error ("near 'WHERE fk_object = ...'") and aborting the creation of the invoice. Guard the UPDATE branch: when there is no column of the target element to update, commit and return without running the query (the existing row is already correct). This keeps the multicompany data-loss fix (UPDATE instead of DELETE+INSERT, #34022) untouched. Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Laurent Destailleur --- htdocs/core/class/commonobject.class.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index f6088b69482..68734355249 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -7001,6 +7001,15 @@ abstract class CommonObject // if the extrafields row already exists for the object, we update it if ($this->db->getRow("SELECT 1 FROM {$extrafieldsTable} WHERE fk_object = ".((int) $this->id))) { array_shift($sqlColumnValues); // drop the 'fk_object' column because its value won't change + if (empty($sqlColumnValues)) { + // No column of the target element to update. This happens when the object was created from + // another element whose extrafields do not exist on this element (e.g. invoice created from a + // shipment: the source line extrafields are copied into array_options but none match facturedet). + // The existing row is already correct, so there is nothing to update: avoid an empty "SET" clause + // that would produce an invalid SQL statement. + $this->db->commit(); + return 1; + } $sqlColumnValueString = implode( ',', /** From 063d0242b31b94e2bbfd55db54650344f7addd37 Mon Sep 17 00:00:00 2001 From: John BOTELLA <68917336+thersane-john@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:04:47 +0200 Subject: [PATCH 177/307] Fix extrafield hidden field not ecaped value (#39149) * Fix extrafield hidden field not ecaped value * Update commonobject.class.php --------- Co-authored-by: Laurent Destailleur --- htdocs/core/class/commonobject.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index d00b6ae876e..7dccf56f726 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -9478,8 +9478,8 @@ abstract class CommonObject } elseif (($mode == 'edit') && !in_array(abs($visibility), array(1, 3, 4))) { // We need to make sure, that the values of hidden extrafields are also part of $_POST. Otherwise, they would be empty after an update of the object. See also getOptionalsFromPost $ef_name = 'options_' . $key; - $ef_value = $this->array_options[$ef_name]??''; - $out .= '' . "\n"; + $ef_value = $this->array_options[$ef_name] ?? ''; + $out .= '' . "\n"; // HTML-escape the value for safe output, but preserve its original content so it can be submitted and saved back unchanged. Do not use dol_escape_htmltag(). continue; // <> -1 and <> 1 and <> 3 = not visible on forms, only on list and <> 4 = not visible at the creation } elseif ($mode == 'view' && empty($visibility)) { continue; From 3ecc61845ea30ca8fcab6bb482889e9407af1b46 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 01:19:08 +0200 Subject: [PATCH 178/307] Fix type --- htdocs/admin/mails_templates.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/admin/mails_templates.php b/htdocs/admin/mails_templates.php index 6acfd7d3fc7..75a6e2fadad 100644 --- a/htdocs/admin/mails_templates.php +++ b/htdocs/admin/mails_templates.php @@ -777,7 +777,7 @@ if ($sortfield) { $paramwithsearch .= '&sortfield='.urlencode($sortfield); } if ($limit) { - $paramwithsearch .= '&limit='.urlencode($limit); + $paramwithsearch .= '&limit='.((int) $limit); } if (GETPOST('from', 'alpha')) { $paramwithsearch .= '&from='.urlencode(GETPOST('from', 'alpha')); From 2b4a16df16ef3c6c4ea7ea13cb63ca8b37f35110 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 01:24:52 +0200 Subject: [PATCH 179/307] Version --- htdocs/blockedlog/versionmod.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/blockedlog/versionmod.inc.php b/htdocs/blockedlog/versionmod.inc.php index e8aa54e3cce..811c007acda 100644 --- a/htdocs/blockedlog/versionmod.inc.php +++ b/htdocs/blockedlog/versionmod.inc.php @@ -24,7 +24,7 @@ // The version of the POS system (Immutable Log system) // Can be 3.0.0-beta when not yet certified // 3.0.0 once certified -define('DOLCERT_VERSION', '2.0.0'); +define('DOLCERT_VERSION', '3.0.0'); // 1 was used for beta version candidate for certification, or for stable version that has been certified. From 650a339f5b7898e42cf31ac7e526803784600865 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 01:27:25 +0200 Subject: [PATCH 180/307] Version --- htdocs/blockedlog/versionmod.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/blockedlog/versionmod.inc.php b/htdocs/blockedlog/versionmod.inc.php index 811c007acda..62aeb3645a5 100644 --- a/htdocs/blockedlog/versionmod.inc.php +++ b/htdocs/blockedlog/versionmod.inc.php @@ -24,7 +24,7 @@ // The version of the POS system (Immutable Log system) // Can be 3.0.0-beta when not yet certified // 3.0.0 once certified -define('DOLCERT_VERSION', '3.0.0'); +define('DOLCERT_VERSION', '2.5.0'); // 1 was used for beta version candidate for certification, or for stable version that has been certified. From 34c78e21b6945abf896a0b6d8f5e7204a4fc8015 Mon Sep 17 00:00:00 2001 From: HENRY Florian Date: Thu, 16 Jul 2026 01:29:56 +0200 Subject: [PATCH 181/307] fix: avoid Access refused to xxx.xxx.xxx.xxx by SQL or Script injection protection in main.inc.php:analyseVarsForSqlAndScriptsInjection type=1 (#39188) Co-authored-by: Laurent Destailleur --- htdocs/comm/propal/card.php | 2 +- htdocs/commande/card.php | 2 +- htdocs/compta/facture/card.php | 2 +- htdocs/compta/facture/prelevement.php | 4 ++-- htdocs/contrat/agenda.php | 2 +- htdocs/contrat/card.php | 2 +- htdocs/contrat/document.php | 2 +- htdocs/contrat/messaging.php | 2 +- htdocs/fourn/commande/card.php | 2 +- htdocs/fourn/facture/card.php | 2 +- htdocs/fourn/facture/contact.php | 2 +- htdocs/fourn/facture/document.php | 2 +- htdocs/fourn/facture/info.php | 2 +- htdocs/fourn/facture/note.php | 2 +- htdocs/mrp/mo_agenda.php | 2 +- htdocs/mrp/mo_card.php | 2 +- htdocs/mrp/mo_document.php | 2 +- htdocs/mrp/mo_movements.php | 2 +- htdocs/mrp/mo_note.php | 2 +- htdocs/mrp/mo_production.php | 2 +- htdocs/product/stock/stocktransfer/stocktransfer_card.php | 2 +- htdocs/product/stock/stocktransfer/stocktransfer_contact.php | 2 +- htdocs/supplier_proposal/card.php | 2 +- 23 files changed, 24 insertions(+), 24 deletions(-) diff --git a/htdocs/comm/propal/card.php b/htdocs/comm/propal/card.php index b343affbd48..f2e8fab115d 100644 --- a/htdocs/comm/propal/card.php +++ b/htdocs/comm/propal/card.php @@ -2549,7 +2549,7 @@ if ($action == 'create') { // Thirdparty $morehtmlref .= '
'.$soc->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $soc->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherProposals").')'; + $morehtmlref .= ' ('.$langs->trans("OtherProposals").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/commande/card.php b/htdocs/commande/card.php index ed32b693aff..c46ea4b63d3 100644 --- a/htdocs/commande/card.php +++ b/htdocs/commande/card.php @@ -2479,7 +2479,7 @@ if ($action == 'create' && $usercancreate) { // Thirdparty $morehtmlref .= '
'.$soc->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/compta/facture/card.php b/htdocs/compta/facture/card.php index 333d2838723..c7f91cd1972 100644 --- a/htdocs/compta/facture/card.php +++ b/htdocs/compta/facture/card.php @@ -4658,7 +4658,7 @@ if ($action == 'create') { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherBills").')'; + $morehtmlref .= ' ('.$langs->trans("OtherBills").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/compta/facture/prelevement.php b/htdocs/compta/facture/prelevement.php index 65c33a88632..78f086792d5 100644 --- a/htdocs/compta/facture/prelevement.php +++ b/htdocs/compta/facture/prelevement.php @@ -377,11 +377,11 @@ if ($object->id > 0) { $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if ($type == 'bank-transfer') { if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherBills").')'; + $morehtmlref .= ' ('.$langs->trans("OtherBills").')'; } } else { if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherBills").')'; + $morehtmlref .= ' ('.$langs->trans("OtherBills").')'; } } // Project diff --git a/htdocs/contrat/agenda.php b/htdocs/contrat/agenda.php index 4a14558c0e9..aeafff41770 100644 --- a/htdocs/contrat/agenda.php +++ b/htdocs/contrat/agenda.php @@ -185,7 +185,7 @@ if ($object->id > 0) { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; + $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/contrat/card.php b/htdocs/contrat/card.php index 01363b6c8ea..dbeee468d1f 100644 --- a/htdocs/contrat/card.php +++ b/htdocs/contrat/card.php @@ -1412,7 +1412,7 @@ if ($action == 'create') { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; + $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/contrat/document.php b/htdocs/contrat/document.php index 28a1c9455e2..baa1498f635 100644 --- a/htdocs/contrat/document.php +++ b/htdocs/contrat/document.php @@ -144,7 +144,7 @@ if ($object->id) { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; + $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/contrat/messaging.php b/htdocs/contrat/messaging.php index 335146bfce3..7f451d5a4ba 100644 --- a/htdocs/contrat/messaging.php +++ b/htdocs/contrat/messaging.php @@ -182,7 +182,7 @@ $morehtmlref .= $form->editfieldval("RefSupplier", 'ref_supplier', $object->ref_ // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; + $morehtmlref .= ' ('.$langs->trans("OtherContracts").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/fourn/commande/card.php b/htdocs/fourn/commande/card.php index d568cc3b30b..a672de3dc76 100644 --- a/htdocs/fourn/commande/card.php +++ b/htdocs/fourn/commande/card.php @@ -2134,7 +2134,7 @@ if ($action == 'create') { } $morehtmlref .= $object->thirdparty->getNomUrl(1, 'supplier'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } } diff --git a/htdocs/fourn/facture/card.php b/htdocs/fourn/facture/card.php index a9a7d52913d..10e3f31a7b0 100644 --- a/htdocs/fourn/facture/card.php +++ b/htdocs/fourn/facture/card.php @@ -3245,7 +3245,7 @@ if ($action == 'create') { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1, 'supplier'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' '; + $morehtmlref .= ' '; } // Project if (isModEnabled('project')) { diff --git a/htdocs/fourn/facture/contact.php b/htdocs/fourn/facture/contact.php index 6eca82f4e11..722912d328a 100644 --- a/htdocs/fourn/facture/contact.php +++ b/htdocs/fourn/facture/contact.php @@ -150,7 +150,7 @@ if ($id > 0 || !empty($ref)) { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' '; + $morehtmlref .= ' '; } // Project if (isModEnabled('project')) { diff --git a/htdocs/fourn/facture/document.php b/htdocs/fourn/facture/document.php index 69841806aab..6abcf62a515 100644 --- a/htdocs/fourn/facture/document.php +++ b/htdocs/fourn/facture/document.php @@ -114,7 +114,7 @@ if ($object->id > 0) { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' '; + $morehtmlref .= ' '; } // Project if (isModEnabled('project')) { diff --git a/htdocs/fourn/facture/info.php b/htdocs/fourn/facture/info.php index 20dd24d7276..bb26712c967 100644 --- a/htdocs/fourn/facture/info.php +++ b/htdocs/fourn/facture/info.php @@ -80,7 +80,7 @@ $morehtmlref .= $form->editfieldval("RefSupplierBill", 'ref_supplier', $object-> // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' '; + $morehtmlref .= ' '; } // Project if (isModEnabled('project')) { diff --git a/htdocs/fourn/facture/note.php b/htdocs/fourn/facture/note.php index 41b2b1f4d34..7fec65689dc 100644 --- a/htdocs/fourn/facture/note.php +++ b/htdocs/fourn/facture/note.php @@ -110,7 +110,7 @@ if ($object->id > 0) { // Thirdparty $morehtmlref .= '
'.$object->thirdparty->getNomUrl(1); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' '; + $morehtmlref .= ' '; } // Project if (isModEnabled('project')) { diff --git a/htdocs/mrp/mo_agenda.php b/htdocs/mrp/mo_agenda.php index 0ff1e32c411..e658602c461 100644 --- a/htdocs/mrp/mo_agenda.php +++ b/htdocs/mrp/mo_agenda.php @@ -160,7 +160,7 @@ if ($object->id > 0) { if (is_object($object->thirdparty)) { $morehtmlref .= $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } } // Project diff --git a/htdocs/mrp/mo_card.php b/htdocs/mrp/mo_card.php index 7f1ccf1729e..53e1c454281 100644 --- a/htdocs/mrp/mo_card.php +++ b/htdocs/mrp/mo_card.php @@ -616,7 +616,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea if (is_object($object->thirdparty)) { $morehtmlref .= $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } } // Project diff --git a/htdocs/mrp/mo_document.php b/htdocs/mrp/mo_document.php index 6afe0f35686..48061ea4299 100644 --- a/htdocs/mrp/mo_document.php +++ b/htdocs/mrp/mo_document.php @@ -133,7 +133,7 @@ if ($object->id) { if (is_object($object->thirdparty)) { $morehtmlref .= $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } } // Project diff --git a/htdocs/mrp/mo_movements.php b/htdocs/mrp/mo_movements.php index 923a28df591..0f87b1afa58 100644 --- a/htdocs/mrp/mo_movements.php +++ b/htdocs/mrp/mo_movements.php @@ -336,7 +336,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea if (is_object($object->thirdparty)) { $morehtmlref .= $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } } // Project diff --git a/htdocs/mrp/mo_note.php b/htdocs/mrp/mo_note.php index 9d24201da2e..b38459b0778 100644 --- a/htdocs/mrp/mo_note.php +++ b/htdocs/mrp/mo_note.php @@ -109,7 +109,7 @@ if ($id > 0 || !empty($ref)) { if (is_object($object->thirdparty)) { $morehtmlref .= $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } } // Project diff --git a/htdocs/mrp/mo_production.php b/htdocs/mrp/mo_production.php index 073dacf2a61..6e021ecbfd0 100644 --- a/htdocs/mrp/mo_production.php +++ b/htdocs/mrp/mo_production.php @@ -637,7 +637,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea if (is_object($object->thirdparty)) { $morehtmlref .= $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } } diff --git a/htdocs/product/stock/stocktransfer/stocktransfer_card.php b/htdocs/product/stock/stocktransfer/stocktransfer_card.php index 9456b6d1597..ff2906e25e8 100644 --- a/htdocs/product/stock/stocktransfer/stocktransfer_card.php +++ b/htdocs/product/stock/stocktransfer/stocktransfer_card.php @@ -625,7 +625,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea // Thirdparty $morehtmlref .= empty($object->thirdparty) ? '' : $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalInt('MAIN_DISABLE_OTHER_LINK') && !empty($object->thirdparty) && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/product/stock/stocktransfer/stocktransfer_contact.php b/htdocs/product/stock/stocktransfer/stocktransfer_contact.php index 8f599eb71de..448f2944806 100644 --- a/htdocs/product/stock/stocktransfer/stocktransfer_contact.php +++ b/htdocs/product/stock/stocktransfer/stocktransfer_contact.php @@ -132,7 +132,7 @@ if ($object->id > 0) { // Thirdparty $morehtmlref .= empty($object->thirdparty) ? '' : $object->thirdparty->getNomUrl(1, 'customer'); if (!getDolGlobalInt('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; + $morehtmlref .= ' ('.$langs->trans("OtherOrders").')'; } // Project if (isModEnabled('project')) { diff --git a/htdocs/supplier_proposal/card.php b/htdocs/supplier_proposal/card.php index 9965572677e..e80d45fa0f0 100644 --- a/htdocs/supplier_proposal/card.php +++ b/htdocs/supplier_proposal/card.php @@ -1608,7 +1608,7 @@ if ($action == 'create') { // Thirdparty $morehtmlref .= $object->thirdparty->getNomUrl(1, 'supplier'); if (!getDolGlobalString('MAIN_DISABLE_OTHER_LINK') && $object->thirdparty->id > 0) { - $morehtmlref .= ' ('.$langs->trans("OtherProposals").')'; + $morehtmlref .= ' ('.$langs->trans("OtherProposals").')'; } // Project if (isModEnabled('project')) { From d4fc52b1826a0d33231e23587acb32d9366b71bd Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 01:39:10 +0200 Subject: [PATCH 182/307] version --- htdocs/blockedlog/versionmod.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/blockedlog/versionmod.inc.php b/htdocs/blockedlog/versionmod.inc.php index 62aeb3645a5..811c007acda 100644 --- a/htdocs/blockedlog/versionmod.inc.php +++ b/htdocs/blockedlog/versionmod.inc.php @@ -24,7 +24,7 @@ // The version of the POS system (Immutable Log system) // Can be 3.0.0-beta when not yet certified // 3.0.0 once certified -define('DOLCERT_VERSION', '2.5.0'); +define('DOLCERT_VERSION', '3.0.0'); // 1 was used for beta version candidate for certification, or for stable version that has been certified. From 5350af22ba15a19f605b81cce64b8e2ad1173903 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 01:42:19 +0200 Subject: [PATCH 183/307] Trans --- htdocs/langs/en_US/blockedlog.lang | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/langs/en_US/blockedlog.lang b/htdocs/langs/en_US/blockedlog.lang index 68778a6daae..e0eb8f1edcc 100644 --- a/htdocs/langs/en_US/blockedlog.lang +++ b/htdocs/langs/en_US/blockedlog.lang @@ -64,7 +64,7 @@ LNECertifiedPOSSystem=LNE Certified POS system LNECandidatePOSSystem=POS system candidate for the LNE certification LNECertifiedVersionFR=The version %s of module Immutable Logs is a certified version. More information on https://www.dolibarr.org/certifications.php LNECandidateVersionForCertificationFR=The version %s of module Immutable Logs is a candidate version to become a LNE certified version. Not yet certified. -NotCertifiedVersionFR=This version %s of application is technically ready to be compliant with the french VAT anti-fraud law (check any update of the law of your country). If you collect payments from private individuals and you are elligible to VAT, you can't use this software without a compliance statement ("French attestation"). So You MUST ask your reseller, integrator or hosting company to obtain this statement of compliance (Note: A list of companies that provide the statement for their customers may be available on https://partners.dolibarr.org). +NotCertifiedVersionFR=This version %s of the immutable log module is technically ready to be compliant with the french VAT anti-fraud law (check any update of the law of your country). However, if you collect payments from private individuals and you are elligible to VAT, you can't use this software without a compliance statement ("French attestation"). So You MUST ask your reseller, integrator or hosting company to obtain this statement of compliance (Note: A list of companies that provide the statement for their customers may be available on https://partners.dolibarr.org. InvoiceGeneratedWithLNECertifiedPOSSystem=Invoice generated with a LNE certified POS system InvoiceGeneratedWithLNECandidatePOSSystem=Invoice generated with a candidate for the LNE certified POS system TotalTTCIfInvoiceSeeCompleteDataForDetail=Event amount. Total including tax if invoice. See the column "complete data" for details. From 4be9257efaa12635d5f7b63bb83bcb71a0be9e1a Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Thu, 16 Jul 2026 09:07:23 +0900 Subject: [PATCH 184/307] FIX: OpenID server discovery passes the whole HTTP response array as HTML (#39156) htdocs/core/class/openid.class.php OpenId::GetOpenIDServer() calls: $response = getURLContent(...); // array{http_code,content,...} list($servers, $delegates) = $this->HTML2OpenIDServer($response); but HTML2OpenIDServer($content) runs preg_match_all(..., $content), i.e. it expects the HTML string, not the whole getURLContent() array. On PHP 8 this throws a TypeError (preg_match_all subject must be string), breaking OpenID server discovery; on PHP 7 it silently matches nothing. Pass $response['content']. PHPStan level 10 flagged: Parameter #1 $content of method SimpleOpenID::HTML2OpenIDServer() expects string, array given. Co-authored-by: Laurent Destailleur --- htdocs/core/class/openid.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/class/openid.class.php b/htdocs/core/class/openid.class.php index 9a0e91c48a7..5f04f6a9a19 100644 --- a/htdocs/core/class/openid.class.php +++ b/htdocs/core/class/openid.class.php @@ -388,7 +388,7 @@ class SimpleOpenID $response = getURLContent($url, 'GET', '', 1, array(), array('http', 'https')); - list($servers, $delegates) = $this->HTML2OpenIDServer($response); + list($servers, $delegates) = $this->HTML2OpenIDServer($response['content']); if (count($servers) == 0) { $this->ErrorStore('OPENID_NOSERVERSFOUND'); return false; From 3058208201e6fcc80a2468d564f0a041c3a3b568 Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Thu, 16 Jul 2026 09:23:26 +0900 Subject: [PATCH 185/307] FIX: PayPal API calls set CURLOPT_SSL_VERIFYHOST to a bool instead of 2 (#39155) htdocs/paypal/lib/paypal.lib.php passed a boolean to CURLOPT_SSL_VERIFYHOST: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, ($ssl_verifypeer ? true : false)); CURLOPT_SSL_VERIFYHOST expects 0, 1 or 2 (not a bool). 'true' is cast to int 1, which only checks that a Common Name is present, not that it matches the host (value 1 is also deprecated / a no-op on some libcurl builds) - weakening TLS host verification on PayPal API calls. Use 2 (full host check) / 0. PHPStan level 10 flagged: Parameter #3 $value of function curl_setopt expects 0|2, bool given. Co-authored-by: Laurent Destailleur --- htdocs/paypal/lib/paypal.lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/paypal/lib/paypal.lib.php b/htdocs/paypal/lib/paypal.lib.php index 180971463e5..d59ecae699b 100644 --- a/htdocs/paypal/lib/paypal.lib.php +++ b/htdocs/paypal/lib/paypal.lib.php @@ -501,7 +501,7 @@ function hash_call($methodName, $nvpStr) //turning off the server and peer verification(TrustManager Concept). curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, ($ssl_verifypeer ? true : false)); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, ($ssl_verifypeer ? true : false)); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, ($ssl_verifypeer ? 2 : 0)); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, getDolGlobalInt('MAIN_USE_CONNECT_TIMEOUT', 5)); curl_setopt($ch, CURLOPT_TIMEOUT, getDolGlobalInt('MAIN_USE_RESPONSE_TIMEOUT', 30)); From 591974e5d1322e3dfc3711f6310dff8dbe01f944 Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Thu, 16 Jul 2026 10:08:11 +0900 Subject: [PATCH 186/307] FIX: agenda day view passes usergroup filter as string instead of id array (#39157) In htdocs/comm/action/index.php the month and week views call print_actions_filter(..., $actioncode, $usergroupids, ...) with $usergroupids (array, built at the top from the comma-separated $usergroup), but the day view passed the raw $usergroup string instead. print_actions_filter() forwards this to select_dolgroups() which expects an array, so a multi-group filter (e.g. '3,5') is not preselected in the day view. Pass $usergroupids to match the two sibling calls. PHPStan level 10 flagged: Parameter #16 $usergroupid of function print_actions_filter expects array, string given. Co-authored-by: Laurent Destailleur --- htdocs/comm/action/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/comm/action/index.php b/htdocs/comm/action/index.php index 12f5e97cb8f..611858c94ad 100644 --- a/htdocs/comm/action/index.php +++ b/htdocs/comm/action/index.php @@ -1826,7 +1826,7 @@ if (empty($mode) || $mode == 'show_month') { // View by month $arraytimestamp = dol_getdate($timestamp); print '
'; - print_actions_filter($form, $canedit, $status, $year, $month, $day, $check_birthday, '', $filtert, '', $pid, $socid, $action, -1, $actioncode, $usergroup, '', $resourceid); + print_actions_filter($form, $canedit, $status, $year, $month, $day, $check_birthday, '', $filtert, '', $pid, $socid, $action, -1, $actioncode, $usergroupids, '', $resourceid); print '
'; print '
'; From 27d13fc880055d1c3ea4a03c75cdb349328497ba Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Thu, 16 Jul 2026 10:46:25 +0900 Subject: [PATCH 187/307] FIX: supplier payment document.php runs restrictedArea() on unloaded object (#39117) htdocs/fourn/paiement/document.php called restrictedArea($user, $object->element, $object->id, 'paiementfourn', '') before $object was instantiated (the 'Load object' block sat after the security check), so the permission check ran on an undefined $object (null element / null id) and PHPStan level 10 flagged 'Variable $object might not be defined'. The sibling pages fourn/paiement/card.php and info.php instantiate the PaiementFourn object before the same restrictedArea() call. Move the object load ahead of the security check so the check runs against the real object. --- htdocs/fourn/paiement/document.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/htdocs/fourn/paiement/document.php b/htdocs/fourn/paiement/document.php index b695e17a32a..676442f4eb9 100644 --- a/htdocs/fourn/paiement/document.php +++ b/htdocs/fourn/paiement/document.php @@ -63,6 +63,14 @@ $ref = GETPOST('ref', 'alpha'); $action = GETPOST('action', 'aZ09'); $confirm = GETPOST('confirm', 'alpha'); +// Load object +$object = new PaiementFourn($db); +$upload_dir = null; +if ($object->fetch($id, $ref)) { + $object->fetch_thirdparty(); + $ref = dol_sanitizeFileName($object->ref); + $upload_dir = $conf->fournisseur->payment->dir_output.'/'.dol_sanitizeFileName($object->ref); +} // Security check if ($user->isExternalUser()) { @@ -88,15 +96,6 @@ if (!$sortfield) { $sortfield = "name"; } -// Load object -$object = new PaiementFourn($db); -$upload_dir = null; -if ($object->fetch($id, $ref)) { - $object->fetch_thirdparty(); - $ref = dol_sanitizeFileName($object->ref); - $upload_dir = $conf->fournisseur->payment->dir_output.'/'.dol_sanitizeFileName($object->ref); -} - $permissiontoadd = ($user->hasRight("fournisseur", "facture", "creer") || $user->hasRight("supplier_invoice", "creer")); // Used by the include of actions_setnotes.inc.php From 5e156537079c16c1864758f4c87c42aec37952c1 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 04:11:02 +0200 Subject: [PATCH 188/307] Fix default value system for checkbox "Show variant" on product list. --- htdocs/core/lib/functions.lib.php | 10 ++++++---- htdocs/product/list.php | 14 +++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/htdocs/core/lib/functions.lib.php b/htdocs/core/lib/functions.lib.php index 78c23d39b3b..101f1537cc3 100644 --- a/htdocs/core/lib/functions.lib.php +++ b/htdocs/core/lib/functions.lib.php @@ -948,11 +948,12 @@ function GETPOSTISARRAY($paramname, $method = 0) * * @param string $paramname Name of the $_GET or $_POST parameter * @param int<0,3> $method Type of method (0 = $_GET then $_POST, 1 = only $_GET, 2 = only $_POST, 3 = $_POST then $_GET) + * @param int $nodefault Force disable of default values set from the admin setup page * @return int Value converted into integer */ -function GETPOSTINT($paramname, $method = 0) +function GETPOSTINT($paramname, $method = 0, $nodefault = 0) { - return (int) GETPOST($paramname, 'int', $method, null, null, 0); + return (int) GETPOST($paramname, 'int', $method, null, null, 0, $nodefault); } /** @@ -1073,6 +1074,7 @@ function GETPOSTDATE($prefix, $hourTime = '', $gm = 'auto', $saverestore = '') * @param ?int $filter Filter to apply when $check is set to 'custom'. (See http://php.net/manual/en/filter.filters.php for details) * @param mixed $options Options to pass to filter_var when $check is set to 'custom' * @param int $noreplace Force disable of replacement of __xxx__ strings. + * @param int $nodefault Force disable of default values set from the admin setup page * @return string|array Value found (string or array), or '' if check fails * @phpstan-return ( * $check is 'int' ? numeric-string|'' : @@ -1081,7 +1083,7 @@ function GETPOSTDATE($prefix, $hourTime = '', $gm = 'auto', $saverestore = '') * $check is 'alpha' | 'aZ' | 'aZ09' | 'aZ09arobase' | 'aZ09comma' | 'password' | 'email' | 'url' | 'alphanohtml' |'nohtml' | 'restricthtml' | 'alphawithlgt' | 'intcomma' | 'restricthtmlallowclass' | 'restricthtmlallowunvalid' | 'restricthtmlallowiframe' | 'restricthtmlallowlinkscript' ? string : string|array * ) */ -function GETPOST($paramname, $check = 'alphanohtml', $method = 0, $filter = null, $options = null, $noreplace = 0) +function GETPOST($paramname, $check = 'alphanohtml', $method = 0, $filter = null, $options = null, $noreplace = 0, $nodefault = 0) { global $langs, $mysoc, $user, $conf; @@ -1188,7 +1190,7 @@ function GETPOST($paramname, $check = 'alphanohtml', $method = 0, $filter = null } } } - } elseif (!empty($paramname) && !isset($_GET[$paramname]) && !isset($_POST[$paramname])) { + } elseif (!empty($paramname) && !isset($_GET[$paramname]) && !isset($_POST[$paramname]) && empty($nodefault)) { // Management of default search_filters and sort order if (!empty($user->default_values)) { // $user->default_values defined from menu 'Setup - Default values' diff --git a/htdocs/product/list.php b/htdocs/product/list.php index 11198f791d7..c4ec0261ea3 100644 --- a/htdocs/product/list.php +++ b/htdocs/product/list.php @@ -146,12 +146,12 @@ $type = GETPOST("type", 'alpha'); // Show/hide child product variants $show_childproducts = 0; if (isModEnabled('variants')) { - // An HTML checkbox does not submit anything when unchecked, so a hidden - // "search_show_childproducts=0" companion field is emitted next to the - // checkbox (see below). GETPOSTISSET() then let's us distinguish a - // resubmission with the checkbox off from a request that does not carry - // the filter at all, and the value is read as a strict 0/1 flag. - if (GETPOSTISSET('search_show_childproducts')) { + // An HTML checkbox does not submit anything when unchecked, so a hidden "search_show_childproducts_flag=1" companion field is emitted next to the + // checkbox (see below). GETPOSTISSET() then let's us distinguish a resubmission with the checkbox off from a request that does not carry + // the filter at all, and the value is read as a strict 0/1 flag taking account of possible default values or not. + if (GETPOSTISSET('search_show_childproducts_flag')) { + $show_childproducts = GETPOSTINT('search_show_childproducts', 0, 1); + } else { $show_childproducts = GETPOSTINT('search_show_childproducts'); } } @@ -1092,7 +1092,7 @@ if (isModEnabled('variants')) { // Companion hidden field ensures search_show_childproducts is always // posted, even when the checkbox below is unchecked, so the unchecked // state survives the filter/pagination submit and is parsed as 0/1. - $moreforfilter .= ''; + $moreforfilter .= ''; $moreforfilter .= ''; $moreforfilter .= ' '; $moreforfilter .= '
'; From 3dd16ec3c2477be6748a7e7ab3a6213fdcb9fd2c Mon Sep 17 00:00:00 2001 From: nikube <63295277+nikube@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:18:11 +0200 Subject: [PATCH 189/307] FIX Mass action "Reverse movement" bypasses the MAIN_LIMIT_FOR_MASS_ACTIONS limit (#39091) * FIX Mass reverse of stock movements bypasses the MAIN_LIMIT_FOR_MASS_ACTIONS limit The confirm_reverse handler did not check the error raised by the generic mass action limit control (TooManyRecordForMassAction), so selecting more records than the limit still executed the reverse after the error message was displayed. The reverse was also triggered when relaunching a search while the confirmation form was open, because the protection only reset $massaction, not $action. * FIX phan: initialize $error at the start of the confirm_reverse handler The new !$error guard lets phan narrow $error to falsy types inside the block, so the $error++ in the query-error branch was reported as PhanTypeInvalidUnaryOperandIncOrDec. Initialize $error to 0 at the start of the handler (and drop the redundant init inside the loop setup where present). * FIX coding rule: comment on the $action test that only cancels the pending action CodingPhpTest requires a permission check or an explicit comment on every test of $action in the Actions section. --------- Co-authored-by: Nicolas Zaou --- htdocs/product/stock/movement_list.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/htdocs/product/stock/movement_list.php b/htdocs/product/stock/movement_list.php index a0972cf4665..2c2b12c3713 100644 --- a/htdocs/product/stock/movement_list.php +++ b/htdocs/product/stock/movement_list.php @@ -250,6 +250,9 @@ if (empty($reshook)) { if (GETPOST('button_removefilter_x', 'alpha') || GETPOST('button_removefilter.x', 'alpha') || GETPOST('button_removefilter', 'alpha') || GETPOST('button_search_x', 'alpha') || GETPOST('button_search.x', 'alpha') || GETPOST('button_search', 'alpha')) { $massaction = ''; // Protection to avoid mass action if we force a new search during a mass action confirmation + if ($action == 'confirm_reverse') { // Test on permission not required here, we only cancel a pending action + $action = 'list'; // Protection to avoid the reverse if we force a new search during the reverse confirmation + } } // Mass actions @@ -616,8 +619,9 @@ if ($action == "transfert_stock" && $permissiontoadd && !$cancel) { } // reverse movement of stock -if ($action == 'confirm_reverse' && $confirm == "yes" && $permissiontoadd) { +if (!$error && $action == 'confirm_reverse' && $confirm == "yes" && $permissiontoadd) { $toselect = array_map('intval', $toselect); + $error = 0; $db->begin(); @@ -629,7 +633,6 @@ if ($action == 'confirm_reverse' && $confirm == "yes" && $permissiontoadd) { if ($resql) { $num = $db->num_rows($resql); $i = 0; - $error =0; while ($i < $num) { $obj = $db->fetch_object($resql); @@ -1146,7 +1149,7 @@ $modelmail = "movementstock"; $objecttmp = new MouvementStock($db); $trackid = 'mov'.$warehouse->id; include DOL_DOCUMENT_ROOT.'/core/tpl/massactions_pre.tpl.php'; -if ($massaction == 'prereverse') { +if ($massaction == 'prereverse' && count($toselect) <= getDolGlobalInt('MAIN_LIMIT_FOR_MASS_ACTIONS', 1000)) { print $form->formconfirm($_SERVER["PHP_SELF"], $langs->trans("ConfirmMassReverse"), $langs->trans("ConfirmMassReverseQuestion", count($toselect)), "confirm_reverse", null, '', 0, 200, 500, 1, 'Yes'); } From 8a246db393fb99dd9324af975fb5728d595903b0 Mon Sep 17 00:00:00 2001 From: Zakaria Boushaba <48571684+Boushabazakaria@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:27:35 +0100 Subject: [PATCH 190/307] FIX Wrong margin calculation on situation invoices due to cumulative situation_percent (#39090) * FIX: Margin calculation on situation invoices uses cumulative percent instead of delta, causing inflated amounts across all margin views * Fix * use the 'as' between table name and alias name --------- Co-authored-by: Zakaria Boushaba Co-authored-by: Laurent Destailleur --- htdocs/accountancy/customer/index.php | 11 +++++---- htdocs/core/class/html.formmargin.class.php | 10 +++++++-- htdocs/margin/agentMargins.php | 22 +++++++++++++----- htdocs/margin/customerMargins.php | 22 +++++++++++++----- htdocs/margin/productMargins.php | 25 ++++++++++++++++----- htdocs/margin/tabs/productMargins.php | 21 +++++++++++++---- htdocs/margin/tabs/thirdpartyMargins.php | 16 ++++++++++--- 7 files changed, 98 insertions(+), 29 deletions(-) diff --git a/htdocs/accountancy/customer/index.php b/htdocs/accountancy/customer/index.php index 2abd0fb89dd..e7f91c6efb2 100644 --- a/htdocs/accountancy/customer/index.php +++ b/htdocs/accountancy/customer/index.php @@ -604,6 +604,9 @@ if (getDolGlobalString('SHOW_TOTAL_OF_PREVIOUS_LISTS_IN_LIN_PAGE')) { // This pa $sql .= " SUM(fd.total_ht) as total"; $sql .= " FROM ".MAIN_DB_PREFIX."facturedet as fd"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture as f ON f.rowid = fd.fk_facture"; + if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facturedet AS prev_fd ON prev_fd.rowid = fd.fk_prev_id"; + } $sql .= " WHERE f.datef >= '".$db->idate($search_date_start)."'"; $sql .= " AND f.datef <= '".$db->idate($search_date_end)."'"; // Define begin binding date @@ -665,16 +668,16 @@ if (getDolGlobalString('SHOW_TOTAL_OF_PREVIOUS_LISTS_IN_LIN_PAGE')) { // This pa "MONTH(f.datef) = ".((int) $j), " (".$db->ifsql( "fd.total_ht < 0", - " (-1 * (abs(fd.total_ht) - (fd.buy_price_ht * fd.qty * (fd.situation_percent / 100))))", // TODO This is bugged, we must use the percent for the invoice and fd.situation_percent is cumulated percent ! - " (fd.total_ht - (fd.buy_price_ht * fd.qty * (fd.situation_percent / 100)))" + " (-1 * (abs(fd.total_ht * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / fd.situation_percent)) - (fd.buy_price_ht * fd.qty * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / 100))))", + " (fd.total_ht * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / fd.situation_percent) - (fd.buy_price_ht * fd.qty * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / 100)))" ).")", '0' ).") AS month".str_pad((string) $j, 2, '0', STR_PAD_LEFT).","; } $sql .= " SUM(".$db->ifsql( "fd.total_ht < 0", - " (-1 * (abs(fd.total_ht) - (fd.buy_price_ht * fd.qty * (fd.situation_percent / 100))))", // TODO This is bugged, we must use the percent for the invoice and fd.situation_percent is cumulated percent ! - " (fd.total_ht - (fd.buy_price_ht * fd.qty * (fd.situation_percent / 100)))" + " (-1 * (abs(fd.total_ht * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / fd.situation_percent)) - (fd.buy_price_ht * fd.qty * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / 100))))", + " (fd.total_ht * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / fd.situation_percent) - (fd.buy_price_ht * fd.qty * ((fd.situation_percent - COALESCE(prev_fd.situation_percent, 0)) / 100)))" ).") as total"; } else { $sql = "SELECT '".$db->escape($langs->trans("Vide"))."' AS marge,"; diff --git a/htdocs/core/class/html.formmargin.class.php b/htdocs/core/class/html.formmargin.class.php index b4530c893de..ab05efe02a1 100644 --- a/htdocs/core/class/html.formmargin.class.php +++ b/htdocs/core/class/html.formmargin.class.php @@ -112,8 +112,14 @@ class FormMargin /** @var Facture $object */ if (($object->element == 'facture' && $object->type == $object::TYPE_SITUATION) || ($object->element == 'facture' && $object->type == $object::TYPE_CREDIT_NOTE && getDolGlobalInt('INVOICE_USE_SITUATION_CREDIT_NOTE') && $object->situation_counter > 0)) { - // We need a compensation relative to $line->situation_percent - $pa = $line->qty * $pa_ht * ($line->situation_percent / 100); + // We need only the delta between this situation and the previous one to avoid cumulating margins across situation invoices + // total_ht is stored cumulatively (e.g. 60% of full price), so we extract only this invoice's share using the delta percent + $prevPercent = $line->get_prev_progress($object->id); + $deltaPercent = $line->situation_percent - $prevPercent; + if ($line->situation_percent > 0) { + $pv *= ($deltaPercent / $line->situation_percent); + } + $pa = $line->qty * $pa_ht * ($deltaPercent / 100); } else { $pa = $line->qty * $pa_ht; } diff --git a/htdocs/margin/agentMargins.php b/htdocs/margin/agentMargins.php index 7c9fcc45315..e7de6fdff81 100644 --- a/htdocs/margin/agentMargins.php +++ b/htdocs/margin/agentMargins.php @@ -160,16 +160,28 @@ $invoice_status_except_list = array(Facture::STATUS_DRAFT, Facture::STATUS_ABAND $sql = "SELECT"; $sql .= " s.rowid as socid, s.nom as name, s.code_client, s.client,"; $sql .= " u.rowid as agent, u.login, u.lastname, u.firstname,"; -$sql .= " sum(d.total_ht) as selling_price,"; -// Note: qty and buy_price_ht is always positive (if not your database may be corrupted, you can update this) - -$sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * d.qty * d.buy_price_ht * (d.situation_percent / 100)', 'd.qty * d.buy_price_ht * (d.situation_percent / 100)').") as buying_price,"; -$sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * (abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100)))', 'd.total_ht - (d.buy_price_ht * d.qty * (d.situation_percent / 100))').") as marge"; +// Special case for old situation mode: total_ht is stored cumulatively, use delta percent to avoid cumulating margins +if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $delta_pct = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN (d.situation_percent - COALESCE(prev_d.situation_percent, 0)) ELSE d.situation_percent END'; + $delta_ht = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN d.total_ht * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / d.situation_percent) ELSE d.total_ht END'; + $sql .= " sum($delta_ht) as selling_price,"; + // Note: qty and buy_price_ht is always positive (if not your database may be corrupted, you can update this) + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', "-1 * d.qty * d.buy_price_ht * ($delta_pct / 100)", "d.qty * d.buy_price_ht * ($delta_pct / 100)").") as buying_price,"; + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', "-1 * (abs($delta_ht) - (d.buy_price_ht * d.qty * ($delta_pct / 100)))", "$delta_ht - (d.buy_price_ht * d.qty * ($delta_pct / 100))").") as marge"; +} else { + $sql .= " sum(d.total_ht) as selling_price,"; + // Note: qty and buy_price_ht is always positive (if not your database may be corrupted, you can update this) + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * d.qty * d.buy_price_ht * (d.situation_percent / 100)', 'd.qty * d.buy_price_ht * (d.situation_percent / 100)').") as buying_price,"; + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * (abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100)))', 'd.total_ht - (d.buy_price_ht * d.qty * (d.situation_percent / 100))').") as marge"; +} $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; $sql .= ", ".MAIN_DB_PREFIX."facture as f"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."element_contact e ON e.element_id = f.rowid and e.statut = 4 and e.fk_c_type_contact = ".(!getDolGlobalString('AGENT_CONTACT_TYPE') ? -1 : $conf->global->AGENT_CONTACT_TYPE); $sql .= ", ".MAIN_DB_PREFIX."facturedet as d"; +if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facturedet AS prev_d ON prev_d.rowid = d.fk_prev_id"; +} $sql .= ", ".MAIN_DB_PREFIX."societe_commerciaux as sc"; $sql .= ", ".MAIN_DB_PREFIX."user as u"; $sql .= " WHERE f.fk_soc = s.rowid"; diff --git a/htdocs/margin/customerMargins.php b/htdocs/margin/customerMargins.php index 10cfcb27e4e..455d3afde66 100644 --- a/htdocs/margin/customerMargins.php +++ b/htdocs/margin/customerMargins.php @@ -228,15 +228,27 @@ $sql .= " s.rowid as socid, s.nom as name, s.code_client, s.client,"; if ($client) { $sql .= " f.rowid as facid, f.ref, f.total_ht, f.datef, f.paye, f.type, f.fk_statut as statut,"; } -$sql .= " sum(d.total_ht) as selling_price,"; -// Note: qty and buy_price_ht is always positive (if not, your database may be corrupted, you can update this) - -$sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * d.qty * d.buy_price_ht * (d.situation_percent / 100)', 'd.qty * d.buy_price_ht * (d.situation_percent / 100)').") as buying_price,"; -$sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * (abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100)))', 'd.total_ht - (d.buy_price_ht * d.qty * (d.situation_percent / 100))').") as marge"; +// Special case for old situation mode: total_ht is stored cumulatively, use delta percent to avoid cumulating margins +if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $delta_pct = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN (d.situation_percent - COALESCE(prev_d.situation_percent, 0)) ELSE d.situation_percent END'; + $delta_ht = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN d.total_ht * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / d.situation_percent) ELSE d.total_ht END'; + $sql .= " sum($delta_ht) as selling_price,"; + // Note: qty and buy_price_ht is always positive (if not, your database may be corrupted, you can update this) + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', "-1 * d.qty * d.buy_price_ht * ($delta_pct / 100)", "d.qty * d.buy_price_ht * ($delta_pct / 100)").") as buying_price,"; + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', "-1 * (abs($delta_ht) - (d.buy_price_ht * d.qty * ($delta_pct / 100)))", "$delta_ht - (d.buy_price_ht * d.qty * ($delta_pct / 100))").") as marge"; +} else { + $sql .= " sum(d.total_ht) as selling_price,"; + // Note: qty and buy_price_ht is always positive (if not, your database may be corrupted, you can update this) + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * d.qty * d.buy_price_ht * (d.situation_percent / 100)', 'd.qty * d.buy_price_ht * (d.situation_percent / 100)').") as buying_price,"; + $sql .= " sum(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * (abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100)))', 'd.total_ht - (d.buy_price_ht * d.qty * (d.situation_percent / 100))').") as marge"; +} $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; $sql .= ", ".MAIN_DB_PREFIX."facture as f"; $sql .= ", ".MAIN_DB_PREFIX."facturedet as d"; +if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facturedet AS prev_d ON prev_d.rowid = d.fk_prev_id"; +} if (!empty($TSelectedCats)) { $sql .= ' LEFT JOIN '.MAIN_DB_PREFIX.'categorie_product as cp ON cp.fk_product=d.fk_product'; } diff --git a/htdocs/margin/productMargins.php b/htdocs/margin/productMargins.php index 38aab2d3049..0acf79b3057 100644 --- a/htdocs/margin/productMargins.php +++ b/htdocs/margin/productMargins.php @@ -194,17 +194,30 @@ if ($id > 0) { if ($id > 0) { $sql .= " f.rowid as facid, f.ref, f.total_ht, f.datef, f.paye, f.fk_statut as statut,"; } -$sql .= " SUM(d.total_ht) as selling_price,"; -$sql .= " SUM(d.qty) as product_qty,"; - -// Note: qty and buy_price_ht is always positive (if not your database may be corrupted, you can update this) -$sql .= " SUM(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * d.qty * d.buy_price_ht * (d.situation_percent / 100)', 'd.qty * d.buy_price_ht * (d.situation_percent / 100)').") as buying_price,"; -$sql .= " SUM(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * (abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100)))', 'd.total_ht - (d.buy_price_ht * d.qty * (d.situation_percent / 100))').") as marge"; +// Special case for old situation mode: total_ht is stored cumulatively, use delta percent to avoid cumulating margins +if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $delta_pct = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN (d.situation_percent - COALESCE(prev_d.situation_percent, 0)) ELSE d.situation_percent END'; + $delta_ht = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN d.total_ht * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / d.situation_percent) ELSE d.total_ht END'; + $sql .= " SUM($delta_ht) as selling_price,"; + $sql .= " SUM(d.qty) as product_qty,"; + // Note: qty and buy_price_ht is always positive (if not your database may be corrupted, you can update this) + $sql .= " SUM(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', "-1 * d.qty * d.buy_price_ht * ($delta_pct / 100)", "d.qty * d.buy_price_ht * ($delta_pct / 100)").") as buying_price,"; + $sql .= " SUM(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', "-1 * (abs($delta_ht) - (d.buy_price_ht * d.qty * ($delta_pct / 100)))", "$delta_ht - (d.buy_price_ht * d.qty * ($delta_pct / 100))").") as marge"; +} else { + $sql .= " SUM(d.total_ht) as selling_price,"; + $sql .= " SUM(d.qty) as product_qty,"; + // Note: qty and buy_price_ht is always positive (if not your database may be corrupted, you can update this) + $sql .= " SUM(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * d.qty * d.buy_price_ht * (d.situation_percent / 100)', 'd.qty * d.buy_price_ht * (d.situation_percent / 100)').") as buying_price,"; + $sql .= " SUM(".$db->ifsql('(d.total_ht < 0 OR (d.total_ht = 0 AND f.type = 2))', '-1 * (abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100)))', 'd.total_ht - (d.buy_price_ht * d.qty * (d.situation_percent / 100))').") as marge"; +} $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; $sql .= ", ".MAIN_DB_PREFIX."facture as f"; $sql .= ", ".MAIN_DB_PREFIX."facturedet as d"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = d.fk_product"; +if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facturedet AS prev_d ON prev_d.rowid = d.fk_prev_id"; +} if (!empty($TSelectedCats)) { $sql .= ' LEFT JOIN '.MAIN_DB_PREFIX.'categorie_product as cp ON cp.fk_product=p.rowid'; } diff --git a/htdocs/margin/tabs/productMargins.php b/htdocs/margin/tabs/productMargins.php index 838ff109178..392f7c4ef3e 100644 --- a/htdocs/margin/tabs/productMargins.php +++ b/htdocs/margin/tabs/productMargins.php @@ -213,13 +213,26 @@ if ($id > 0 || !empty($ref)) { if (!$user->hasRight('societe', 'client', 'voir')) { $sql .= " sc.fk_soc, sc.fk_user,"; } - $sql .= " sum(d.total_ht) as selling_price,"; // may be negative or positive - $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(d.qty) as qty,"; // not always positive in case of Credit note - $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(d.qty * d.buy_price_ht * (d.situation_percent / 100)) as buying_price,"; // not always positive in case of Credit note - $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100))) as marge"; // not always positive in case of Credit note + // Special case for old situation mode: total_ht is stored cumulatively, use delta percent to avoid cumulating margins + if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $delta_pct = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN (d.situation_percent - COALESCE(prev_d.situation_percent, 0)) ELSE d.situation_percent END'; + $delta_ht = 'CASE WHEN f.type = '.Facture::TYPE_SITUATION.' AND d.situation_percent > 0 THEN d.total_ht * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / d.situation_percent) ELSE d.total_ht END'; + $sql .= " sum($delta_ht) as selling_price,"; // may be negative or positive + $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(d.qty) as qty,"; // not always positive in case of Credit note + $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(d.qty * d.buy_price_ht * ($delta_pct / 100)) as buying_price,"; // not always positive in case of Credit note + $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(abs($delta_ht) - (d.buy_price_ht * d.qty * ($delta_pct / 100))) as marge"; // not always positive in case of Credit note + } else { + $sql .= " sum(d.total_ht) as selling_price,"; // may be negative or positive + $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(d.qty) as qty,"; // not always positive in case of Credit note + $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(d.qty * d.buy_price_ht * (d.situation_percent / 100)) as buying_price,"; // not always positive in case of Credit note + $sql .= " ".$db->ifsql('f.type = 2', '-1', '1')." * sum(abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100))) as marge"; // not always positive in case of Credit note + } $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; $sql .= ", ".MAIN_DB_PREFIX."facture as f"; $sql .= ", ".MAIN_DB_PREFIX."facturedet as d"; + if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facturedet AS prev_d ON prev_d.rowid = d.fk_prev_id"; + } if (!$user->hasRight('societe', 'client', 'voir')) { $sql .= ", ".MAIN_DB_PREFIX."societe_commerciaux as sc"; } diff --git a/htdocs/margin/tabs/thirdpartyMargins.php b/htdocs/margin/tabs/thirdpartyMargins.php index fa09a02fe42..eb65fa53e17 100644 --- a/htdocs/margin/tabs/thirdpartyMargins.php +++ b/htdocs/margin/tabs/thirdpartyMargins.php @@ -244,12 +244,22 @@ if ($socid > 0) { $sql = "SELECT s.nom, s.rowid as socid, s.code_client,"; $sql .= " f.rowid as facid, f.ref, f.total_ht,"; $sql .= " f.datef, f.paye, f.fk_statut as statut, f.type,"; - $sql .= " sum(d.total_ht) as selling_price,"; // may be negative or positive - $sql .= " sum(d.qty * d.buy_price_ht * (d.situation_percent / 100)) as buying_price,"; // always positive - $sql .= " sum(abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100))) as marge"; // always positive + // Special case for old situation mode: total_ht is stored cumulatively, use delta percent to avoid cumulating margins + if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $sql .= " sum(CASE WHEN f.type = ".Facture::TYPE_SITUATION." AND d.situation_percent > 0 THEN d.total_ht * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / d.situation_percent) ELSE d.total_ht END) as selling_price,"; // may be negative or positive + $sql .= " sum(CASE WHEN f.type = ".Facture::TYPE_SITUATION." AND d.situation_percent > 0 THEN d.qty * d.buy_price_ht * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / 100) ELSE d.qty * d.buy_price_ht * (d.situation_percent / 100) END) as buying_price,"; // always positive + $sql .= " sum(CASE WHEN f.type = ".Facture::TYPE_SITUATION." AND d.situation_percent > 0 THEN (d.total_ht * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / d.situation_percent)) - (d.buy_price_ht * d.qty * ((d.situation_percent - COALESCE(prev_d.situation_percent, 0)) / 100)) ELSE abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100)) END) as marge"; // always positive + } else { + $sql .= " sum(d.total_ht) as selling_price,"; // may be negative or positive + $sql .= " sum(d.qty * d.buy_price_ht * (d.situation_percent / 100)) as buying_price,"; // always positive + $sql .= " sum(abs(d.total_ht) - (d.buy_price_ht * d.qty * (d.situation_percent / 100))) as marge"; // always positive + } $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; $sql .= ", ".MAIN_DB_PREFIX."facture as f"; $sql .= ", ".MAIN_DB_PREFIX."facturedet as d"; + if (getDolGlobalInt('INVOICE_USE_SITUATION') == 1) { + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facturedet AS prev_d ON prev_d.rowid = d.fk_prev_id"; + } $sql .= " WHERE f.fk_soc = s.rowid"; $sql .= " AND f.fk_statut > 0"; $sql .= " AND f.entity IN (".getEntity('invoice').")"; From 8b7dab1c2d3b9024242656b006033fa825eb8767 Mon Sep 17 00:00:00 2001 From: MDW Date: Thu, 16 Jul 2026 11:29:42 +0200 Subject: [PATCH 191/307] Qual: Fix PHPStan for 23.0 + pre-commit backports (#39192) --- .github/scripts/get_changed_php.sh | 60 ++++++++++++++++++++++++++---- .github/workflows/phpstan.yml | 3 +- .pre-commit-config.yaml | 38 ++++++++++--------- pyproject.toml | 4 +- 4 files changed, 77 insertions(+), 28 deletions(-) diff --git a/.github/scripts/get_changed_php.sh b/.github/scripts/get_changed_php.sh index 60d01874f35..43d64101c74 100755 --- a/.github/scripts/get_changed_php.sh +++ b/.github/scripts/get_changed_php.sh @@ -1,5 +1,7 @@ #!/bin/bash -# Copyright (C) 2025 MDW +# Copyright (C) 2025-2026 MDW + +# shellcheck disable=2129,2128,2034,2016 set -euo pipefail @@ -20,11 +22,11 @@ if [[ -z "${GITHUB_TOKEN:-}" ]]; then fi if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then echo "GITHUB_REPOSITORY is not set" >&2 - exit 1 + exit 2 fi if [[ -z "${GITHUB_EVENT_PATH:-}" ]]; then echo "GITHUB_EVENT_PATH is not set" >&2 - exit 1 + exit 3 fi # Extract the pull request number from the event payload @@ -34,7 +36,6 @@ if [[ "$pr_number" == "null" ]]; then exit 0 fi -# Split repository into owner and repo name # Split repository into owner and repo name using Bash parameter expansion owner="${GITHUB_REPOSITORY%%/*}" # Extract text before the first '/' repo="${GITHUB_REPOSITORY##*/}" # Extract text after the last '/' @@ -42,16 +43,34 @@ repo="${GITHUB_REPOSITORY##*/}" # Extract text after the last '/' page=1 per_page=100 changed_php_files=() +changed_phan_files=() +changed_lang_files=() + +# Get phan path configuration +phan_directory_list=$(php -r '$config = require("dev/tools/phan/config.php"); echo "^".implode("|",$config["directory_list"]);') +phan_exclude_directory=$(php -r '$config = require("dev/tools/phan/config.php"); echo "^".implode("|",$config["exclude_analysis_directory_list"]);') + +phan_exclude_file_regex=$(php -r '$config = require("dev/tools/phan/config.php"); echo $config["exclude_file_regex"];') +phan_exclude_file_regex=${phan_exclude_file_regex#@} +phan_exclude_file_regex=${phan_exclude_file_regex%@} # Loop through all pages to gather changed files while true; do response=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ "https://api.github.com/repos/${owner}/${repo}/pulls/${pr_number}/files?per_page=${per_page}&page=${page}") + + # Filter for files ending with .php and add them to the list - mapfile -t files < <(echo "$response" | jq -r '.[] | select(.filename | test("\\.php$")) | .filename') + mapfile -t files < <(echo "$response" | jq -r '.[] | select((.filename | test("\\.php$")) and (.filename | test("^dev/") | not)) | .filename') changed_php_files+=("${files[@]}") + mapfile -t files < <(echo "$response" | jq -r '.[] | select((.filename | test("\\.php$")) and (.filename | test("'"$phan_directory_list"'"))) | .filename' | grep -vP "$phan_exclude_file_regex") + changed_phan_files+=("${files[@]}") + + mapfile -t files < <(echo "$response" | jq -r '.[] | select(.filename | test("\\.lang$")) | .filename') + changed_lang_files+=("${files[@]}") + # Check if we have reached the last page (less than per_page results) count=$(echo "$response" | jq 'length') if (( count < per_page )); then @@ -61,26 +80,51 @@ while true; do done -# Build a space-separated string of changed PHP files +# Build a space-separated string of changed PHP and lang files # This does not cope with files that have spaces. + # But such files do not exist in the project (at least not for the # files we are filtering). all_changed_files=$(IFS=" " ; echo "${changed_php_files[*]}") +all_changed_lang=$(IFS=" " ; echo "${changed_lang_files[*]}") +phan_changed_files=$(IFS=" " ; echo "${changed_phan_files[*]}") -# Determine changed files flag -if [ -z "$all_changed_files" ]; then +forbidden_files="" +#forbidden_files=$(echo "$all_changed_lang" | grep -E 'htdocs/langs/([^/]+)/.*\.lang$' | grep -v 'htdocs/langs/en_US/') +#if [ -n "$forbidden_files" ]; then +# echo "You tried to modify one or more language files that are not allowed to be modified in Pull requests." +# echo "$forbidden_files" +# echo "To modify translations that are not the source language (en_US), you must modify them from transifex.com" +# exit 10 +#fi + + +# Determine changed files flags +if [ -z "${all_changed_files}" ]; then any_changed="false" else any_changed="true" fi +if [ -z "${phan_changed_files}" ]; then + phan_changed="false" +else + phan_changed="true" +fi + # Set outputs for GitHub Actions if GITHUB_OUTPUT is available if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "any_changed=${any_changed}" >> "$GITHUB_OUTPUT" + echo "phan_changed=${phan_changed}" >> "$GITHUB_OUTPUT" echo "all_changed_files=${all_changed_files}" >> "$GITHUB_OUTPUT" + echo "phan_changed_files=${phan_changed_files[*]}" >> "$GITHUB_OUTPUT" + echo "forbidden_files=${forbidden_files}" >> "$GITHUB_OUTPUT" else # Otherwise, print the outputs echo "any_changed=${any_changed}" + echo "phan_changed=${phan_changed}" + echo "phan_changed_files=${phan_changed_files[*]}" echo "all_changed_files=${all_changed_files}" + echo "forbidden_files=${forbidden_files}" fi diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 9f5a91b7925..b597b678c68 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -21,6 +21,7 @@ env: gh_event: ${{ inputs.gh_event || github.event_name }} CACHE_KEY_PART: ${{ ( inputs.gh_event == 'pull_request' || github.event_name == 'pull_request' ) && format('{0}-{1}', github.base_ref, github.head_ref) || github.ref_name }} GITHUB_JSON: ${{ toJSON(github) }} # Helps in debugging Github Action + DUMMY_FILE: htdocs/theme/md/progress.inc.php # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job @@ -45,7 +46,7 @@ jobs: with: php-version: ${{ matrix.php-version }} coverage: none # disable xdebug, pcov - tools: phpstan:2.1.12, cs2pr + tools: phpstan:2.1.12, cs2pr:1.8.6 extensions: calendar, json, imagick, gd, zip, mbstring, intl, opcache, imap, mysql, pgsql, sqlite3, ldap, xml, mcrypt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 76bb55b7b45..db4c1d2691b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,6 +41,9 @@ repos: exclude_types: [markdown] # Fix the end of file - id: end-of-file-fixer + exclude: | + (?x)^(htdocs/public/includes/tinymce/.* + )$ # Check that there are no completely merged file conflicts - id: check-merge-conflict stages: [pre-rebase, pre-commit, pre-merge-commit] @@ -60,23 +63,25 @@ repos: # Gitleaks is a SAST tool for detecting and preventing hardcoded secrets like passwords, api keys, and tokens in git repos - repo: https://github.com/gitleaks/gitleaks.git - rev: v8.29.0 + rev: v8.30.0 hooks: - id: gitleaks - # Check github actions - #- repo: https://github.com/rhysd/actionlint - # rev: v1.7.8 - # hooks: - # - id: actionlint + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint + stages: [manual] # To run: pre-commit run -a --hook-stage=manual actionlint # Beautify shell scripts #- repo: https://github.com/lovesegfault/beautysh.git - # rev: v6.2.1 + # rev: v6.4.1 # hooks: # - id: beautysh # exclude: | # (?x)^(dev/setup/git/hooks/pre-commit + # |dev/initdemo/initdemo.sh # indent/outdent mismatch + # |dev/build/debian/dolibarr.postrm # indent/outdent mismatch # )$ # args: [--tab] @@ -193,7 +198,7 @@ repos: # Check format of yaml files - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint args: @@ -244,16 +249,16 @@ repos: - --uri-ignore-words-list - ned - # Check some shell scripts - #- repo: https://github.com/shellcheck-py/shellcheck-py - # rev: v0.11.0.1 - # hooks: - # - id: shellcheck - # args: [-W, "100"] + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + stages: [manual] # To run: `pre-commit run -a --hook-stage=manual shellcheck` + args: [-W, "100"] # Check sql file syntax - repo: https://github.com/sqlfluff/sqlfluff - rev: 3.3.1 + rev: 4.2.0 hooks: - id: sqlfluff-lint #stages: [pre-commit, manual] # manual needed for ci @@ -262,8 +267,7 @@ repos: (dev/initdemo/mysqldump_.*\.sql |htdocs/core/menus/init_menu_auguria\.sql |htdocs/includes/.* - |htdocs/install/doctemplates/websites/.*_template - |htdocs/install/doctemplates/websites/website_template.*\.sql + |htdocs/install/doctemplates/websites/.* |htdocs/install/mysql/data/llx_20_c_departements\.sql |htdocs/install/mysql/data/llx_accounting_account_.*\.sql |htdocs/install/mysql/migration/3\..*\.sql diff --git a/pyproject.toml b/pyproject.toml index ba1e562e155..c90e42a930d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" # `codespell` can be run as a standalone program from the CLI # with the appropriate default options. -skip = "*/.*/*,*/langs/*,*/dev/build/exe/*,**.log,*.pdf,*.PDF,*dev/resources/*,*.phar,*.z,*.gz,*.sql,*.svg,*htdocs/includes/*,*/textiso.txt,*.js,*README-*,*build/rpm/*spec,*build/pad/*ml,*htdocs/includes/phpoffice/*,*htdocs/includes/tecnickcom/*,*dev/initdemo/removeconfdemo.sh,*dev/tools/codespell/*,*dev/trans*/ignore_translation_keys.lst,*pyproject.toml,*build/exe/*,*fontawe*,*htdocs/theme/*/flags-sprite.inc.php,*dev/setup/codetemplates/codetemplates.xml,*/php.ini,*/html_cerfafr.*,*/lessc.class.php,*.asciidoc,*.xml,*opensurvey/css/style.css,*dev/tools/phan/stubs/*,*/documents,phpstan.*" +skip = "*/.*/*,*/langs/*,*/dev/build/exe/*,**.log,*.pdf,*.PDF,*dev/resources/*,*.phar,*.z,*.gz,*.sql,*.svg,*htdocs/includes/*,*/textiso.txt,*.js,*README-*,*build/rpm/*spec,*build/pad/*ml,*htdocs/includes/phpoffice/*,*htdocs/includes/tecnickcom/*,*dev/initdemo/removeconfdemo.sh,*dev/tools/codespell/*,*dev/trans*/ignore_translation_keys.lst,*pyproject.toml,*build/exe/*,*fontawe*,*htdocs/theme/*/flags-sprite.inc.php,*dev/setup/codetemplates/codetemplates.xml,*/php.ini,*/html_cerfafr.*,*/lessc.class.php,*.asciidoc,*.xml,*opensurvey/css/style.css,*dev/tools/phan/stubs/*,*/documents,phpstan.*,*dev/initdemo/documents_demo/blockedlog/archives/*" check-hidden = true quiet-level=2 @@ -61,7 +61,7 @@ processes = -1 #verbose = 1 #exclude_rules = "LT01,CP01,RF04" # RF04 | Keywords should not be used as identifiers. (rowid, login, position, ...) -exclude_rules = "LT01,LT02,LT05,LT12,LT13,LT14,CP01,CP02,CP04,CP05,RF04" +exclude_rules = "LT01,LT02,LT05,LT12,LT13,LT14,LT15,CP01,CP02,CP04,CP05,RF04" dialect = "mysql" # Default byte limit is 20000, must set limit - some files are too big. large_file_skip_byte_limit = 100000 From 6d828c29fcaa34c530a85410d05d52b4ac2bb5ed Mon Sep 17 00:00:00 2001 From: Zakaria Boushaba <48571684+Boushabazakaria@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:35:37 +0100 Subject: [PATCH 192/307] FIX accounting export: reexport date not updated and fiscal year filter incorrectly applied on reexport (#39186) * fix: accounting export reexport date not updated and fiscal year filter incorrectly applied on reexport * Update date_export only if it is empty or ACCOUNTING_REEXPORT is active --------- Co-authored-by: Zakaria Boushaba Co-authored-by: Laurent Destailleur --- htdocs/accountancy/bookkeeping/export.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/accountancy/bookkeeping/export.php b/htdocs/accountancy/bookkeeping/export.php index 0310f8a502c..6af1271787a 100644 --- a/htdocs/accountancy/bookkeeping/export.php +++ b/htdocs/accountancy/bookkeeping/export.php @@ -167,7 +167,7 @@ $hookmanager->initHooks(array('bookkeepingexport')); $formaccounting = new FormAccounting($db); $form = new Form($db); -if (!in_array($action, array('export_file', 'delmouv', 'delmouvconfirm')) && !GETPOSTISSET('begin') && !GETPOSTISSET('formfilteraction') && GETPOSTINT('page') == 0 && !GETPOSTINT('noreset') && $user->hasRight('accounting', 'mouvements', 'export')) { +if (!in_array($action, array('export_file', 'export_fileconfirm', 'delmouv', 'delmouvconfirm')) && !GETPOSTISSET('begin') && !GETPOSTISSET('formfilteraction') && GETPOSTINT('page') == 0 && !GETPOSTINT('noreset') && $user->hasRight('accounting', 'mouvements', 'export')) { if (empty($search_date_start) && empty($search_date_end) && !GETPOSTISSET('restore_lastsearch_values') && !GETPOST('search_accountancy_code_start')) { $sql = "SELECT date_start, date_end"; $sql .= " FROM ".MAIN_DB_PREFIX."accounting_fiscalyear "; @@ -680,10 +680,10 @@ if ($action == 'export_fileconfirm' && $user->hasRight('accounting', 'mouvements $now = dol_now(); $sanitizedsetfields = ''; - if (!empty($notifiedexportdate) && empty($movement->date_export)) { + if (!empty($notifiedexportdate) && (empty($movement->date_export) || getDolGlobalString('ACCOUNTING_REEXPORT'))) { $sanitizedsetfields .= ($sanitizedsetfields ? "," : "")." date_export = '".$db->idate($now)."'"; } - if (!empty($notifiedvalidationdate) && empty($movement->date_validation)) { + if (!empty($notifiedvalidationdate) && (empty($movement->date_validation) || getDolGlobalString('ACCOUNTING_REEXPORT'))) { $sanitizedsetfields .= ($sanitizedsetfields ? "," : "")." date_validated = '".$db->idate($now)."'"; } From 9cc9367fc0c3b0ee845e811154e897b38e222e73 Mon Sep 17 00:00:00 2001 From: HENRY Florian Date: Thu, 16 Jul 2026 12:51:02 +0200 Subject: [PATCH 193/307] fix: if user cannot create suppluer invoice it cannot create payment (#39183) Co-authored-by: Laurent Destailleur --- htdocs/fourn/facture/card.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/fourn/facture/card.php b/htdocs/fourn/facture/card.php index 2a498c51a34..d119fe2bda3 100644 --- a/htdocs/fourn/facture/card.php +++ b/htdocs/fourn/facture/card.php @@ -4258,7 +4258,7 @@ if ($action == 'create') { } // Create payment - if ($object->type != FactureFournisseur::TYPE_CREDIT_NOTE && $object->status == FactureFournisseur::STATUS_VALIDATED && $object->paid == 0) { + if ($object->type != FactureFournisseur::TYPE_CREDIT_NOTE && $object->status == FactureFournisseur::STATUS_VALIDATED && $object->paid == 0 && $usercancreate) { print ''.$langs->trans('DoPayment').''; // must use facid because id is for payment id not invoice } @@ -4268,7 +4268,7 @@ if ($action == 'create') { if ($object->type == FactureFournisseur::TYPE_CREDIT_NOTE && $object->status == 1 && $object->paid == 0) { if ($resteapayer == 0) { print ''.$langs->trans('DoPaymentBack').''; - } else { + } elseif ($usercancreate) { print ''.$langs->trans('DoPaymentBack').''; } } From bd419519ff0bc9f78071e8e7ebcf612551d2277c Mon Sep 17 00:00:00 2001 From: John BOTELLA <68917336+thersane-john@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:06:00 +0200 Subject: [PATCH 194/307] Fix extrafield hidden field not ecaped value (#39148) Co-authored-by: Laurent Destailleur --- htdocs/core/class/commonobject.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index 83e40cece85..cf190bb67c5 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -8971,7 +8971,7 @@ abstract class CommonObject } elseif (($mode == 'edit') && !in_array(abs($visibility), array(1, 3, 4))) { // We need to make sure, that the values of hidden extrafields are also part of $_POST. Otherwise, they would be empty after an update of the object. See also getOptionalsFromPost $ef_name = 'options_' . $key; - $ef_value = $this->array_options[$ef_name]; + $ef_value = dol_htmlentities($this->array_options[$ef_name], ENT_QUOTES | ENT_SUBSTITUTE); $out .= '' . "\n"; continue; // <> -1 and <> 1 and <> 3 = not visible on forms, only on list and <> 4 = not visible at the creation } elseif ($mode == 'view' && empty($visibility)) { From f70ccfa84200bbb4d8e732c2dd39204230222ec6 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Thu, 16 Jul 2026 13:10:41 +0200 Subject: [PATCH 195/307] Add a TODO --- htdocs/core/class/commonobject.class.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index 7fcdeb0b79a..1da7bc7eb97 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -9242,10 +9242,12 @@ abstract class CommonObject if (($mode == 'create') && !in_array(abs($visibility), array(1, 3))) { continue; // <> -1 and <> 1 and <> 3 = not visible on forms, only on list } elseif (($mode == 'edit') && !in_array(abs($visibility), array(1, 3, 4))) { - // We need to make sure, that the values of hidden extrafields are also part of $_POST. Otherwise, they would be empty after an update of the object. See also getOptionalsFromPost + // We need to make sure, that the values of hidden extrafields are also part of $_POST. + // Otherwise, they would be empty after an update of the object. See also getOptionalsFromPost + // TODO: We should not have this hidden field, and action='update' should be done only if field was POSTED by form. $ef_name = 'options_' . $key; - $ef_value = dol_htmlentities($this->array_options[$ef_name], ENT_QUOTES | ENT_SUBSTITUTE); - $out .= '' . "\n"; + $ef_value = $this->array_options[$ef_name]; + $out .= '' . "\n"; continue; // <> -1 and <> 1 and <> 3 = not visible on forms, only on list and <> 4 = not visible at the creation } elseif ($mode == 'view' && empty($visibility)) { continue; From 5f75ee865a8b6e321c4d2ff383809f31d4ed3b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Aliot?= Date: Thu, 16 Jul 2026 13:14:59 +0200 Subject: [PATCH 196/307] FIX #37540 DST distorts working day count in num_public_holiday (#39158) The day loop of num_public_holiday() advanced with dol_time_plus_duree($timestampStart, 1, 'd'). On 21.0 and 22.0 that function only forces UTC when MAIN_DATE_IN_MEMORY_ARE_GMT is set, so by default it adds a wall-clock day in the server timezone. When the range crosses a DST boundary (last Sunday of March in Europe), the GMT timestamp advances by only 23 hours, the same GMT day is evaluated twice, one day is counted as holiday twice and the working day after the boundary is never examined. For a leave request from Sat 28/03/2026 to Mon 30/03/2026 with a server in Europe/Paris: 3 days between, 3 holidays counted (Sunday twice), 0 working days instead of 1. Advance by exactly 86400 seconds instead. Inputs of this function are GMT dates (enforced by the modulo check at the top of the function), so a GMT day is always exactly 86400 seconds, in every timezone and conf. This matches the behaviour that 23.0 and develop get by default since dol_time_plus_duree() switched to UTC by default there. Signed-off-by: Gregory Aliot Co-authored-by: Gregory Aliot --- htdocs/core/lib/date.lib.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/htdocs/core/lib/date.lib.php b/htdocs/core/lib/date.lib.php index d2d88114820..9b07c05d7ab 100644 --- a/htdocs/core/lib/date.lib.php +++ b/htdocs/core/lib/date.lib.php @@ -1003,7 +1003,12 @@ function num_public_holiday($timestampStart, $timestampEnd, $country_code = '', } // Increase number of days (on go up into loop) - $timestampStart = dol_time_plus_duree($timestampStart, 1, 'd'); + // Advance by exactly one GMT day. Do not use dol_time_plus_duree() here: when dates in memory + // are not GMT, it adds a wall-clock day in the server timezone, so crossing a DST boundary + // moves the GMT timestamp by only 23 hours, the same GMT day is then evaluated twice and the + // holiday count is wrong. Inputs of this function are GMT dates (checked above), so a day + // is always exactly 86400 seconds. + $timestampStart += 86400; //var_dump($jour.' '.$mois.' '.$annee.' '.$timestampStart); $i++; From b2eae1b7d736936256a3630f16ff27786f8e767c Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Thu, 16 Jul 2026 20:56:33 +0900 Subject: [PATCH 197/307] FIX #37826 country_code must be an ISO code, not a label (#39191) * FIX #37826 country_code must be an ISO code, not a label On a proposal/order, the 2nd+ line got VAT 0% with the error "no vat rates defined for country 'France'/'Suisse'". Root cause: the seller country_code held a translated country label instead of the ISO code, so the "c.code IN (...)" lookup in Form::load_cache_vatrates() matched nothing. - Societe::setMysoc(): the canonical MAIN_INFO_SOCIETE_COUNTRY is "id:code:label"; a legacy "id:label" value put the label into country_code. Only treat the full 3-token form as "id:code:label" and otherwise rebuild code/label from the authoritative country id. - Form::load_tva(): defensively recover the seller ISO code from its country id when country_code is not a known c_country code, so a bad value can never silently force the VAT rate to 0%. Signed-off-by: Jam Balaya * FIX #37826 avoid useless SQL per page: recover country ISO code from id only when code is not a 2-char ISO code --------- Signed-off-by: Jam Balaya --- htdocs/core/class/html.form.class.php | 20 ++++++++--- htdocs/societe/class/societe.class.php | 9 +++-- test/phpunit/SocieteTest.php | 46 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php index e2a267607c6..14e4e0231f4 100644 --- a/htdocs/core/class/html.form.class.php +++ b/htdocs/core/class/html.form.class.php @@ -8032,12 +8032,22 @@ class Form //exit; // Define list of countries to use to search VAT rates to show - // First we defined code_country to use to find list - if (is_object($societe_vendeuse)) { - $code_country = "'" . $societe_vendeuse->country_code . "'"; - } else { - $code_country = "'" . $mysoc->country_code . "'"; // Pour compatibilite ascendente + // First we defined code_country to use to find list. + // country_code must be a c_country ISO code (e.g. 'FR', 'CH'). In some setups it may hold a + // country label (e.g. 'Suisse') instead, which would make the "c.code IN (...)" lookup done by + // load_cache_vatrates() match nothing and wrongly force the VAT rate to 0%. A valid ISO code is + // always 2 chars, so when the value is not a well formed ISO code (empty or a label) and we have + // a valid country id, we recover the ISO code from the authoritative country id. This way we do + // not run any SQL on each page access when we already have a valid ISO code. + $sellercountrycode = is_object($societe_vendeuse) ? $societe_vendeuse->country_code : $mysoc->country_code; + $sellercountryid = is_object($societe_vendeuse) ? $societe_vendeuse->country_id : $mysoc->country_id; + if ((int) $sellercountryid > 0 && strlen((string) $sellercountrycode) != 2) { + $tmpcountrycode = dol_getIdFromCode($this->db, (string) $sellercountryid, 'c_country', 'rowid', 'code'); + if (!empty($tmpcountrycode) && !is_numeric($tmpcountrycode)) { + $sellercountrycode = $tmpcountrycode; + } } + $code_country = "'" . $sellercountrycode . "'"; // Pour compatibilite ascendente if ($societe_vendeuse == $mysoc && getDolGlobalString('SERVICE_ARE_ECOMMERCE_200238EC')) { // If option to have vat for end customer for services is on require_once DOL_DOCUMENT_ROOT . '/core/lib/company.lib.php'; diff --git a/htdocs/societe/class/societe.class.php b/htdocs/societe/class/societe.class.php index 41d5591d4b8..8ec9caa8fcb 100644 --- a/htdocs/societe/class/societe.class.php +++ b/htdocs/societe/class/societe.class.php @@ -4771,11 +4771,14 @@ class Societe extends CommonObject if (getDolGlobalString('MAIN_INFO_SOCIETE_COUNTRY')) { $tmp = explode(':', getDolGlobalString('MAIN_INFO_SOCIETE_COUNTRY')); $country_id = (is_numeric($tmp[0])) ? (int) $tmp[0] : 0; - if (!empty($tmp[1])) { // If $conf->global->MAIN_INFO_SOCIETE_COUNTRY is "id:code:label" + if (!empty($tmp[1]) && !empty($tmp[2])) { // MAIN_INFO_SOCIETE_COUNTRY is the canonical "id:code:label" $country_code = $tmp[1]; $country_label = $tmp[2]; - } else { - // For backward compatibility + } elseif ($country_id > 0) { + // For backward compatibility. The value is "id" only, or a legacy "id:label" where the + // second token is a country label (e.g. 'Suisse') and not an ISO code. In both cases we + // must not keep a label in country_code (it would break code-based lookups like VAT rates), + // so we rebuild code and label from the authoritative country id. dol_syslog("Your country setup use an old syntax. Reedit it using setup area.", LOG_WARNING); include_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php'; $country_code = getCountry($country_id, '2', $this->db); // This need a SQL request, but it's the old feature that should not be used anymore diff --git a/test/phpunit/SocieteTest.php b/test/phpunit/SocieteTest.php index 4f8a4e0fa72..3a1aeaf3d5b 100644 --- a/test/phpunit/SocieteTest.php +++ b/test/phpunit/SocieteTest.php @@ -568,4 +568,50 @@ class SocieteTest extends CommonClassTest print __METHOD__." result=".$result."\n"; return $result; } + + /** + * Test that Societe::setMysoc() always resolves country_code to an ISO country code. + * + * Regression test for issue #37826: when MAIN_INFO_SOCIETE_COUNTRY uses the legacy + * "id:label" (2-token) syntax, the second token is a country label (e.g. 'France'), + * not an ISO code. setMysoc() must not leave that label in country_code (it would break + * code-based lookups such as VAT rates and force a 0% rate), but rebuild it from the id. + * + * @return void + */ + public function testSetMysocCountryCode() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + require_once dirname(__FILE__).'/../../htdocs/core/lib/company.lib.php'; + + $frid = (int) getCountry('FR', '3', $db); + $this->assertGreaterThan(0, $frid, 'Country FR must exist in dictionary c_country'); + + $savcountryconst = getDolGlobalString('MAIN_INFO_SOCIETE_COUNTRY'); + + // Canonical 3-token syntax "id:code:label". + $conf->global->MAIN_INFO_SOCIETE_COUNTRY = $frid.':FR:France'; + $soc3 = new Societe($db); + $soc3->setMysoc($conf); + + // Legacy 2-token syntax "id:label" (the #37826 case), second token is a label not a code. + $conf->global->MAIN_INFO_SOCIETE_COUNTRY = $frid.':France'; + $soc2 = new Societe($db); + $soc2->setMysoc($conf); + + // Restore the constant before asserting so a failure does not leak state to other tests. + $conf->global->MAIN_INFO_SOCIETE_COUNTRY = $savcountryconst; + + $this->assertEquals('FR', $soc3->country_code, 'country_code must be the ISO code from a 3-token constant'); + $this->assertEquals($frid, $soc3->country_id); + $this->assertEquals('FR', $soc2->country_code, 'country_code must be an ISO code, not a label, from a legacy 2-token constant (#37826)'); + $this->assertEquals($frid, $soc2->country_id); + + print __METHOD__." ok\n"; + } } From 0194d442662d5a2e2dcbcbf221d4bbef37c76420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Houssin?= Date: Thu, 16 Jul 2026 14:06:54 +0200 Subject: [PATCH 198/307] FIX BLOCKEDLOG_HMAC_KEY can not be the same by entity (#39067) * FIX BLOCKEDLOG_HMAC_KEY can not be the same by entity * FIX migrate for all entities --- htdocs/core/modules/modBlockedLog.class.php | 5 +++-- htdocs/install/upgrade2.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/htdocs/core/modules/modBlockedLog.class.php b/htdocs/core/modules/modBlockedLog.class.php index beed7c8abbd..e058b8aff91 100644 --- a/htdocs/core/modules/modBlockedLog.class.php +++ b/htdocs/core/modules/modBlockedLog.class.php @@ -1,5 +1,6 @@ +/* Copyright (C) 2017-2025 Laurent Destailleur + * Copyright (C) 2026 Regis Houssin * * 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 @@ -194,7 +195,7 @@ class modBlockedLog extends DolibarrModules // Add key $hmac_secret_key = 'BLOCKEDLOGHMAC'.getRandomPassword(true); // This is using random_int for 32 chars - $result = dolibarr_set_const($this->db, 'BLOCKEDLOG_HMAC_KEY', $hmac_secret_key, 'chaine', 0, 'The secret key for HMAC used for blockedlog record', 0); // Will encrypt the value using dolCrypt and store it. + $result = dolibarr_set_const($this->db, 'BLOCKEDLOG_HMAC_KEY', $hmac_secret_key, 'chaine', 0, 'The secret key for HMAC used for blockedlog record', $conf->entity); // Will encrypt the value using dolCrypt and store it. if ($result < 0) { dol_print_error($this->db); diff --git a/htdocs/install/upgrade2.php b/htdocs/install/upgrade2.php index 32825f7d5bf..8fc20e47425 100644 --- a/htdocs/install/upgrade2.php +++ b/htdocs/install/upgrade2.php @@ -5632,7 +5632,7 @@ function migrate_blockedlog_add_hmac_key() // Add key $hmac_secret_key = 'BLOCKEDLOGHMAC'.getRandomPassword(true); // This is using random_int for 32 chars - $result = dolibarr_set_const($db, 'BLOCKEDLOG_HMAC_KEY', $hmac_secret_key, 'chaine', 0, 'The secret key for HMAC used for blockedlog record', 0); // Will encrypt the value using dolCrypt and store it. + $result = dolibarr_set_const($db, 'BLOCKEDLOG_HMAC_KEY', $hmac_secret_key, 'chaine', 0, 'The secret key for HMAC used for blockedlog record', $conf->entity); // Will encrypt the value using dolCrypt and store it. if ($result < 0) { dol_print_error($db); From fb4d379786d59ba561fcc5f68975772b407b03d8 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 17 Jul 2026 11:28:00 +0200 Subject: [PATCH 199/307] Debug --- htdocs/core/lib/security.lib.php | 53 +++++++++++++++++++------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/htdocs/core/lib/security.lib.php b/htdocs/core/lib/security.lib.php index ab0f324001a..684a6ffbb91 100644 --- a/htdocs/core/lib/security.lib.php +++ b/htdocs/core/lib/security.lib.php @@ -166,7 +166,7 @@ function dolGetLdapPasswordHash($password, $type = 'md5') * @param string $features Features to check (it must be module name or $object->element. Can be a 'or' check with 'levela|levelb'. * Examples: 'societe', 'contact', 'produit&service', 'produit|service', ...) * This is used to check permission $user->rights->features->... - * @param int|string|Object $object Object or Object ID or list of Object ID if we want to check a particular record (optional) is linked to a owned thirdparty (optional). + * @param int|string|Object $object Object (recommended) or Object ID or list of Object ID if we want to check a particular record (optional) is linked to a owned thirdparty (optional). * @param string $tableandshare 'TableName&SharedElement' with Tablename is table where object is stored. SharedElement is an optional key to define where to check entity for multicompany module. Param not used if objectid is null (optional). * @param string $feature2 Feature to check, second level of permission (optional). Can be a 'or' check with 'sublevela|sublevelb'. * This is used to check permission $user->rights->features->feature2... @@ -194,8 +194,8 @@ function restrictedArea(User $user, $features, $object = 0, $tableandshare = '', $objectid = preg_replace('/[^0-9\.\,]/', '', (string) $objectid); // For the case value is coming from a non sanitized user input } - //dol_syslog("functions.lib:restrictedArea $feature, $objectid, $dbtablename, $feature2, $dbt_socfield, $dbt_select, $isdraft"); - /*print "user_id=".$user->id.", features=".$features.", feature2=".$feature2.", objectid=".$objectid; + //dol_syslog("functions.lib:restrictedArea $feature, $object, $dbtablename, $feature2, $dbt_socfield, $dbt_select, $isdraft"); + /*print "user_id=".$user->id.", features=".$features.", feature2=".$feature2.", object=".$object; print ", dbtablename=".$tableandshare.", dbt_socfield=".$dbt_keyfield.", dbt_select=".$dbt_select; print ", perm: user->hasRight(".$features.($feature2 ? ",".$feature2 : "").", lire) = ".($feature2 ? $user->hasRight($features, $feature2, 'lire') : $user->hasRight($features, 'lire'))."
"; */ @@ -706,7 +706,7 @@ function restrictedArea(User $user, $features, $object = 0, $tableandshare = '', * * @param User $user User to check * @param string[] $featuresarray Features/modules to check. Example: ('user','service','member','project','task',...) - * @param int|string|Object $object Full object or object ID or list of object id. For example if we want to check a particular record (optional) is linked to a owned thirdparty (optional). + * @param int|string|Object $object Object (recommended) or Object ID or list of Object id. For example if we want to check a particular record (optional) is linked to a owned thirdparty (optional). * @param string $tableandshare 'TableName&SharedElement' with Tablename is table where object is stored. SharedElement is an optional key to define where to check entity for multicompany modume. Param not used if objectid is null (optional). * @param string[]|string $feature2 Feature to check, second level of permission (optional). Can be or check with 'level1|level2'. * @param string $dbt_keyfield Field name for socid foreign key if not fk_soc. Not used if objectid is null (optional). Can use '' if NA. @@ -724,10 +724,10 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl } else { $objectid = $object; // $objectid can be X or 'X,Y,Z' } - $objectid = preg_replace('/[^0-9\.\,]/', '', $objectid); // For the case value is coming from a non sanitized user input + $objectid = preg_replace('/[^0-9\.\,]/', '', (string) $objectid); // For the case value is coming from a non sanitized user input - //dol_syslog("functions.lib:restrictedArea $feature, $objectid, $dbtablename, $feature2, $dbt_socfield, $dbt_select, $isdraft"); - //print "user_id=".$user->id.", features=".join(',', $featuresarray).", objectid=".$objectid; + //dol_syslog("functions.lib:restrictedArea $feature, $object, $dbtablename, $feature2, $dbt_socfield, $dbt_select, $isdraft"); + //print "user_id=".$user->id.", features=".join(',', $featuresarray).", object=".$object; //print ", tableandshare=".$tableandshare.", dbt_socfield=".$dbt_keyfield.", dbt_select=".$dbt_select."
"; // More parameters @@ -770,7 +770,7 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl $parenttableforentity = ''; $dbtablename = "chargesociales"; $feature = "chargesociales"; - $objectid = $object->fk_charge; + $objectid = (string) $object->fk_charge; } $checkonentitydone = 0; @@ -793,12 +793,14 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl $sharedelement = (!empty($params[1]) ? $params[1] : $dbtablename); // We change dbtablename, so we set sharedelement too. } + // $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') { - $objectid = "'".$objectid."'"; // Note: $objectid was already cast into int at begin of this method. + $objectid = "'".$objectid."'"; } + // Check permission for objectid on entity only - if (in_array($feature, $check) && $objectid > 0) { // For $objectid = 0, no check + if (in_array($feature, $check) && !empty($objectid)) { // For $objectid = 0, no check $sql = "SELECT COUNT(dbt.".$db->sanitize($dbt_select).") as nb"; $sql .= " FROM ".MAIN_DB_PREFIX.$db->sanitize($dbtablename)." as dbt"; if (($feature == 'user' || $feature == 'usergroup') && isModEnabled('multicompany')) { // Special for multicompany @@ -830,10 +832,10 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl } $checkonentitydone = 1; } - if (in_array($feature, $checksoc) && $objectid > 0) { // We check feature = checksoc. For $objectid = 0, no check + if (in_array($feature, $checksoc) && !empty($objectid)) { // We check feature = checksoc. For $objectid = 0, no check // If external user: Check permission for external users if ($user->socid > 0) { - if ($user->socid != $objectid) { + if ((string) $user->socid != $objectid) { return false; } } elseif (isModEnabled('societe') && !$user->hasRight('societe', 'lire') && !$user->hasRight('societe', 'client', 'voir')) { @@ -863,7 +865,7 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl $checkonentitydone = 1; } - if (in_array($feature, $checkparentsoc) && $objectid > 0) { // Test on entity + link to thirdparty. Allowed if link is empty (Ex: contacts...). + if (in_array($feature, $checkparentsoc) && !empty($objectid)) { // Test on entity + link to thirdparty. Allowed if link is empty (Ex: contacts...). // If external user: Check permission for external users if ($user->socid > 0) { $sql = "SELECT COUNT(dbt.".$db->sanitize($dbt_select).") as nb"; @@ -888,9 +890,9 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl $checkonentitydone = 1; } - if (in_array($feature, $checkproject) && $objectid > 0) { + if (in_array($feature, $checkproject) && !empty($objectid)) { if (isModEnabled('project') && !$user->hasRight('projet', 'all', 'lire')) { - $projectid = $objectid; + $projectid = $objectid; // Note that if $objectid is a string list of id; the test later will return false include_once DOL_DOCUMENT_ROOT.'/projet/class/project.class.php'; $projectstatic = new Project($db); @@ -908,8 +910,11 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl } $checkonentitydone = 1; } - if (in_array($feature, $checktask) && (int) $objectid > 0) { + if (in_array($feature, $checktask) && !empty($objectid)) { if (isModEnabled('project') && !$user->hasRight('projet', 'all', 'lire')) { + if (preg_match('/,/', $objectid)) { // if this is a list of id + return false; + } $task = new Task($db); $task->fetch((int) $objectid); $projectid = $task->fk_project; @@ -934,7 +939,7 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl } //var_dump($sql); - if (!$checkonentitydone && !in_array($feature, $nocheck) && $objectid > 0) { // By default (case of $checkdefault), we check on object entity + link to third party on field $dbt_keyfield + if (!$checkonentitydone && !in_array($feature, $nocheck) && !empty($objectid)) { // By default (case of $checkdefault), we check on object entity + link to third party on field $dbt_keyfield // If external user: Check permission for external users if ($user->socid > 0) { if (empty($dbt_keyfield)) { @@ -981,9 +986,13 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl } // For events, check on users assigned to event - if ($feature === 'agenda' && ((int) $objectid) > 0) { + if ($feature === 'agenda' && !empty($objectid)) { // Also check owner or attendee for users without allactions->read - if (/* $objectid > 0 && */ !$user->hasRight('agenda', 'allactions', 'read')) { + if (!$user->hasRight('agenda', 'allactions', 'read')) { + if (preg_match('/,/', $objectid)) { // if this is a list of id + return false; + } + require_once DOL_DOCUMENT_ROOT.'/comm/action/class/actioncomm.class.php'; $action = new ActionComm($db); $action->fetch((int) $objectid); @@ -995,7 +1004,7 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl // For some object, we also have to check it is in the user hierarchy // Param $object must be the full object and not a simple id to have this test possible. - if (in_array($feature, $checkhierarchy) && is_object($object) && $objectid > 0) { + if (in_array($feature, $checkhierarchy) && is_object($object) && !empty($objectid)) { $childids = $user->getAllChildIds(1); $useridtocheck = 0; if ($feature == 'holiday') { @@ -1030,7 +1039,7 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl // For some object, we also have to check it is public or owned by user // Param $object must be the full object and not a simple id to have this test possible. - if (in_array($feature, $checkuser) && is_object($object) && $objectid > 0) { + if (in_array($feature, $checkuser) && is_object($object) && !empty($objectid)) { $useridtocheck = $object->fk_user; if (!empty($useridtocheck) && $useridtocheck > 0 && $useridtocheck != $user->id && empty($user->admin)) { return false; @@ -1041,7 +1050,7 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl $resql = $db->query($sql); if ($resql) { $obj = $db->fetch_object($resql); - if (!$obj || $obj->nb < count(explode(',', $objectid))) { // error if we found 0 or less record than nb of id provided + if (!$obj || $obj->nb < count(explode(',', $objectid))) { // error if we found 0 or less record than the nb of ids provided return false; } } else { From 07db77f07132a55c943bcee6a162ce7b1b9a1d0f Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 17 Jul 2026 12:52:13 +0200 Subject: [PATCH 200/307] Debug v24 --- htdocs/user/card.php | 82 +++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/htdocs/user/card.php b/htdocs/user/card.php index 25cb3f8c295..63d98c44fcb 100644 --- a/htdocs/user/card.php +++ b/htdocs/user/card.php @@ -1264,23 +1264,6 @@ if ($action == 'create' || $action == 'adduserldap') { print ''; print "
\n"; - // Force update on next login -- only on dolibarr auth context - if ($_SESSION["dol_authmode"] == 'dolibarr') { - print ''; - print ''; - print "\n"; - } - // Password print ''; print ''; + // Force update on next login -- only on dolibarr auth context + if ($_SESSION["dol_authmode"] == 'dolibarr') { + print ''; + print ''; + print "\n"; + } + if (!getDolGlobalString('API_IN_TOKEN_TABLE')) { if (isModEnabled('api')) { // API key @@ -2087,13 +2086,12 @@ if ($action == 'create' || $action == 'adduserldap') { } } else { print ''; - print $form->textwithpicto(''.$langs->trans("No").'', $langs->trans("UserDoesNotHaveRightsToChangeHisPassword")); + print ''.$langs->trans("No").''; } print ''; print "\n"; } - // Password for LDAP or HTTP Basic $valuetoshow = ''; if (preg_match('/ldap/', $dolibarr_main_authentication)) { @@ -2770,27 +2768,6 @@ if ($action == 'create' || $action == 'adduserldap') { print "\n"; - // Force update on next login only on dolibarr auth mode - if ($_SESSION["dol_authmode"] == 'dolibarr') { - print ''; - print ''; - } - // Pass print ''; print '\n"; + // Force update on next login only on dolibarr auth mode + if ($_SESSION["dol_authmode"] == 'dolibarr') { + print ''; + print ''; + } + // API key if (!getDolGlobalString('API_IN_TOKEN_TABLE')) { if (isModEnabled('api')) { From 2e65e0ea234b834e22367e6574de53dbad130874 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 18 Jul 2026 01:13:22 +0200 Subject: [PATCH 201/307] Clean code --- htdocs/societe/class/api_thirdparties.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/societe/class/api_thirdparties.class.php b/htdocs/societe/class/api_thirdparties.class.php index adefe537b86..38797ac01dd 100644 --- a/htdocs/societe/class/api_thirdparties.class.php +++ b/htdocs/societe/class/api_thirdparties.class.php @@ -1433,7 +1433,7 @@ class Thirdparties extends DolibarrApi $sql = "SELECT f.ref, f.type as factype, re.fk_facture_source, re.rowid, re.amount_ht, re.amount_tva, re.amount_ttc, re.description, re.fk_facture, re.fk_facture_line"; $sql .= " FROM ".MAIN_DB_PREFIX."societe_remise_except as re, ".MAIN_DB_PREFIX."facture as f"; - $sql .= " WHERE re.rowid IN ( $newid1, $newid2 ) AND f.rowid = re.fk_facture_source AND re.fk_soc = ".((int) $id); + $sql .= " WHERE re.rowid IN (".$this->db->sanitize(((int) $newid1).",".((int) $newid2)).") AND f.rowid = re.fk_facture_source AND re.fk_soc = ".((int) $id); $sql .= $this->db->order("f.type", "ASC"); From 19991c8ffa9b4fd7338b7ef2585fadb554b86735 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 18 Jul 2026 02:48:07 +0200 Subject: [PATCH 202/307] Sec: sql injection via api in POST of thirdparties/id/banckaccount - reported by MDnyn --- .../class/companybankaccount.class.php | 2 +- test/phpunit/CodingPhpTest.php | 53 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/htdocs/societe/class/companybankaccount.class.php b/htdocs/societe/class/companybankaccount.class.php index eb405b0523a..e914528b4aa 100644 --- a/htdocs/societe/class/companybankaccount.class.php +++ b/htdocs/societe/class/companybankaccount.class.php @@ -395,7 +395,7 @@ class CompanyBankAccount extends Account $this->db->begin(); $sql = "INSERT INTO ".MAIN_DB_PREFIX."societe_rib (fk_soc, type, datec, model_pdf)"; - $sql .= " VALUES (".((int) $this->socid).", '".$this->type."', '".$this->db->idate($this->datec)."',"; + $sql .= " VALUES (".((int) $this->socid).", '".$this->db->escape($this->type)."', '".$this->db->idate($this->datec)."',"; $sql .= " '".$this->db->escape(getDolGlobalString("BANKADDON_PDF"))."'"; $sql .= ")"; $resql = $this->db->query($sql); diff --git a/test/phpunit/CodingPhpTest.php b/test/phpunit/CodingPhpTest.php index 41748a5c050..5be42e6ae15 100644 --- a/test/phpunit/CodingPhpTest.php +++ b/test/phpunit/CodingPhpTest.php @@ -117,7 +117,7 @@ class CodingPhpTest extends CommonClassTest // To process only 1 file, uncomment this /* foreach($returnlist as $key => $val) { - if ($val[0]['name'] != 'societe.class.php') { + if ($val[0]['name'] != 'companybankaccount.class.php') { unset($returnlist[$key]); } } @@ -411,21 +411,52 @@ class CodingPhpTest extends CommonClassTest } $this->assertTrue($ok, 'Found a forged SQL string that mix on same line the use of \' for PHP string and PHP variables in file '.$file['relativename'].' Use " to forge PHP string like this: $sql = "SELECT ".$myvar...'); - // Check sql string VALUES ... , ".$xxx + // Check sql string VALUES ... , ".$xxx or string VALUES ... , '".$xxx // with xxx that is not 'db-' (for $db->escape). It means we forget a ' if string, or an (int) if int, when forging sql request. + // ... = " VALUES (".((int) $this->socid).", '".$this->type."', '".$this->db->idate($this->datec)."',"; + $ok = true; $matches = array(); - preg_match_all('/(VALUES).*,\s*"\s*\.\s*\$(...)/', $filecontent, $matches, PREG_SET_ORDER); + preg_match_all('/(VALUES).*,\s*\'?"\s*\.\s*\$([a-z\-\>]+)/', $filecontent, $matches, PREG_SET_ORDER); foreach ($matches as $key => $val) { - if ($val[1] == 'VALUES' && $val[2] == 'db-') { // exclude $db->escape( - continue; + $matches2 = array(); + preg_match_all('/,\s*\'?"\s*\.\s*\$([a-z\-\>]+)/', $val[0], $matches2, PREG_SET_ORDER); + foreach ($matches2 as $key2 => $val2) { + if ($val2[1] == 'mydb->escape') { // exclude ".$mydb->escape( + continue; + } + if ($val2[1] == 'dbsession->escape') { // exclude ".$dbsession->escape( + continue; + } + if ($val2[1] == 'dbsession->idate') { // exclude ".$dbsession->escape( + continue; + } + if ($val2[1] == 'this->db->encrypt') { // exclude ".$this->db->encrypt( + continue; + } + if ($val2[1] == 'this->db->escape') { // exclude ".$this->db->escape( + continue; + } + if ($val2[1] == 'this->db->idate') { // exclude ".$this->db->idate( + continue; + } + if ($val2[1] == 'db->encrypt') { // exclude ".$db->encrypt( + continue; + } + if ($val2[1] == 'db->escape') { // exclude ".$db->escape( + continue; + } + if ($val2[1] == 'db->idate') { // exclude ".$db->idate( + continue; + } + if ($val2[1] == 'this->escape') { // exclude ".$this->db->encrypt( + continue; + } + + var_dump($matches2); + $ok = false; + break; } - if ($val[1] == 'VALUES' && $val[2] == 'thi' && preg_match('/this->db->encrypt/', $val[0])) { // exclude ".$this->db->encrypt( - continue; - } - var_dump($matches); - $ok = false; - break; } //print __METHOD__." Result for checking we don't have non escaped string in sql requests for file ".$file."\n"; $this->assertTrue($ok, 'Found non quoted or not casted var in sql request '.$file['relativename'].' - Bad.'); From 1057ad6c7e222e58ec81aff955a1abe63d6dc364 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 18 Jul 2026 02:49:05 +0200 Subject: [PATCH 203/307] Sec: sql injection via api in POST of thirdparties/id/banckaccount - reported by MDnyn --- htdocs/accountancy/admin/productaccount.php | 2 +- htdocs/adherents/class/subscription.class.php | 6 +++--- htdocs/admin/external_rss.php | 2 +- htdocs/contrat/class/contratligne.class.php | 2 +- htdocs/core/class/discount.class.php | 2 +- htdocs/user/class/userbankaccount.class.php | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/htdocs/accountancy/admin/productaccount.php b/htdocs/accountancy/admin/productaccount.php index 98947f2213c..308b1097a68 100644 --- a/htdocs/accountancy/admin/productaccount.php +++ b/htdocs/accountancy/admin/productaccount.php @@ -349,7 +349,7 @@ if (getDolGlobalString('MAIN_PRODUCT_PERENTITY_SHARED')) { $sql .= " LEFT JOIN " . MAIN_DB_PREFIX . "accounting_account as aa ON aa.account_number = p." . $db->sanitize($accountancy_field_name) . " AND aa.fk_pcg_version = '" . $db->escape($pcgvercode) . "'"; } if (!empty($searchCategoryProductList)) { - $sql .= ' LEFT JOIN '.MAIN_DB_PREFIX."categorie_product as cp ON p.rowid = cp.fk_product"; // We'll need this table joined to the select in order to filter by categ + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."categorie_product as cp ON p.rowid = cp.fk_product"; // We'll need this table joined to the select in order to filter by categ } $sql .= ' WHERE p.entity IN ('.getEntity('product').')'; if (strlen(trim($search_current_account))) { diff --git a/htdocs/adherents/class/subscription.class.php b/htdocs/adherents/class/subscription.class.php index 4956e3e767d..68a68ae4311 100644 --- a/htdocs/adherents/class/subscription.class.php +++ b/htdocs/adherents/class/subscription.class.php @@ -518,9 +518,9 @@ class Subscription extends CommonObject */ public function info($id) { - $sql = 'SELECT c.rowid, c.datec, c.tms as datem, c.fk_user_creat'; - $sql .= ' FROM '.MAIN_DB_PREFIX.'subscription as c'; - $sql .= ' WHERE c.rowid = '.((int) $id); + $sql = "SELECT c.rowid, c.datec, c.tms as datem, c.fk_user_creat"; + $sql .= " FROM ".MAIN_DB_PREFIX."subscription as c"; + $sql .= " WHERE c.rowid = ".((int) $id); $resql = $this->db->query($sql); if ($resql) { diff --git a/htdocs/admin/external_rss.php b/htdocs/admin/external_rss.php index 271198e9268..9bff2be00cd 100644 --- a/htdocs/admin/external_rss.php +++ b/htdocs/admin/external_rss.php @@ -148,7 +148,7 @@ if (GETPOST("delete")) { $obj = $db->fetch_object($resql); $sql = "DELETE FROM ".MAIN_DB_PREFIX."boxes"; - $sql .= " WHERE entity = ".$conf->entity; + $sql .= " WHERE entity = ".((int) $conf->entity); $sql .= " AND box_id = ".((int) $obj->rowid); $resql = $db->query($sql); diff --git a/htdocs/contrat/class/contratligne.class.php b/htdocs/contrat/class/contratligne.class.php index a480183a39c..07e4ef30701 100644 --- a/htdocs/contrat/class/contratligne.class.php +++ b/htdocs/contrat/class/contratligne.class.php @@ -834,7 +834,7 @@ class ContratLigne extends CommonObjectLine if ($this->date_end > 0) { $sql .= ",date_fin_validite"; } - $sql .= ") VALUES ($this->fk_contrat, '', '".$this->db->escape($this->description)."',"; + $sql .= ") VALUES (".((int) $this->fk_contrat).", '', '".$this->db->escape($this->description)."',"; $sql .= ($this->fk_product > 0 ? $this->fk_product : "null").","; $sql .= " '".$this->db->escape((string) $this->qty)."',"; $sql .= " '".$this->db->escape($this->vat_src_code)."',"; diff --git a/htdocs/core/class/discount.class.php b/htdocs/core/class/discount.class.php index 11b1caf0230..97588d1d44b 100644 --- a/htdocs/core/class/discount.class.php +++ b/htdocs/core/class/discount.class.php @@ -349,7 +349,7 @@ class DiscountAbsolute extends CommonObject $sql .= " multicurrency_amount_ht, multicurrency_amount_tva, multicurrency_amount_ttc,"; $sql .= " fk_facture_source, fk_invoice_supplier_source, multicurrency_code, multicurrency_tx"; $sql .= ")"; - $sql .= " VALUES (".$conf->entity.", '".$this->db->idate($this->datec != '' ? $this->datec : dol_now())."', ".((int) $this->socid).", ".(empty($this->discount_type) ? 0 : intval($this->discount_type)).", ".((int) $userid).", '".$this->db->escape($this->description)."',"; + $sql .= " VALUES (".((int) $conf->entity).", '".$this->db->idate($this->datec != '' ? $this->datec : dol_now())."', ".((int) $this->socid).", ".(empty($this->discount_type) ? 0 : intval($this->discount_type)).", ".((int) $userid).", '".$this->db->escape($this->description)."',"; $sql .= " ".price2num($this->amount_ht).", ".price2num($this->amount_tva).", ".price2num($this->amount_ttc).", ".price2num($this->tva_tx).", '".$this->db->escape($this->vat_src_code)."',"; $sql .= " ".price2num($this->multicurrency_amount_ht).", ".price2num($this->multicurrency_amount_tva).", ".price2num($this->multicurrency_amount_ttc).", "; $sql .= " ".($this->fk_facture_source ? ((int) $this->fk_facture_source) : "null").","; diff --git a/htdocs/user/class/userbankaccount.class.php b/htdocs/user/class/userbankaccount.class.php index 19db5f1514e..be10af47621 100644 --- a/htdocs/user/class/userbankaccount.class.php +++ b/htdocs/user/class/userbankaccount.class.php @@ -96,7 +96,7 @@ class UserBankAccount extends Account $now = dol_now(); $sql = "INSERT INTO ".$this->db->prefix()."user_rib (fk_user, datec)"; - $sql .= " VALUES (".$this->userid.", '".$this->db->idate($now)."')"; + $sql .= " VALUES (".((int) $this->userid).", '".$this->db->idate($now)."')"; $resql = $this->db->query($sql); if ($resql) { if ($this->db->affected_rows($resql)) { From 61916b9039bfe0bdd962c1c4e674c6f6769e1ef0 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 18 Jul 2026 18:12:35 +0200 Subject: [PATCH 204/307] Sec: Fix wrong test on salary id - reported by Arpit Jain --- htdocs/salaries/class/api_salaries.class.php | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/htdocs/salaries/class/api_salaries.class.php b/htdocs/salaries/class/api_salaries.class.php index 2574466bc62..1526801db27 100644 --- a/htdocs/salaries/class/api_salaries.class.php +++ b/htdocs/salaries/class/api_salaries.class.php @@ -141,25 +141,25 @@ class Salaries extends DolibarrApi throw new RestException(403); } - if (!DolibarrApiAccess::$user->hasRight('salaries', 'readall')) { - if (!DolibarrApiAccess::$user->hasRight('salaries', 'readchild')) { - if ($id != DolibarrApiAccess::$user->id) { - throw new RestException(404, 'salary not found'); - } - } else { - $childids = DolibarrApiAccess::$user->getAllChildIds(1); - if (!in_array($id, $childids)) { - throw new RestException(404, 'salary not found'); - } - } - } - $salary = new Salary($this->db); $result = $salary->fetch($id); if (!$result) { throw new RestException(404, 'salary not found'); } + if (!DolibarrApiAccess::$user->hasRight('salaries', 'readall')) { + if (!DolibarrApiAccess::$user->hasRight('salaries', 'readchild')) { + if ($salary->fk_user != DolibarrApiAccess::$user->id) { + throw new RestException(404, 'salary not found'); + } + } else { + $childids = DolibarrApiAccess::$user->getAllChildIds(1); + if (!in_array($salary->fk_user, $childids)) { + throw new RestException(404, 'salary not found'); + } + } + } + return $this->_cleanObjectDatas($salary); } From e4c593e0f9f109dd708be0f1d62a51712d36823a Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 18 Jul 2026 18:31:26 +0200 Subject: [PATCH 205/307] Fix use of & char inside of mysql backup --- htdocs/admin/tools/export.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/htdocs/admin/tools/export.php b/htdocs/admin/tools/export.php index af4991ac757..773126498d4 100644 --- a/htdocs/admin/tools/export.php +++ b/htdocs/admin/tools/export.php @@ -127,7 +127,8 @@ $lowmemorydump = (int) (GETPOSTISSET("lowmemorydump") ? GETPOSTINT("lowmemorydum // MYSQL if ($what == 'mysql') { $cmddump = GETPOST("mysqldump", 'none'); // Do not sanitize here with 'alpha', will be sanitize later by dol_sanitizePathName and escapeshellarg - $cmddump = dol_sanitizePathName($cmddump); + $cmddump = dol_sanitizePathName($cmddump); // Sanitize path + $cmddump = dol_string_nospecial($cmddump, '', array("|", ";", "<", ">", "&", "+")); // Sanitize command $basenamecmddump = basename(str_replace('\\', '/', $cmddump)); // Add a fallback when we detect something wrong with the path of the dump command @@ -177,6 +178,7 @@ if ($what == 'mysqlnobin') { if ($what == 'postgresql') { $cmddump = GETPOST("postgresqldump", 'none'); // Do not sanitize here with 'alpha', will be sanitize later by dol_sanitizePathName and escapeshellarg $cmddump = dol_sanitizePathName($cmddump); + $cmddump = dol_string_nospecial($cmddump, '', array("|", ";", "<", ">", "&", "+")); // Sanitize command /* Not required, the command is output on screen but not ran for pgsql if (!empty($dolibarr_main_restrict_os_commands)) From f02aba54886cd792ae46e9840caa4a125722f32c Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sat, 18 Jul 2026 18:31:45 +0200 Subject: [PATCH 206/307] Clean code --- htdocs/admin/security_file.php | 2 ++ htdocs/admin/tools/export_files.php | 9 ++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/htdocs/admin/security_file.php b/htdocs/admin/security_file.php index 1e5bdafa65c..dc025221d8a 100644 --- a/htdocs/admin/security_file.php +++ b/htdocs/admin/security_file.php @@ -33,6 +33,8 @@ require '../main.inc.php'; * @var HookManager $hookmanager * @var Translate $langs * @var User $user + * + * @var string $dolibarr_main_restrict_os_commands */ require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; diff --git a/htdocs/admin/tools/export_files.php b/htdocs/admin/tools/export_files.php index 4364c6d7ad0..631c7dcb99c 100644 --- a/htdocs/admin/tools/export_files.php +++ b/htdocs/admin/tools/export_files.php @@ -30,11 +30,6 @@ if (! defined('CSRFCHECK_WITH_TOKEN')) { // Load Dolibarr environment require '../../main.inc.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/class/utils.class.php'; -require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; - /** * @var Conf $conf * @var DoliDB $db @@ -42,6 +37,10 @@ require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; * @var Translate $langs * @var User $user */ +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/utils.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; $langs->load("admin"); From aea6a5fe992b292609776fc9e57be76f665883a1 Mon Sep 17 00:00:00 2001 From: atm-jonathan <146709163+atm-jonathan@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:27:39 +0200 Subject: [PATCH 207/307] FIX : situation invoice credit note wrong amount in progressive mode (#39197) When creating a credit note "with same lines" from a situation invoice, the code always subtracts the previous situation invoice's line values before inverting the sign, assuming lines are stored cumulative (INVOICE_USE_SITUATION = 1, legacy mode). In progressive mode (INVOICE_USE_SITUATION = 2), each situation invoice line already stores its own delta amount and percent, so this extra subtraction credits the wrong amount and percent (e.g. 60% / 1272 instead of the correct 20% / 424 on a real cycle). Skip the subtraction in progressive mode so the credit note reuses the source line's own delta values, as expected. --- htdocs/compta/facture/card.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/htdocs/compta/facture/card.php b/htdocs/compta/facture/card.php index ef4b0962216..fad6020f714 100644 --- a/htdocs/compta/facture/card.php +++ b/htdocs/compta/facture/card.php @@ -1397,7 +1397,10 @@ if (empty($reshook)) { $source_fk_prev_id = $line->fk_prev_id; // temporary storing situation invoice fk_prev_id $line->fk_prev_id = $line->id; // The new line of the new credit note we are creating must be linked to the situation invoice line it is created from - if (!empty($facture_source->tab_previous_situation_invoice)) { + // The subtraction below assumes total_ht/situation_percent are stored cumulative on situation invoice lines + // (INVOICE_USE_SITUATION = 1, legacy). In progressive mode (INVOICE_USE_SITUATION = 2), each line already + // holds its own delta, so subtracting the previous invoice's line here would credit the wrong amount. + if (getDolGlobalInt('INVOICE_USE_SITUATION') != 2 && !empty($facture_source->tab_previous_situation_invoice)) { // search the last standard invoice in cycle and the possible credit note between this last and facture_source // TODO Move this out of loop of $facture_source->lines $tab_jumped_credit_notes = array(); From b305e6a0cc6422027dc442ec83f488f9c975a92d Mon Sep 17 00:00:00 2001 From: TowyTowy <85077986+TowyTowy@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:40:57 +0200 Subject: [PATCH 208/307] FIX getFirstDayOfEachWeek()/getLastDayOfEachWeek() use the wrong year for weeks overlapping 2 years (#39198) In a January that starts with week 52 or 53 of the previous year (for example January 2022), getFirstDayOfEachWeek() wrongly shifted week 01 to the next year, and the $year++ inside the loop then also corrupted all the following weeks of the month: every first day returned was the one of the next year (02, 09, 16, 23, 30 instead of 03, 10, 17, 24, 31 for January 2022). getLastDayOfEachWeek() had no year handling at all, so the trailing/leading weeks of the other year were resolved in the wrong year too. This made the per-month time spent view (projet/activity/permonth.php) display wrong week date ranges, link to the wrong week and compute the weekly holiday overlap on wrong date ranges for such months. Co-authored-by: Claude --- htdocs/core/lib/date.lib.php | 27 +++++++++++++++++--- test/phpunit/DateLibTest.php | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/htdocs/core/lib/date.lib.php b/htdocs/core/lib/date.lib.php index 9b07c05d7ab..72b88163c71 100644 --- a/htdocs/core/lib/date.lib.php +++ b/htdocs/core/lib/date.lib.php @@ -1200,11 +1200,20 @@ function getWeekNumbersOfMonth($month, $year) function getFirstDayOfEachWeek($TWeek, $year) { $TFirstDayOfWeek = array(); + // When a month overlaps 2 years, the weeks 52/53 of previous year and the week 01 of next year are present together in $TWeek. + // If $TWeek starts with week 52 or 53 (a January), these first weeks belong to previous year. + // If $TWeek ends with week 01 (a December), this last week belongs to next year. + $startwithweeksofpreviousyear = ((int) reset($TWeek) >= 52 && in_array('01', $TWeek)); foreach ($TWeek as $weekNb) { - if (in_array('01', $TWeek) && in_array('52', $TWeek) && $weekNb == '01') { - $year++; //Si on a la 1re semaine et la semaine 52 c'est qu'on change d'année + $yeartouse = $year; + if ($startwithweeksofpreviousyear) { + if ((int) $weekNb >= 52) { + $yeartouse = $year - 1; + } + } elseif ($weekNb == '01' && in_array('52', $TWeek)) { + $yeartouse = $year + 1; } - $TFirstDayOfWeek[$weekNb] = date('d', strtotime($year.'W'.$weekNb)); + $TFirstDayOfWeek[$weekNb] = date('d', strtotime($yeartouse.'W'.$weekNb)); } return $TFirstDayOfWeek; } @@ -1219,8 +1228,18 @@ function getFirstDayOfEachWeek($TWeek, $year) function getLastDayOfEachWeek($TWeek, $year) { $TLastDayOfWeek = array(); + // Same rule as in getFirstDayOfEachWeek() to find the year each week belongs to + $startwithweeksofpreviousyear = ((int) reset($TWeek) >= 52 && in_array('01', $TWeek)); foreach ($TWeek as $weekNb) { - $TLastDayOfWeek[$weekNb] = date('d', strtotime($year.'W'.$weekNb.'+6 days')); + $yeartouse = $year; + if ($startwithweeksofpreviousyear) { + if ((int) $weekNb >= 52) { + $yeartouse = $year - 1; + } + } elseif ($weekNb == '01' && in_array('52', $TWeek)) { + $yeartouse = $year + 1; + } + $TLastDayOfWeek[$weekNb] = date('d', strtotime($yeartouse.'W'.$weekNb.'+6 days')); } return $TLastDayOfWeek; } diff --git a/test/phpunit/DateLibTest.php b/test/phpunit/DateLibTest.php index b3c70e0409d..80252e2d8f1 100644 --- a/test/phpunit/DateLibTest.php +++ b/test/phpunit/DateLibTest.php @@ -496,6 +496,54 @@ class DateLibTest extends CommonClassTest return 1; } + /** + * testGetFirstDayOfEachWeek + * + * @return int + */ + public function testGetFirstDayOfEachWeek() + { + // June 2026 (no year overlap): weeks 23 to 27 + $TWeek = getWeekNumbersOfMonth(6, 2026); + $this->assertEquals(array('23' => '01', '24' => '08', '25' => '15', '26' => '22', '27' => '29'), getFirstDayOfEachWeek($TWeek, 2026)); + + // December 2025 ends with week 01 of 2026: week 01 starts on monday 2025-12-29 + $TWeek = getWeekNumbersOfMonth(12, 2025); + $this->assertEquals(array('49' => '01', '50' => '08', '51' => '15', '52' => '22', '01' => '29'), getFirstDayOfEachWeek($TWeek, 2025)); + + // January 2022 starts with week 52 of 2021 (monday 2021-12-27). Week 01 of 2022 starts on monday the 3rd, + // week 02 on the 10th, ... (weeks 01 to 05 must not be shifted to next year) + $TWeek = getWeekNumbersOfMonth(1, 2022); + $this->assertEquals(array('52' => '27', '01' => '03', '02' => '10', '03' => '17', '04' => '24', '05' => '31'), getFirstDayOfEachWeek($TWeek, 2022)); + + // January 2021 starts with week 53 of 2020 (monday 2020-12-28) + $TWeek = getWeekNumbersOfMonth(1, 2021); + $this->assertEquals(array('53' => '28', '01' => '04', '02' => '11', '03' => '18', '04' => '25'), getFirstDayOfEachWeek($TWeek, 2021)); + + return 1; + } + + /** + * testGetLastDayOfEachWeek + * + * @return int + */ + public function testGetLastDayOfEachWeek() + { + // June 2026 (no year overlap): weeks 23 to 27 + $TWeek = getWeekNumbersOfMonth(6, 2026); + $this->assertEquals(array('23' => '07', '24' => '14', '25' => '21', '26' => '28', '27' => '05'), getLastDayOfEachWeek($TWeek, 2026)); + + // December 2025 ends with week 01 of 2026: week 01 ends on sunday 2026-01-04 + $TWeek = getWeekNumbersOfMonth(12, 2025); + $this->assertEquals(array('49' => '07', '50' => '14', '51' => '21', '52' => '28', '01' => '04'), getLastDayOfEachWeek($TWeek, 2025)); + + // January 2022 starts with week 52 of 2021: week 52 ends on sunday 2022-01-02 + $TWeek = getWeekNumbersOfMonth(1, 2022); + $this->assertEquals(array('52' => '02', '01' => '09', '02' => '16', '03' => '23', '04' => '30', '05' => '06'), getLastDayOfEachWeek($TWeek, 2022)); + + return 1; + } /** * testDolGetFirstHour From 37017da5f57ab2de3dd6bede8e1523786cc41e6f Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sun, 19 Jul 2026 15:22:56 +0200 Subject: [PATCH 209/307] css --- htdocs/theme/eldy/global.inc.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/htdocs/theme/eldy/global.inc.php b/htdocs/theme/eldy/global.inc.php index b070873c9ff..9a357c8525d 100644 --- a/htdocs/theme/eldy/global.inc.php +++ b/htdocs/theme/eldy/global.inc.php @@ -519,6 +519,7 @@ section.setupsection { padding: 20px !important; background-color: var(--colorbackgrey); border-radius: 5px; + box-shadow: 1px 1px 3px rgb(0, 0, 0, 0.1); } section.setupsection:hover { box-shadow: 0 0 5px #aaa; @@ -2677,8 +2678,8 @@ td.showDragHandle { display: table-cell; - border-: 1px solid #ECECEC; - border-bottom: 1px solid #ECECEC; + border-: 1px solid #F0F0F0; + border-bottom: 1px solid #F0F0F0; box-shadow: 3px 0 6px -2px #eee; background: var(--colorbackvmenu1); transition: left 0.5s ease; From 2693fd8c925123ff827498d82d6d2520f8505603 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Sun, 19 Jul 2026 15:26:01 +0200 Subject: [PATCH 210/307] css --- htdocs/theme/eldy/info-box.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/theme/eldy/info-box.inc.php b/htdocs/theme/eldy/info-box.inc.php index 74a70aa2de0..6c63fe4c7e2 100644 --- a/htdocs/theme/eldy/info-box.inc.php +++ b/htdocs/theme/eldy/info-box.inc.php @@ -46,7 +46,7 @@ if (isModEnabled("bank") && isModEnabled("prelevement") && isModEnabled("payment width: 100%; - box-shadow: 0 0 5px rgba(128, 128, 128, 0.3); + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15); border-radius: 5px; /* border: 1px solid #e9e9e9; */ From e9e526c0320561d7f8d1e403fd064be331c978ea Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Sun, 19 Jul 2026 22:45:45 +0900 Subject: [PATCH 211/307] FIX #35658 leading-zero account numbers dropped from personalized group totals (#39208) In compta/resultat/result.php and clientfourn.php the account number was cast to (int) before being passed to AccountancyCategory::getSumDebitCredit(), which compares it as a string (t.numero_compte = '...'). (int) '0510' -> 510 -> '510' never matches the stored '0510' (numero_compte and account_number are varchar(32), so leading zeros are significant). Accounts whose code starts with 0 were therefore silently excluded from the current-year and per-month totals of a personalized account group, while the previous-year column was already correct because it passes a string array. The (int) cast had no functional purpose: it was added to silence a phan notice in "Qual: Fix phan notices" (#33484). Remove it and pass the raw string, and widen the getSumDebitCredit() @param to string|int|array so the string argument is accepted by static analysis and the cast is not reintroduced. Numeric codes are unaffected (the callee already casts the scalar with (string)). --- htdocs/accountancy/class/accountancycategory.class.php | 2 +- htdocs/compta/resultat/clientfourn.php | 2 +- htdocs/compta/resultat/result.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/accountancy/class/accountancycategory.class.php b/htdocs/accountancy/class/accountancycategory.class.php index 2c0d985e41e..0f028eb8781 100644 --- a/htdocs/accountancy/class/accountancycategory.class.php +++ b/htdocs/accountancy/class/accountancycategory.class.php @@ -624,7 +624,7 @@ class AccountancyCategory // extends CommonObject /** * Function to set the property ->sdc (and ->sdcperaccount) that is the result of an accounting account from the ledger with a direction and a period * - * @param int|array $cpt Accounting account or array of accounting account + * @param string|int|array $cpt Accounting account or array of accounting account * @param int $date_start Date start * @param int $date_end Date end * @param int<0,1> $sens Sens of the account: 0: credit - debit (use this by default), 1: debit - credit diff --git a/htdocs/compta/resultat/clientfourn.php b/htdocs/compta/resultat/clientfourn.php index 66a5130787e..f2de46762cf 100644 --- a/htdocs/compta/resultat/clientfourn.php +++ b/htdocs/compta/resultat/clientfourn.php @@ -386,7 +386,7 @@ if ($modecompta == 'BOOKKEEPING') { $cpts = $AccCat->getCptsCat(0, $tmppredefinedgroupwhere); foreach ($cpts as $j => $cpt) { - $return = $AccCat->getSumDebitCredit((int) $cpt['account_number'], $date_start, $date_end, (empty($cpt['dc']) ? 0 : $cpt['dc'])); + $return = $AccCat->getSumDebitCredit($cpt['account_number'], $date_start, $date_end, (empty($cpt['dc']) ? 0 : $cpt['dc'])); if ($return < 0) { setEventMessages(null, $AccCat->errors, 'errors'); $resultN = 0; diff --git a/htdocs/compta/resultat/result.php b/htdocs/compta/resultat/result.php index 09328db8ca3..57209912ec3 100644 --- a/htdocs/compta/resultat/result.php +++ b/htdocs/compta/resultat/result.php @@ -560,7 +560,7 @@ if ($modecompta == 'CREANCES-DETTES') { ) { //var_dump($monthtoprocess.'_'.$yeartoprocess); if (isset($cpt['account_number'])) { - $return = $AccCat->getSumDebitCredit((int) $cpt['account_number'], $date_start, $date_end, empty($cat['dc']) ? 0 : $cat['dc'], 'nofilter', $monthtoprocess, $yeartoprocess); + $return = $AccCat->getSumDebitCredit($cpt['account_number'], $date_start, $date_end, empty($cat['dc']) ? 0 : $cat['dc'], 'nofilter', $monthtoprocess, $yeartoprocess); if ($return < 0) { setEventMessages(null, $AccCat->errors, 'errors'); $resultM = 0; From 159802c08f9a5b121e0bcfd07cd6a4315502bb6d Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Sun, 19 Jul 2026 15:52:16 +0200 Subject: [PATCH 212/307] FIX Stripe payout: store payout reference (po_) in bank transfer (#39204) The payout.paid IPN handler recorded the bank transfer line with an empty num_chq (a "// TODO Store the po ref from $event->data" placeholder). The Stripe payout id is available as $event->data->object->id, so store it as the transfer reference. This makes auto-recorded payouts traceable and reconcilable against Stripe. Co-authored-by: Claude Opus 4.8 --- htdocs/public/stripe/ipn.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/public/stripe/ipn.php b/htdocs/public/stripe/ipn.php index 9435ef3e45f..4e80ace5a38 100644 --- a/htdocs/public/stripe/ipn.php +++ b/htdocs/public/stripe/ipn.php @@ -283,7 +283,7 @@ if ($event->type == 'payout.created' && getDolGlobalString('STRIPE_AUTO_RECORD_P $typefrom = 'PRE'; $typeto = 'VIR'; - $numChqOrOpe = ''; // TODO Store the po ref from $event->data + $numChqOrOpe = $event->data->object->id; // Store the payout reference (po_...) as the bank transfer number $db->begin(); From 118ae2e655a3b3ddd57e7c9b97b42d9b555a3b87 Mon Sep 17 00:00:00 2001 From: Joachim Kueter Date: Sun, 19 Jul 2026 15:52:40 +0200 Subject: [PATCH 213/307] FIX #39210 Contact extrafields filters missing on advanced emailing target selection (#39211) Typo $elementype vs $elementtype: extrafields were fetched for socpeople but read under the stale key societe, so the contact extrafields section rendered empty. --- htdocs/comm/mailing/advtargetemailing.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/comm/mailing/advtargetemailing.php b/htdocs/comm/mailing/advtargetemailing.php index acc1adf3024..1684b9904fb 100644 --- a/htdocs/comm/mailing/advtargetemailing.php +++ b/htdocs/comm/mailing/advtargetemailing.php @@ -1084,11 +1084,11 @@ if ($object->fetch($id) >= 0) { // Standard Extrafield feature if (!getDolGlobalString('MAIN_EXTRAFIELDS_DISABLED')) { $contactstatic = new Contact($db); - $elementype = $contactstatic->table_element; + $elementtype = $contactstatic->table_element; // fetch optionals attributes and labels dol_include_once('/core/class/extrafields.class.php'); $extrafields = new ExtraFields($db); - $extrafields->fetch_name_optionals_label($elementype); + $extrafields->fetch_name_optionals_label($elementtype); if (!empty($extrafields->attributes[$elementtype]['type'])) { foreach ($extrafields->attributes[$elementtype]['type'] as $key => &$value) { if ($value == 'radio') { From 6dac0c7263e2e9cae57d6cbd998d2d1919e45b56 Mon Sep 17 00:00:00 2001 From: MDW Date: Sun, 19 Jul 2026 15:53:14 +0200 Subject: [PATCH 214/307] Qual: Fix 'SqlInjection' notices (#39203) # Qual: Fix 'SqlInjection' notices Batch of 25 files with changes to fix 'SqlInjection' Notices --- .../accountancy/class/bookkeeping.class.php | 38 ++++----- htdocs/admin/dict.php | 4 +- htdocs/blockedlog/class/blockedlog.class.php | 16 ++-- htdocs/comm/action/class/actioncomm.class.php | 18 ++--- htdocs/comm/propal/class/propal.class.php | 18 ++--- htdocs/commande/class/commande.class.php | 38 ++++----- .../facture/class/facture-rec.class.php | 30 +++---- htdocs/compta/facture/class/facture.class.php | 22 ++--- .../class/bonprelevement.class.php | 2 +- htdocs/core/class/commonobject.class.php | 80 ++++++++++--------- htdocs/core/class/conf.class.php | 4 +- htdocs/core/class/extrafields.class.php | 15 ++-- htdocs/core/class/html.form.class.php | 11 +-- htdocs/core/class/html.formticket.class.php | 2 +- htdocs/core/lib/admin.lib.php | 58 +++++++------- htdocs/core/lib/company.lib.php | 4 +- htdocs/core/lib/functions.lib.php | 16 ++-- htdocs/core/modules/DolibarrModules.class.php | 34 ++++---- .../class/emailcollector.class.php | 56 ++++++------- .../class/fournisseur.commande.class.php | 44 +++++----- htdocs/install/upgrade2.php | 22 ++--- htdocs/product/class/product.class.php | 6 +- htdocs/public/members/new.php | 6 +- htdocs/societe/class/societe.class.php | 28 +++---- htdocs/ticket/class/ticket.class.php | 42 +++++----- 25 files changed, 309 insertions(+), 305 deletions(-) diff --git a/htdocs/accountancy/class/bookkeeping.class.php b/htdocs/accountancy/class/bookkeeping.class.php index 096fe121731..3f09411ae60 100644 --- a/htdocs/accountancy/class/bookkeeping.class.php +++ b/htdocs/accountancy/class/bookkeeping.class.php @@ -3,7 +3,7 @@ * Copyright (C) 2015-2026 Alexandre Spangaro * Copyright (C) 2015-2020 Florian Henry * Copyright (C) 2018-2025 Frédéric France - * Copyright (C) 2024-2026 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 Jose MARTINEZ * Copyright (C) 2025 Nicolas Barrouillet * @@ -350,7 +350,7 @@ class BookKeeping extends CommonObject if (!empty($this->subledger_account)) { $sql .= " AND subledger_account = '".$this->db->escape($this->subledger_account)."'"; } - $sql .= " AND entity = ".$conf->entity; // Do not use getEntity for accounting features + $sql .= " AND entity = ".((int) $conf->entity); // Do not use getEntity for accounting features // Allow duplicates in incomes or loss statements $accountProfit = getDolGlobalString('ACCOUNTING_RESULT_PROFIT'); @@ -377,7 +377,7 @@ class BookKeeping extends CommonObject $sqlnum .= " AND fk_docdet = ".((int) $this->fk_docdet); } $sqlnum .= " AND doc_ref = '".$this->db->escape($this->doc_ref)."'"; // ref of source object - $sqlnum .= " AND entity = ".$conf->entity; // Do not use getEntity for accounting features + $sqlnum .= " AND entity = ".((int) $conf->entity); // Do not use getEntity for accounting features dol_syslog(get_class($this).":: create sqlnum=".$sqlnum, LOG_DEBUG); $resqlnum = $this->db->query($sqlnum); @@ -461,7 +461,7 @@ class BookKeeping extends CommonObject $sql .= ", ".(!empty($this->journal_label) ? ("'".$this->db->escape($this->journal_label)."'") : "NULL"); $sql .= ", ".((int) $this->piece_num); $sql .= ", '".$this->db->escape($this->ref)."'"; - $sql .= ", ".(!isset($this->entity) ? $conf->entity : $this->entity); + $sql .= ", ".(!isset($this->entity) ? ((int) $conf->entity) : ((int) $this->entity)); $sql .= ")"; $resql = $this->db->query($sql); @@ -793,9 +793,9 @@ class BookKeeping extends CommonObject $sql .= ' '.(!isset($this->numero_compte) ? 'NULL' : "'".$this->db->escape($this->numero_compte)."'").','; $sql .= ' '.(!isset($this->label_compte) ? 'NULL' : "'".$this->db->escape($this->label_compte)."'").','; $sql .= ' '.(!isset($this->label_operation) ? 'NULL' : "'".$this->db->escape($this->label_operation)."'").','; - $sql .= ' '.(!isset($this->debit) ? 'NULL' : $this->debit).','; - $sql .= ' '.(!isset($this->credit) ? 'NULL' : $this->credit).','; - $sql .= ' '.(!isset($this->montant) ? 'NULL' : $this->montant).','; + $sql .= ' '.(!isset($this->debit) ? 'NULL' : ((float) $this->debit)).','; + $sql .= ' '.(!isset($this->credit) ? 'NULL' : ((float) $this->credit)).','; + $sql .= ' '.(!isset($this->montant) ? 'NULL' : ((float) $this->montant)).','; $sql .= ' '.(!isset($this->sens) ? 'NULL' : "'".$this->db->escape($this->sens)."'").','; $sql .= ' '.((int) $user->id).','; $sql .= ' '."'".$this->db->idate($now)."',"; @@ -803,7 +803,7 @@ class BookKeeping extends CommonObject $sql .= ' '.(empty($this->journal_label) ? 'NULL' : "'".$this->db->escape($this->journal_label)."'").','; $sql .= ' '.(empty($this->piece_num) ? 'NULL' : $this->db->escape((string) $this->piece_num)).','; $sql .= ' '.(empty($this->ref) ? "''" : "'".$this->db->escape($this->ref)."'").','; - $sql .= ' '.(!isset($this->entity) ? $conf->entity : $this->entity); + $sql .= ' '.(!isset($this->entity) ? ((int) $conf->entity) : ((int) $this->entity)); $sql .= ')'; $this->db->begin(); @@ -1565,23 +1565,23 @@ class BookKeeping extends CommonObject $sql .= ' doc_date = '.(isDolTms($this->doc_date) ? "'".$this->db->idate($this->doc_date)."'" : 'null').','; $sql .= ' doc_type = '.(isset($this->doc_type) ? "'".$this->db->escape($this->doc_type)."'" : "null").','; $sql .= ' doc_ref = '.(isset($this->doc_ref) ? "'".$this->db->escape($this->doc_ref)."'" : "null").','; - $sql .= ' fk_doc = '.(isset($this->fk_doc) ? $this->fk_doc : "null").','; - $sql .= ' fk_docdet = '.(isset($this->fk_docdet) ? $this->fk_docdet : "null").','; + $sql .= ' fk_doc = '.(isset($this->fk_doc) ? ((int) $this->fk_doc) : "null").','; + $sql .= ' fk_docdet = '.(isset($this->fk_docdet) ? ((int) $this->fk_docdet) : "null").','; $sql .= ' thirdparty_code = '.(isset($this->thirdparty_code) ? "'".$this->db->escape($this->thirdparty_code)."'" : "null").','; $sql .= ' subledger_account = '.(isset($this->subledger_account) ? "'".$this->db->escape($this->subledger_account)."'" : "null").','; $sql .= ' subledger_label = '.(isset($this->subledger_label) ? "'".$this->db->escape($this->subledger_label)."'" : "null").','; $sql .= ' numero_compte = '.(isset($this->numero_compte) ? "'".$this->db->escape($this->numero_compte)."'" : "null").','; $sql .= ' label_compte = '.(isset($this->label_compte) ? "'".$this->db->escape($this->label_compte)."'" : "null").','; $sql .= ' label_operation = '.(isset($this->label_operation) ? "'".$this->db->escape($this->label_operation)."'" : "null").','; - $sql .= ' debit = '.(isset($this->debit) ? $this->debit : "null").','; - $sql .= ' credit = '.(isset($this->credit) ? $this->credit : "null").','; - $sql .= ' montant = '.(isset($this->montant) ? $this->montant : "null").','; + $sql .= ' debit = '.(isset($this->debit) ? ((float) $this->debit) : "null").','; + $sql .= ' credit = '.(isset($this->credit) ? ((float) $this->credit) : "null").','; + $sql .= ' montant = '.(isset($this->montant) ? ((float) $this->montant) : "null").','; $sql .= ' sens = '.(isset($this->sens) ? "'".$this->db->escape($this->sens)."'" : "null").','; - $sql .= ' fk_user_author = '.(isset($this->fk_user_author) ? $this->fk_user_author : "null").','; + $sql .= ' fk_user_author = '.(isset($this->fk_user_author) ? ((int) $this->fk_user_author) : "null").','; $sql .= ' import_key = '.(isset($this->import_key) ? "'".$this->db->escape($this->import_key)."'" : "null").','; $sql .= ' code_journal = '.(isset($this->code_journal) ? "'".$this->db->escape($this->code_journal)."'" : "null").','; $sql .= ' journal_label = '.(isset($this->journal_label) ? "'".$this->db->escape($this->journal_label)."'" : "null").','; - $sql .= ' piece_num = '.(isset($this->piece_num) ? $this->piece_num : "null"); + $sql .= ' piece_num = '.(isset($this->piece_num) ? ((int) $this->piece_num) : "null"); $sql .= ' WHERE rowid='.((int) $this->id); $this->db->begin(); @@ -2291,7 +2291,7 @@ class BookKeeping extends CommonObject $sql .= ' doc_ref, fk_doc, fk_docdet, entity, thirdparty_code, subledger_account, subledger_label,'; $sql .= ' numero_compte, label_compte, label_operation, debit, credit,'; $sql .= ' montant, sens, fk_user_author, import_key, code_journal, journal_label, piece_num, date_creation)'; - $sql .= ' SELECT doc_date, doc_type,' . "'{$ref}',"; + $sql .= ' SELECT doc_date, doc_type,' . "'".$this->db->escape($ref)."',"; $sql .= ' doc_ref, fk_doc, fk_docdet, entity, thirdparty_code, subledger_account, subledger_label,'; $sql .= ' numero_compte, label_compte, label_operation, debit, credit,'; $sql .= ' montant, sens, fk_user_author, import_key, code_journal, journal_label, '.((int) $next_piecenum).", '".$this->db->idate($now)."'"; @@ -2328,7 +2328,7 @@ class BookKeeping extends CommonObject $sql .= ' doc_ref, fk_doc, fk_docdet, thirdparty_code, subledger_account, subledger_label,'; $sql .= ' numero_compte, label_compte, label_operation, debit, credit,'; $sql .= ' montant, sens, fk_user_author, import_key, code_journal, journal_label, piece_num)'; - $sql .= ' SELECT doc_date, doc_type,' . "'{$ref}',"; + $sql .= ' SELECT doc_date, doc_type,' . "'".$this->db->escape($ref)."',"; $sql .= ' doc_ref, fk_doc, fk_docdet, thirdparty_code, subledger_account, subledger_label,'; $sql .= ' numero_compte, label_compte, label_operation, debit, credit,'; $sql .= ' montant, sens, fk_user_author, import_key, code_journal, journal_label, piece_num'; @@ -3651,7 +3651,7 @@ class BookKeeping extends CommonObject $bookKeepingInstance = new BookKeeping($this->db); $pieceNumNext = $bookKeepingInstance->getNextNumMvt(); $cloneId = []; - $sqlRowidClone = "SELECT rowid FROM " . MAIN_DB_PREFIX . "accounting_bookkeeping WHERE piece_num = $pieceNum"; + $sqlRowidClone = "SELECT rowid FROM " . MAIN_DB_PREFIX . "accounting_bookkeeping WHERE piece_num = ".((int) $pieceNum); $resqlRowidClone = $this->db->query($sqlRowidClone); if ($resqlRowidClone) { @@ -3699,7 +3699,7 @@ class BookKeeping extends CommonObject $sql_insert .= ", montant"; $sql_insert .= ")"; $sql_insert .= " VALUES ("; - $sql_insert .= $pieceNumNext; + $sql_insert .= ((int) $pieceNumNext); $sql_insert .= ", '" . $this->db->escape($obj->label_operation) . "'"; $sql_insert .= ", '" . $this->db->escape($obj->numero_compte) . "'"; $sql_insert .= ", '" . $this->db->escape($obj->label_compte) . "'"; diff --git a/htdocs/admin/dict.php b/htdocs/admin/dict.php index 8d9c47ada43..277d106f3f0 100644 --- a/htdocs/admin/dict.php +++ b/htdocs/admin/dict.php @@ -701,7 +701,7 @@ if (empty($sortfield)) { $tmp1 = explode(',', empty($tabcomplete[$keytable]['sqlsort']) ? '' : $tabcomplete[$keytable]['sqlsort']); $tmp2 = explode(' ', $tmp1[0]); $sortfield = preg_replace('/^.*\./', '', $tmp2[0]); - $sortorder = (!empty($tmp2[1]) ? $tmp2[1] : ''); + $sortorder = (!empty($tmp2[1]) ? $tmp2[1] : ''); // @phan-suppress-current-line SqlInjection //var_dump($sortfield); //var_dump($sortorder); } @@ -984,7 +984,7 @@ if (empty($reshook)) { // List of values if ($tabrowid[$id] && !in_array($tabrowid[$id], $listfieldinsert)) { - $sql .= $newid.","; + $sql .= ((int) $newid).","; } $i = 0; foreach ($listfieldinsert as $f => $value) { diff --git a/htdocs/blockedlog/class/blockedlog.class.php b/htdocs/blockedlog/class/blockedlog.class.php index 08f507749b3..8e44413bfdb 100644 --- a/htdocs/blockedlog/class/blockedlog.class.php +++ b/htdocs/blockedlog/class/blockedlog.class.php @@ -1410,7 +1410,7 @@ class BlockedLog $sql .= "0,"; $sql .= ((int) $this->fk_user).","; $sql .= "'".$this->db->escape($this->user_fullname)."',"; - $sql .= ((int) ($this->entity ? $this->entity : $conf->entity)).","; + $sql .= ((int) ($this->entity ? ((int) $this->entity) : ((int) $conf->entity))).","; $sql .= "'".$this->db->escape($this->debuginfo)."'"; $sql .= ")"; @@ -2228,16 +2228,16 @@ class BlockedLog if ($element == 'all') { $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."blockedlog - WHERE entity = ".$conf->entity; + WHERE entity = ".((int) $conf->entity); } elseif ($element == 'not_certified') { $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."blockedlog - WHERE entity = ".$conf->entity." AND certified = 0"; + WHERE entity = ".((int) $conf->entity)." AND certified = 0"; } elseif ($element == 'just_certified') { $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."blockedlog - WHERE entity = ".$conf->entity." AND certified = 1"; + WHERE entity = ".((int) $conf->entity)." AND certified = 1"; } else { $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."blockedlog - WHERE entity = ".$conf->entity." AND element = '".$this->db->escape($element)."'"; + WHERE entity = ".((int) $conf->entity)." AND element = '".$this->db->escape($element)."'"; } if ($fk_object) { @@ -2290,9 +2290,9 @@ class BlockedLog } } if (!empty($search_module_source)) { - $tmp = natural_search("module_source", implode(',', $search_module_source), 0, 1); - $tmp = str_replace('%backoffice%', '', $tmp); - $sql .= $tmp; + $sqlTmp = natural_search("module_source", implode(',', $search_module_source), 0, 1); + $sqlTmp = str_replace('%backoffice%', '', $sqlTmp); + $sql .= $sqlTmp; } $sql .= " OR module_source = 'mix'"; // When a payment was recorded and payment was on an invoice with different origins (pos and not pos) $sql .= ")"; diff --git a/htdocs/comm/action/class/actioncomm.class.php b/htdocs/comm/action/class/actioncomm.class.php index cfe601075d4..a8ce446b104 100644 --- a/htdocs/comm/action/class/actioncomm.class.php +++ b/htdocs/comm/action/class/actioncomm.class.php @@ -6,7 +6,7 @@ * Copyright (C) 2015 Marcos García * Copyright (C) 2018 Nicolas ZABOURI * Copyright (C) 2018-2025 Frédéric France - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 William Mead * * This program is free software; you can redistribute it and/or modify @@ -651,15 +651,15 @@ class ActionComm extends CommonObject $sql .= "'".$this->db->idate($this->datep)."', "; // date start event $sql .= (strval($this->datef) != '' ? "'".$this->db->idate($this->datef)."'" : "null").", "; $sql .= ((isset($this->durationp) && $this->durationp >= 0 && $this->durationp != '') ? "'".$this->db->escape((string) $this->durationp)."'" : "null").", "; // deprecated - $sql .= (isset($this->type_id) ? $this->type_id : "null").","; + $sql .= (isset($this->type_id) ? ((int) $this->type_id) : "null").","; $sql .= ($code ? ("'".$this->db->escape($code)."'") : "null").", "; $sql .= (!empty($this->ref_ext) ? "'".$this->db->escape($this->ref_ext)."'" : "null").", "; $sql .= ((isset($this->socid) && $this->socid > 0) ? ((int) $this->socid) : "null").", "; $sql .= ((isset($this->fk_project) && $this->fk_project > 0) ? ((int) $this->fk_project) : "null").", "; $sql .= " '".$this->db->escape($this->note_private)."', "; $sql .= ((isset($this->contact_id) && $this->contact_id > 0) ? ((int) $this->contact_id) : "null").", "; // deprecated, use ->socpeopleassigned - $sql .= (isset($user->id) && $user->id > 0 ? $user->id : "null").", "; - $sql .= ($userownerid > 0 ? $userownerid : "null").", "; + $sql .= (isset($user->id) && $user->id > 0 ? ((int) $user->id) : "null").", "; + $sql .= ($userownerid > 0 ? ((int) $userownerid) : "null").", "; $sql .= (!empty($this->fk_task) ? ((int) $this->fk_task) : "null").", "; $sql .= "'".$this->db->escape($this->label)."', "; $sql .= "'".$this->db->escape((string) $this->percentage)."', "; @@ -695,7 +695,7 @@ class ActionComm extends CommonObject if ($resql) { $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX."actioncomm", "id"); $this->ref = (string) $this->id; - $sql = "UPDATE ".MAIN_DB_PREFIX."actioncomm SET ref='".$this->db->escape($this->ref)."' WHERE id=".$this->id; + $sql = "UPDATE ".MAIN_DB_PREFIX."actioncomm SET ref='".$this->db->escape($this->ref)."' WHERE id=".((int) $this->id); $resql = $this->db->query($sql); if (!$resql) { $error++; @@ -1406,13 +1406,13 @@ class ActionComm extends CommonObject * @param int $socid Filter by thirdparty * @param int $fk_element Id of element action is linked to * @param string $elementtype Type of element action is linked to - * @param string $filter Other filter + * @param string $sqlfilter Other filter * @param string $sortfield Sort on this field * @param string $sortorder ASC or DESC * @param int $limit Limit number of answers * @return ActionComm[]|string Error string if KO, array with actions if OK */ - public function getActions($socid = 0, $fk_element = 0, $elementtype = '', $filter = '', $sortfield = 'a.datep', $sortorder = 'DESC', $limit = 0) + public function getActions($socid = 0, $fk_element = 0, $elementtype = '', $sqlfilter = '', $sortfield = 'a.datep', $sortorder = 'DESC', $limit = 0) { global $hookmanager; @@ -1461,8 +1461,8 @@ class ActionComm extends CommonObject $sql .= " AND a.fk_element = ".((int) $fk_element)." AND a.elementtype = '".$this->db->escape($elementtype)."'"; } } - if (!empty($filter)) { - $sql .= $filter; + if (!empty($sqlfilter)) { + $sql .= $sqlfilter; } // Fields where hook $parameters = array('sql' => &$sql, 'socid' => $socid, 'fk_element' => $fk_element, 'elementtype' => $elementtype); diff --git a/htdocs/comm/propal/class/propal.class.php b/htdocs/comm/propal/class/propal.class.php index 2aa29f0dcfe..882ca701000 100644 --- a/htdocs/comm/propal/class/propal.class.php +++ b/htdocs/comm/propal/class/propal.class.php @@ -1264,7 +1264,7 @@ class Propal extends CommonObject $sql .= ", multicurrency_tx"; $sql .= ") "; $sql .= " VALUES ("; - $sql .= $this->socid; + $sql .= ((int) $this->socid); $sql .= ", 0"; $sql .= ", 0"; $sql .= ", 0"; @@ -1283,11 +1283,11 @@ class Propal extends CommonObject $sql .= ", '".$this->db->escape((string) $this->ref_client)."'"; $sql .= ", '".$this->db->escape((string) $this->ref_ext)."'"; $sql .= ", ".(!isDolTms($delivery_date) ? "NULL" : "'".$this->db->idate($delivery_date)."'"); - $sql .= ", ".($this->shipping_method_id > 0 ? $this->shipping_method_id : 'NULL'); - $sql .= ", ".($this->warehouse_id > 0 ? $this->warehouse_id : 'NULL'); + $sql .= ", ".($this->shipping_method_id > 0 ? ((int) $this->shipping_method_id) : 'NULL'); + $sql .= ", ".($this->warehouse_id > 0 ? ((int) $this->warehouse_id) : 'NULL'); $sql .= ", ".((int) $this->availability_id); $sql .= ", ".((int) $this->demand_reason_id); - $sql .= ", ".($this->fk_project ? $this->fk_project : "null"); + $sql .= ", ".($this->fk_project ? ((int) $this->fk_project) : "null"); $sql .= ", ".(int) $this->fk_incoterms; $sql .= ", '".$this->db->escape($this->location_incoterms)."'"; $sql .= ", ".(int) $this->entity; @@ -2162,7 +2162,7 @@ class Propal extends CommonObject $this->error = $this->db->lasterror(); } $sql = 'UPDATE '.MAIN_DB_PREFIX."ecm_files set filepath = 'propale/".$this->db->escape($this->newref)."'"; - $sql .= " WHERE filepath = 'propale/".$this->db->escape($this->ref)."' and entity = ".$conf->entity; + $sql .= " WHERE filepath = 'propale/".$this->db->escape($this->ref)."' and entity = ".((int) $conf->entity); $resql = $this->db->query($sql); if (!$resql) { $error++; @@ -2422,7 +2422,7 @@ class Propal extends CommonObject $this->db->begin(); $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element; - $sql .= " SET fk_availability = ".((int) ($id > 0 ? $id : 0)); + $sql .= " SET fk_availability = ".((int) ($id > 0 ? ((int) $id) : 0)); $sql .= " WHERE rowid = ".((int) $this->id); dol_syslog(__METHOD__.' availability('.$id.')', LOG_DEBUG); @@ -2690,7 +2690,7 @@ class Propal extends CommonObject $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element; $sql .= " SET fk_statut = ".((int) $status).", note_private = '".$this->db->escape($newprivatenote)."', note_public = '".$this->db->escape($newpublicnote)."'"; if ($status == self::STATUS_SIGNED) { - $sql .= ", date_signature='".$this->db->idate($now)."', fk_user_signature = ".($fk_user_signature); + $sql .= ", date_signature='".$this->db->idate($now)."', fk_user_signature = ".((int) $fk_user_signature); } $sql .= " WHERE rowid = ".((int) $this->id); @@ -3110,8 +3110,8 @@ class Propal extends CommonObject $tab_sqlobj = array(); $nump = $this->db->num_rows($resql); for ($i = 0; $i < $nump; $i++) { - $sqlobj = $this->db->fetch_object($resql); - $tab_sqlobj[] = $sqlobj; + $obj = $this->db->fetch_object($resql); + $tab_sqlobj[] = $obj; } $this->db->free($resql); diff --git a/htdocs/commande/class/commande.class.php b/htdocs/commande/class/commande.class.php index f58dbd84533..f43f1156c05 100644 --- a/htdocs/commande/class/commande.class.php +++ b/htdocs/commande/class/commande.class.php @@ -618,14 +618,14 @@ class Commande extends CommonOrder if (preg_match('/^[\(]?PROV/i', $this->ref)) { // Now we rename also files into index $sql = 'UPDATE '.MAIN_DB_PREFIX."ecm_files set filename = CONCAT('".$this->db->escape($this->newref)."', SUBSTR(filename, ".(strlen($this->ref) + 1).")), filepath = 'commande/".$this->db->escape($this->newref)."'"; - $sql .= " WHERE filename LIKE '".$this->db->escape($this->ref)."%' AND filepath = 'commande/".$this->db->escape($this->ref)."' and entity = ".$conf->entity; + $sql .= " WHERE filename LIKE '".$this->db->escape($this->ref)."%' AND filepath = 'commande/".$this->db->escape($this->ref)."' and entity = ".((int) $conf->entity); $resql = $this->db->query($sql); if (!$resql) { $error++; $this->error = $this->db->lasterror(); } $sql = 'UPDATE '.MAIN_DB_PREFIX."ecm_files set filepath = 'commande/".$this->db->escape($this->newref)."'"; - $sql .= " WHERE filepath = 'commande/".$this->db->escape($this->ref)."' and entity = ".$conf->entity; + $sql .= " WHERE filepath = 'commande/".$this->db->escape($this->ref)."' and entity = ".((int) $conf->entity); $resql = $this->db->query($sql); if (!$resql) { $error++; @@ -3443,29 +3443,29 @@ class Commande extends CommonOrder $sql .= " ref=".(isset($this->ref) ? "'".$this->db->escape($this->ref)."'" : "null").","; $sql .= " ref_client=".(isset($this->ref_client) ? "'".$this->db->escape($this->ref_client)."'" : "null").","; $sql .= " ref_ext=".(isset($this->ref_ext) ? "'".$this->db->escape($this->ref_ext)."'" : "null").","; - $sql .= " fk_soc=".(isset($this->socid) ? $this->socid : "null").","; + $sql .= " fk_soc=".(isset($this->socid) ? ((int) $this->socid) : "null").","; $sql .= " date_commande=".(strval($this->date_commande) != '' ? "'".$this->db->idate($this->date_commande)."'" : 'null').","; $sql .= " date_valid=".(strval($this->date_validation) != '' ? "'".$this->db->idate($this->date_validation)."'" : 'null').","; - $sql .= " total_tva=".(isset($this->total_tva) ? $this->total_tva : "null").","; - $sql .= " localtax1=".(isset($this->total_localtax1) ? $this->total_localtax1 : "null").","; - $sql .= " localtax2=".(isset($this->total_localtax2) ? $this->total_localtax2 : "null").","; - $sql .= " total_ht=".(isset($this->total_ht) ? $this->total_ht : "null").","; - $sql .= " total_ttc=".(isset($this->total_ttc) ? $this->total_ttc : "null").","; - $sql .= " fk_statut=".(isset($this->status) ? $this->status : "null").","; - $sql .= " fk_user_modif=".(isset($user->id) ? $user->id : "null").","; - $sql .= " fk_user_valid=".((isset($this->user_validation_id) && $this->user_validation_id > 0) ? $this->user_validation_id : "null").","; - $sql .= " fk_projet=".(isset($this->fk_project) ? $this->fk_project : "null").","; - $sql .= " fk_cond_reglement=".(isset($this->cond_reglement_id) ? $this->cond_reglement_id : "null").","; - $sql .= " deposit_percent=".(!empty($this->deposit_percent) ? strval($this->deposit_percent) : "null").","; - $sql .= " fk_mode_reglement=".(isset($this->mode_reglement_id) ? $this->mode_reglement_id : "null").","; + $sql .= " total_tva=".(isset($this->total_tva) ? ((float) $this->total_tva) : "null").","; + $sql .= " localtax1=".(isset($this->total_localtax1) ? ((float) $this->total_localtax1) : "null").","; + $sql .= " localtax2=".(isset($this->total_localtax2) ? ((float) $this->total_localtax2) : "null").","; + $sql .= " total_ht=".(isset($this->total_ht) ? ((float) $this->total_ht) : "null").","; + $sql .= " total_ttc=".(isset($this->total_ttc) ? ((float) $this->total_ttc) : "null").","; + $sql .= " fk_statut=".(isset($this->status) ? ((int) $this->status) : "null").","; + $sql .= " fk_user_modif=".(isset($user->id) ? ((int) $user->id) : "null").","; + $sql .= " fk_user_valid=".((isset($this->user_validation_id) && $this->user_validation_id > 0) ? ((int) $this->user_validation_id) : "null").","; + $sql .= " fk_projet=".(isset($this->fk_project) ? ((int) $this->fk_project) : "null").","; + $sql .= " fk_cond_reglement=".(isset($this->cond_reglement_id) ? ((int) $this->cond_reglement_id) : "null").","; + $sql .= " deposit_percent=".(!empty($this->deposit_percent) ? ((float) $this->deposit_percent) : "null").","; + $sql .= " fk_mode_reglement=".(isset($this->mode_reglement_id) ? ((int) $this->mode_reglement_id) : "null").","; $sql .= " date_livraison=".(strval($this->delivery_date) != '' ? "'".$this->db->idate($this->delivery_date)."'" : 'null').","; - $sql .= " fk_shipping_method=".(isset($this->shipping_method_id) ? $this->shipping_method_id : "null").","; - $sql .= " fk_account=".($this->fk_account > 0 ? $this->fk_account : "null").","; - $sql .= " fk_input_reason=".($this->demand_reason_id > 0 ? $this->demand_reason_id : "null").","; + $sql .= " fk_shipping_method=".(isset($this->shipping_method_id) ? ((int) $this->shipping_method_id) : "null").","; + $sql .= " fk_account=".($this->fk_account > 0 ? ((int) $this->fk_account) : "null").","; + $sql .= " fk_input_reason=".($this->demand_reason_id > 0 ? ((int) $this->demand_reason_id) : "null").","; $sql .= " note_private=".(isset($this->note_private) ? "'".$this->db->escape($this->note_private)."'" : "null").","; $sql .= " note_public=".(isset($this->note_public) ? "'".$this->db->escape($this->note_public)."'" : "null").","; $sql .= " model_pdf=".(isset($this->model_pdf) ? "'".$this->db->escape($this->model_pdf)."'" : "null").","; - $sql .= " fk_warehouse=".($this->warehouse_id > 0 ? $this->warehouse_id : "null").","; + $sql .= " fk_warehouse=".($this->warehouse_id > 0 ? ((int) $this->warehouse_id) : "null").","; $sql .= " import_key=".(isset($this->import_key) ? "'".$this->db->escape($this->import_key)."'" : "null").","; $sql .= " module_source = ".(isset($this->module_source) ? "'".$this->db->escape($this->module_source)."'" : "null").","; $sql .= " pos_source = ".(isset($this->pos_source) ? "'".$this->db->escape($this->pos_source)."'" : "null"); diff --git a/htdocs/compta/facture/class/facture-rec.class.php b/htdocs/compta/facture/class/facture-rec.class.php index 3a1b06663ff..6d4d84071f6 100644 --- a/htdocs/compta/facture/class/facture-rec.class.php +++ b/htdocs/compta/facture/class/facture-rec.class.php @@ -909,10 +909,10 @@ class FactureRec extends CommonInvoice $error = 0; $this->db->begin(); - $main = MAIN_DB_PREFIX.'facturedet_rec'; - $ef = $main."_extrafields"; + $sql_main_table = MAIN_DB_PREFIX.'facturedet_rec'; + $sql_ef_table = $sql_main_table."_extrafields"; - $sqlef = "DELETE FROM $ef WHERE fk_object IN (SELECT rowid FROM ".$main." WHERE fk_facture = ".((int) $rowid).")"; + $sqlef = "DELETE FROM $sql_ef_table WHERE fk_object IN (SELECT rowid FROM ".$sql_main_table." WHERE fk_facture = ".((int) $rowid).")"; $sql = "DELETE FROM ".MAIN_DB_PREFIX."facturedet_rec WHERE fk_facture = ".((int) $rowid); if ($this->db->query($sqlef) && $this->db->query($sql)) { @@ -1133,7 +1133,7 @@ class FactureRec extends CommonInvoice $sql .= ", ".price2num($total_ttc); $sql .= ", ".(int) $date_start_fill; $sql .= ", ".(int) $date_end_fill; - $sql .= ", ".($fk_fournprice > 0 ? $fk_fournprice : 'null'); + $sql .= ", ".($fk_fournprice > 0 ? ((int) $fk_fournprice) : 'null'); $sql .= ", ".($pa_ht ? price2num($pa_ht) : 0); $sql .= ", ".((int) $info_bits); $sql .= ", ".((int) $ranktouse); @@ -1634,11 +1634,11 @@ class FactureRec extends CommonInvoice } // Sender - $from = getDolGlobalString('MAIN_MAIL_EMAIL_FROM'); + $email_from = getDolGlobalString('MAIN_MAIL_EMAIL_FROM'); if (!empty($arraymessage->email_from)) { // If a sender is defined into template, we use it in priority - $from = (string) $arraymessage->email_from; + $email_from = (string) $arraymessage->email_from; } - if (empty($from)) { + if (empty($email_from)) { $errormesg = "Failed to get sender into global setup MAIN_MAIL_EMAIL_FROM"; $loopError++; } @@ -1678,7 +1678,7 @@ class FactureRec extends CommonInvoice } // Mail Creation - $cMailFile = new CMailFile($sendTopic, $to, $from, $sendContent, $joinFile, $joinFileMime, $joinFileName, $email_tocc, $email_tobcc, 0, 1, $errors_to, '', $trackid, '', $sendcontext, ''); + $cMailFile = new CMailFile($sendTopic, $to, $email_from, $sendContent, $joinFile, $joinFileMime, $joinFileName, $email_tocc, $email_tobcc, 0, 1, $errors_to, '', $trackid, '', $sendcontext, ''); $resultsendmail = $cMailFile->sendfile(); @@ -1699,7 +1699,7 @@ class FactureRec extends CommonInvoice $actioncomm->contact_id = 0; $actioncomm->code = 'AC_EMAIL'; - $actioncomm->label = $langs->trans('MailSentByTo', $from, $to); + $actioncomm->label = $langs->trans('MailSentByTo', $email_from, $to); $actioncomm->note_private = $sendContent; $actioncomm->fk_project = $facture->fk_project; $actioncomm->datep = dol_now(); @@ -1710,7 +1710,7 @@ class FactureRec extends CommonInvoice // Fields when action is an email (content should be added into note) $actioncomm->email_msgid = $cMailFile->msgid; $actioncomm->email_subject = $sendTopic; - $actioncomm->email_from = $from; + $actioncomm->email_from = $email_from; $actioncomm->email_sender = ''; $actioncomm->email_to = $to; //$actioncomm->email_tocc = $sendtocc; @@ -1739,7 +1739,7 @@ class FactureRec extends CommonInvoice $actioncomm->contact_id = 0; $actioncomm->code = 'AC_EMAIL'; - $actioncomm->label = $langs->trans('sendAutoEmailInvoiceKO', $from, $to); + $actioncomm->label = $langs->trans('sendAutoEmailInvoiceKO', $email_from, $to); $actioncomm->note_private = $errormesg; $actioncomm->fk_project = $facture->fk_project; $actioncomm->datep = dol_now(); @@ -1750,7 +1750,7 @@ class FactureRec extends CommonInvoice // Fields when action is an email (content should be added into note) $actioncomm->email_msgid = $cMailFile->msgid; $actioncomm->email_subject = $sendTopic; - $actioncomm->email_from = $from; + $actioncomm->email_from = $email_from; $actioncomm->email_sender = ''; $actioncomm->email_to = $to; //$actioncomm->email_tocc = $sendtocc; @@ -2791,7 +2791,7 @@ class FactureLigneRec extends CommonInvoiceLine $sql = "UPDATE ".MAIN_DB_PREFIX."facturedet_rec SET"; $sql .= " fk_facture = ".((int) $this->fk_facture); - $sql .= ", fk_parent_line=".($this->fk_parent_line > 0 ? $this->fk_parent_line : "null"); + $sql .= ", fk_parent_line=".($this->fk_parent_line > 0 ? ((int) $this->fk_parent_line) : "null"); $sql .= ", label=".(!empty($this->label) ? "'".$this->db->escape($this->label)."'" : "null"); $sql .= ", description='".$this->db->escape($this->desc)."'"; $sql .= ", price=".price2num($this->price); @@ -2802,7 +2802,7 @@ class FactureLigneRec extends CommonInvoiceLine $sql .= ", localtax1_type='".$this->db->escape((string) $this->localtax1_type)."'"; $sql .= ", localtax2_tx=".price2num($this->localtax2_tx); $sql .= ", localtax2_type='".$this->db->escape((string) $this->localtax2_type)."'"; - $sql .= ", fk_product=".($this->fk_product > 0 ? $this->fk_product : "null"); + $sql .= ", fk_product=".($this->fk_product > 0 ? ((int) $this->fk_product) : "null"); $sql .= ", product_type=".((int) $this->product_type); $sql .= ", remise_percent=".price2num($this->remise_percent); $sql .= ", subprice=".price2num($this->subprice); @@ -2819,7 +2819,7 @@ class FactureLigneRec extends CommonInvoiceLine $sql .= ", rang=".((int) $this->rang); $sql .= ", special_code=".((int) $this->special_code); $sql .= ", fk_unit=".($this->fk_unit ? "'".$this->db->escape((string) $this->fk_unit)."'" : "null"); - $sql .= ", fk_contract_line=".($this->fk_contract_line ? $this->fk_contract_line : "null"); + $sql .= ", fk_contract_line=".($this->fk_contract_line ? ((int) $this->fk_contract_line) : "null"); $sql .= " WHERE rowid = ".((int) $this->id); $this->db->begin(); diff --git a/htdocs/compta/facture/class/facture.class.php b/htdocs/compta/facture/class/facture.class.php index 5f75f6c28bf..dd68c26a4ce 100644 --- a/htdocs/compta/facture/class/facture.class.php +++ b/htdocs/compta/facture/class/facture.class.php @@ -749,7 +749,7 @@ class Facture extends CommonInvoice $sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "null"); $sql .= ", ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "null"); $sql .= ", ".($this->ref_customer ? "'".$this->db->escape($this->ref_customer)."'" : ($this->ref_client ? "'".$this->db->escape($this->ref_client)."'" : "null")); - $sql .= ", ".($this->fk_account > 0 ? $this->fk_account : 'NULL'); + $sql .= ", ".($this->fk_account > 0 ? ((int) $this->fk_account) : 'NULL'); $sql .= ", ".($this->module_source ? "'".$this->db->escape($this->module_source)."'" : "null"); $sql .= ", ".($this->pos_source != '' ? "'".$this->db->escape((string) $this->pos_source)."'" : "null"); $sql .= ", ".(int) $this->pos_print_counter; @@ -3255,9 +3255,9 @@ class Facture extends CommonInvoice } // Invoice line extrafields - $main = MAIN_DB_PREFIX.'facturedet'; - $ef = $main."_extrafields"; - $sqlef = "DELETE FROM ".$ef." WHERE fk_object IN (SELECT rowid FROM ".$main." WHERE fk_facture = ".((int) $rowid).")"; + $sql_main_table = MAIN_DB_PREFIX.'facturedet'; + $sql_ef_table = $sql_main_table."_extrafields"; + $sqlef = "DELETE FROM ".$sql_ef_table." WHERE fk_object IN (SELECT rowid FROM ".$sql_main_table." WHERE fk_facture = ".((int) $rowid).")"; // Delete invoice line $sql = 'DELETE FROM '.MAIN_DB_PREFIX.'facturedet WHERE fk_facture = '.((int) $rowid); @@ -3753,7 +3753,7 @@ class Facture extends CommonInvoice // Validate $sql = 'UPDATE '.MAIN_DB_PREFIX.'facture'; - $sql .= " SET ref = '".$this->db->escape($num)."', fk_statut = ".self::STATUS_VALIDATED.", fk_user_valid = ".($user->id > 0 ? $user->id : "null").", date_valid = '".$this->db->idate($now)."'"; + $sql .= " SET ref = '".$this->db->escape($num)."', fk_statut = ".self::STATUS_VALIDATED.", fk_user_valid = ".($user->id > 0 ? ((int) $user->id) : "null").", date_valid = '".$this->db->idate($now)."'"; if (getDolGlobalString('FAC_FORCE_DATE_VALIDATION')) { // If option enabled, we force invoice date $sql .= ", datef='".$this->db->idate($this->date)."'"; $sql .= ", date_lim_reglement='".$this->db->idate($this->date_lim_reglement)."'"; @@ -6319,11 +6319,11 @@ class Facture extends CommonInvoice } // Sender - $from = getDolGlobalString('MAIN_MAIL_EMAIL_FROM'); + $email_from = getDolGlobalString('MAIN_MAIL_EMAIL_FROM'); if (!empty($arraymessage->email_from)) { // If a sender is defined into template, we use it in priority - $from = (string) $arraymessage->email_from; + $email_from = (string) $arraymessage->email_from; } - if (empty($from)) { + if (empty($email_from)) { $errormesg = "Failed to get sender into global setup MAIN_MAIL_EMAIL_FROM"; $loopError++; } @@ -6361,7 +6361,7 @@ class Facture extends CommonInvoice } // Mail Creation - $cMailFile = new CMailFile($sendTopic, $to, $from, $sendContent, $joinFile, $joinFileMime, $joinFileName, $email_tocc, $email_tobcc, 0, 1, $errors_to, '', $trackid, '', $sendcontext, ''); + $cMailFile = new CMailFile($sendTopic, $to, $email_from, $sendContent, $joinFile, $joinFileMime, $joinFileName, $email_tocc, $email_tobcc, 0, 1, $errors_to, '', $trackid, '', $sendcontext, ''); $resultsendmail = $cMailFile->sendfile(); @@ -6393,7 +6393,7 @@ class Facture extends CommonInvoice // Fields when action is an email (content should be added into note) $actioncomm->email_msgid = $cMailFile->msgid; $actioncomm->email_subject = $sendTopic; - $actioncomm->email_from = $from; + $actioncomm->email_from = $email_from; $actioncomm->email_sender = ''; $actioncomm->email_to = $to; //$actioncomm->email_tocc = $sendtocc; @@ -6433,7 +6433,7 @@ class Facture extends CommonInvoice // Fields when action is an email (content should be added into note) $actioncomm->email_msgid = $cMailFile->msgid; $actioncomm->email_subject = $sendTopic; - $actioncomm->email_from = $from; + $actioncomm->email_from = $email_from; $actioncomm->email_sender = ''; $actioncomm->email_to = $to; //$actioncomm->email_tocc = $sendtocc; diff --git a/htdocs/compta/prelevement/class/bonprelevement.class.php b/htdocs/compta/prelevement/class/bonprelevement.class.php index ae0f57bb388..b86d01afd25 100644 --- a/htdocs/compta/prelevement/class/bonprelevement.class.php +++ b/htdocs/compta/prelevement/class/bonprelevement.class.php @@ -461,7 +461,7 @@ class BonPrelevement extends CommonObject $sql .= ", fk_prelevement_demande"; $sql .= ($sourcetype == 'salary' ? ", fk_user" : ""); $sql .= ") VALUES ("; - $sql .= $this->id; + $sql .= ((int) $this->id); $sql .= ", " . (($sourcetype != 'salary') ? ((int) $client_id) : "0"); // fk_soc can't be null $sql .= ", '" . $this->db->escape($client_nom) . "'"; $sql .= ", " . ((float) price2num($amount)); diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php index 27500c38439..641da65657b 100644 --- a/htdocs/core/class/commonobject.class.php +++ b/htdocs/core/class/commonobject.class.php @@ -1406,7 +1406,7 @@ abstract class CommonObject // Insert into database $sql = "INSERT INTO ".$this->db->prefix()."element_contact"; $sql .= " (element_id, fk_socpeople, datecreate, statut, fk_c_type_contact) "; - $sql .= " VALUES (".$this->id.", ".((int) $fk_socpeople)." , "; + $sql .= " VALUES (".((int) $this->id).", ".((int) $fk_socpeople)." , "; $sql .= "'".$this->db->idate($datecreate)."'"; $sql .= ", 4, ".((int) $id_type_contact); $sql .= ")"; @@ -1751,11 +1751,13 @@ abstract class CommonObject // phpcs:enable global $langs; - if (empty($order)) { - $order = 'position'; + $sanitized_order = $order; // @phan-suppress-current-line SqlInjection + + if (empty($sanitized_order)) { + $sanitized_order = 'position'; } - if ($order == 'position') { - $order .= ',code'; + if ($sanitized_order == 'position') { + $sanitized_order .= ',code'; } $tab = array(); @@ -1772,7 +1774,7 @@ abstract class CommonObject if (!empty($code)) { $sql .= " AND tc.code = '".$this->db->escape($code)."'"; } - $sql .= $this->db->order($order, 'ASC'); + $sql .= $this->db->order($sanitized_order, 'ASC'); //print "sql=".$sql; $resql = $this->db->query($sql); @@ -2572,7 +2574,7 @@ abstract class CommonObject if (!preg_match('/^\s*AND/i', $filtermax)) { $sql .= " AND "; } - $sql .= $filtermax; + $sql .= $filtermax; // @phan-suppress-current-line SqlInjection } else { $sql .= $tmpsql; } @@ -2621,7 +2623,7 @@ abstract class CommonObject $sql .= " FROM ".(empty($nodbprefix) ? $this->db->prefix() : '').$this->table_element." as te"; if (isset($this->ismultientitymanaged) && !is_numeric($this->ismultientitymanaged)) { $tmparray = explode('@', $this->ismultientitymanaged); - $sql .= ", ".$this->db->prefix().$tmparray[1]." as ".($tmparray[1] == 'societe' ? 's' : 'parenttable'); // If we need to link to this table to limit select to entity + $sql .= ", ".$this->db->prefix().$this->db->sanitize($tmparray[1])." as ".($tmparray[1] == 'societe' ? 's' : 'parenttable'); // If we need to link to this table to limit select to entity } elseif ($restrictiononfksoc == 1 && $this->element != 'societe' && !$user->hasRight('societe', 'client', 'voir') && !$socid) { $sql .= ", ".$this->db->prefix()."societe as s"; // If we need to link to societe to limit select to socid } elseif ($restrictiononfksoc == 2 && $this->element != 'societe' && !$user->hasRight('societe', 'client', 'voir') && !$socid) { @@ -2658,7 +2660,7 @@ abstract class CommonObject if (!preg_match('/^\s*AND/i', $filtermin)) { $sql .= " AND "; } - $sql .= $filtermin; + $sql .= $filtermin; // @phan-suppress-current-line SqlInjection $filtermin = ''; } else { @@ -6814,7 +6816,7 @@ abstract class CommonObject // use geo sql function to read as text if (!empty($extrafields->attributes[$this->table_element]['type'][$name]) && in_array($extrafields->attributes[$this->table_element]['type'][$name], array('point', 'multipts', 'linestrg', 'polygon'))) { // TODO Add an abstraction method in the database driver - $sql .= ", ST_AsWKT(".$name.") as ".$this->db->sanitize($name); + $sql .= ", ST_AsWKT(".$this->db->sanitize($name).") as ".$this->db->sanitize($name); } } $sql .= " FROM ".$this->db->prefix().$table_element."_extrafields"; @@ -7209,7 +7211,7 @@ abstract class CommonObject // - multipts => "MULTIPOINT(0 0, 20 20, 60 60)" // - linestrg => "LINESTRING(0 0, 10 10, 20 25, 50 60)" // - polygon => "POLYGON((0 0,10 0,10 10,0 10,0 0),(5 5,7 5,7 7,5 7, 5 5))" - $sqlColumnValues[$attributeKey] = $geoDataType['ST_Function']."('".$this->db->escape($newValue)."')"; + $sqlColumnValues[$attributeKey] = $geoDataType['ST_Function']."('".$this->db->escape($newValue)."')"; // @phan-suppress-current-line SqlInjection } $this->db->begin(); @@ -7391,7 +7393,7 @@ abstract class CommonObject if ($value !== '') { $sql = "INSERT INTO ".$this->db->prefix()."object_lang (fk_object, property, type_object, lang, value"; - $sql .= ") VALUES (".$this->id.", '".$this->db->escape($key)."', '".$this->db->escape($table_element)."', '".$this->db->escape($langcode)."', '".$this->db->escape($value)."'"; + $sql .= ") VALUES (".((int) $this->id).", '".$this->db->escape($key)."', '".$this->db->escape($table_element)."', '".$this->db->escape($langcode)."', '".$this->db->escape($value)."'"; $sql .= ")"; $resql = $this->db->query($sql); @@ -7691,7 +7693,7 @@ abstract class CommonObject //var_dump('linealreadyfound='.$linealreadyfound.' sql='.$sql); exit; if ($linealreadyfound) { - $sanitizedGeoDataType = ExtraFields::$geoDataTypes[$attributeType] ?? null; + $sanitizedGeoDataType = ExtraFields::$geoDataTypes[$attributeType] ?? null; // @phan-suppress-current-line SqlInjection if ($this->array_options["options_".$key] === null) { $sql = "UPDATE ".$this->db->prefix().$this->db->sanitize($table_element)."_extrafields"; $sql .= " SET ".$this->db->sanitize($key)." = null"; @@ -9017,7 +9019,7 @@ abstract class CommonObject $sql .= " WHERE ".$this->db->sanitize($selectkey)." = '".$this->db->escape((string) $value)."'"; } - //$sql.= ' AND entity = '.$conf->entity; + //$sql.= ' AND entity = '.((int) $conf->entity); dol_syslog(get_class($this).':showOutputField:$type=sellist', LOG_DEBUG); $resql = $this->db->query($sql); @@ -9120,7 +9122,7 @@ abstract class CommonObject $sql .= ' as main'; } // $sql.= " WHERE ".$selectkey."='".$this->db->escape($value)."'"; - // $sql.= ' AND entity = '.$conf->entity; + // $sql.= ' AND entity = '.((int) $conf->entity); dol_syslog(get_class($this).':showOutputField:$type=chkbxlst', LOG_DEBUG); $resql = $this->db->query($sql); @@ -10645,7 +10647,7 @@ abstract class CommonObject if ((isset($this->{$field}) && $this->{$field} != '') || !empty($info['notnull'])) { if (!isset($this->{$field})) { if (!empty($info['default'])) { - $queryarray[$field] = $info['default']; + $queryarray[$field] = $info['default']; // From safe source @phan-suppress-current-line SqlInjection } else { $queryarray[$field] = 0; } @@ -10865,31 +10867,31 @@ abstract class CommonObject unset($fieldvalues['rowid']); // The field 'rowid' is reserved field name for autoincrement field so we don't need it into insert. - $keys = array(); - $values = array(); // Array to store string forged for SQL syntax + $sanitized_keys = array(); + $sanitized_values = array(); // Array to store string forged for SQL syntax foreach ($fieldvalues as $k => $v) { - $keys[$k] = $k; + $sanitized_keys[$k] = $k; // Safe source @phan-suppress-current-line SqlInjection $value = $this->fields[$k]; // @phan-suppress-next-line PhanPluginSuspiciousParamPosition - $values[$k] = $this->quote($v, $value); // May return string 'NULL' if $value is null + $sanitized_values[$k] = $this->quote($v, $value); // May return string 'NULL' if $value is null } // Clean and check mandatory - foreach ($keys as $key) { + foreach ($sanitized_keys as $key) { if (!isset($this->fields[$key])) { continue; } $key_fields = $this->fields[$key]; // If field is an implicit foreign key field (so type = 'integer:...') - if (preg_match('/^integer:/i', $key_fields['type']) && $values[$key] == '-1') { - $values[$key] = ''; + if (preg_match('/^integer:/i', $key_fields['type']) && $sanitized_values[$key] == '-1') { + $sanitized_values[$key] = ''; } - if (!empty($key_fields['foreignkey']) && $values[$key] == '-1') { - $values[$key] = ''; + if (!empty($key_fields['foreignkey']) && $sanitized_values[$key] == '-1') { + $sanitized_values[$key] = ''; } - if (isset($key_fields['notnull']) && $key_fields['notnull'] == 1 && (!isset($values[$key]) || $values[$key] === 'NULL') && (!isset($key_fields['default']) || is_null($key_fields['default']))) { + if (isset($key_fields['notnull']) && $key_fields['notnull'] == 1 && (!isset($sanitized_values[$key]) || $sanitized_values[$key] === 'NULL') && (!isset($key_fields['default']) || is_null($key_fields['default']))) { $error++; global $langs; if (empty($langs)) { @@ -10903,20 +10905,20 @@ abstract class CommonObject } // If value is null and there is a default value for field @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset - if (isset($key_fields['notnull']) && $key_fields['notnull'] == 1 && (!isset($values[$key]) || $values[$key] === 'NULL') && array_key_exists('default', $key_fields) && !is_null($key_fields['default'])) { - $values[$key] = $this->quote($key_fields['default'], $key_fields); + if (isset($key_fields['notnull']) && $key_fields['notnull'] == 1 && (!isset($sanitized_values[$key]) || $sanitized_values[$key] === 'NULL') && array_key_exists('default', $key_fields) && !is_null($key_fields['default'])) { + $sanitized_values[$key] = $this->quote($key_fields['default'], $key_fields); } // If field is an implicit foreign key field (so type = 'integer:...') - if (isset($key_fields['type']) && preg_match('/^integer:/i', $key_fields['type']) && empty($values[$key])) { + if (isset($key_fields['type']) && preg_match('/^integer:/i', $key_fields['type']) && empty($sanitized_values[$key])) { if (isset($key_fields['default'])) { - $values[$key] = ((int) $key_fields['default']); + $sanitized_values[$key] = ((int) $key_fields['default']); } else { - $values[$key] = 'null'; + $sanitized_values[$key] = 'null'; } } - if (!empty($key_fields['foreignkey']) && empty($values[$key])) { - $values[$key] = 'null'; + if (!empty($key_fields['foreignkey']) && empty($sanitized_values[$key])) { + $sanitized_values[$key] = 'null'; } } @@ -10928,8 +10930,8 @@ abstract class CommonObject if (!$error) { $sql = "INSERT INTO ".$this->db->prefix().$this->table_element; - $sql .= " (".implode(", ", $keys).')'; - $sql .= " VALUES (".implode(", ", $values).")"; // $values can contains 'abc' or 123 + $sql .= " (".implode(", ", $sanitized_keys).')'; + $sql .= " VALUES (".implode(", ", $sanitized_values).")"; // $sanitized_values can contains 'abc' or 123 $res = $this->db->query($sql); if (!$res) { @@ -11196,7 +11198,7 @@ abstract class CommonObject // Add quotes and escape on fields with type string $keys = array(); $values = array(); - $tmp = array(); + $sanitized_tmp = array(); foreach ($fieldvalues as $k => $v) { $keys[$k] = $k; $value = $this->fields[$k]; @@ -11207,8 +11209,8 @@ abstract class CommonObject $v = preg_replace('/\s/', ',', $v); $v = preg_replace('/,+/', ',', $v); } - // @phan-suppress-next-line PhanPluginSuspiciousParamPosition - $tmp[] = $k.'='.$this->quote($v, $this->fields[$k]); + // @phan-suppress-next-line PhanPluginSuspiciousParamPosition, SqlInjection + $sanitized_tmp[] = $k.'='.$this->quote($v, $this->fields[$k]); } // Clean and check mandatory fields @@ -11229,7 +11231,7 @@ abstract class CommonObject }*/ } - $sql = 'UPDATE '.$this->db->prefix().$this->table_element.' SET '.implode(', ', $tmp).' WHERE rowid='.((int) $this->id); + $sql = 'UPDATE '.$this->db->prefix().$this->table_element.' SET '.implode(', ', $sanitized_tmp).' WHERE rowid='.((int) $this->id); $this->db->begin(); diff --git a/htdocs/core/class/conf.class.php b/htdocs/core/class/conf.class.php index 08c6099920d..8675408edec 100644 --- a/htdocs/core/class/conf.class.php +++ b/htdocs/core/class/conf.class.php @@ -5,7 +5,7 @@ * Copyright (C) 2005-2017 Regis Houssin * Copyright (C) 2006 Jean Heimburger * Copyright (C) 2024-2025 Frédéric France - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * * 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 @@ -559,7 +559,7 @@ class Conf extends stdClass $sql = "SELECT ".$db->decrypt('name')." as name,"; $sql .= " ".$db->decrypt('value')." as value, entity"; $sql .= " FROM ".$db->prefix()."const"; - $sql .= " WHERE entity IN (0,".$this->entity.")"; + $sql .= " WHERE entity IN (0,".((int) $this->entity).")"; $sql .= " ORDER BY entity"; // This is to have entity 0 first, then entity 1 that overwrite. $resql = $db->query($sql); diff --git a/htdocs/core/class/extrafields.class.php b/htdocs/core/class/extrafields.class.php index 63b910622d1..3cdca1be7a1 100644 --- a/htdocs/core/class/extrafields.class.php +++ b/htdocs/core/class/extrafields.class.php @@ -394,6 +394,7 @@ class ExtraFields $result = $this->db->DDLAddField($this->db->prefix().$this->db->sanitize($table), $attrname, $field_desc); if ($result > 0) { if ($unique) { + // @phan-suppress-next-line SqlInjection $sql = "ALTER TABLE ".$this->db->prefix().$this->db->sanitize($table)." ADD UNIQUE INDEX uk_".$this->db->sanitize($table)."_".$attrname." (".$attrname.")"; $resql = $this->db->query($sql, 1, 'dml'); } @@ -542,7 +543,7 @@ class ExtraFields $sql .= " '".$this->db->escape($type)."',"; $sql .= " ".((int) $pos).","; $sql .= " '".$this->db->escape($size)."',"; - $sql .= " ".((int) ($entity === '' ? $conf->entity : $entity)).","; + $sql .= " ".((int) ($entity === '' ? ((int) $conf->entity) : ((int) $entity))).","; $sql .= " '".$this->db->escape($elementtype)."',"; $sql .= " ".((int) $unique).","; $sql .= " ".((int) $required).","; @@ -554,8 +555,8 @@ class ExtraFields $sql .= " '".$this->db->escape((string) $printable)."',"; $sql .= " ".($default ? "'".$this->db->escape($default)."'" : "null").","; $sql .= " ".($computed ? "'".$this->db->escape($computed)."'" : "null").","; - $sql .= " ".(is_object($user) ? $user->id : 0).","; - $sql .= " ".(is_object($user) ? $user->id : 0).","; + $sql .= " ".(is_object($user) ? ((int) $user->id) : 0).","; + $sql .= " ".(is_object($user) ? ((int) $user->id) : 0).","; $sql .= "'".$this->db->idate(dol_now())."',"; $sql .= " ".($enabled ? "'".$this->db->escape($enabled)."'" : "1").","; $sql .= " ".($help ? "'".$this->db->escape($help)."'" : "null").","; @@ -618,7 +619,7 @@ class ExtraFields $sql .= " FROM ".$this->db->prefix()."extrafields"; $sql .= " WHERE elementtype = '".$this->db->escape($elementtype)."'"; $sql .= " AND name = '".$this->db->escape($attrname)."'"; - //$sql.= " AND entity IN (0,".$conf->entity.")"; Do not test on entity here. We want to see if there is still on field remaining in other entities before deleting field in table + //$sql.= " AND entity IN (0,".((int) $conf->entity).")"; Do not test on entity here. We want to see if there is still on field remaining in other entities before deleting field in table $resql = $this->db->query($sql); if ($resql) { $obj = $this->db->fetch_object($resql); @@ -669,7 +670,7 @@ class ExtraFields if (isset($attrname) && $attrname != '' && preg_match("/^\w[a-zA-Z0-9-_]*$/", $attrname)) { $sql = "DELETE FROM ".$this->db->prefix()."extrafields"; $sql .= " WHERE name = '".$this->db->escape($attrname)."'"; - $sql .= " AND entity IN (0,".$conf->entity.')'; + $sql .= " AND entity IN (0,".((int) $conf->entity).')'; if (!empty($elementtype)) { $sql .= " AND elementtype = '".$this->db->escape($elementtype)."'"; } @@ -1082,7 +1083,7 @@ class ExtraFields $sql = "SELECT rowid, name, label, type, size, elementtype, fieldunique, fieldrequired, param, pos, alwayseditable, emptyonclone, perms, langs, list, printable, showintooltip, totalizable, fielddefault, fieldcomputed, entity, enabled, help, aiprompt"; $sql .= " , css, cssview, csslist, personal_data"; $sql .= " FROM ".$this->db->prefix()."extrafields"; - //$sql.= " WHERE entity IN (0,".$conf->entity.")"; // Filter is done later + //$sql.= " WHERE entity IN (0,".((int) $conf->entity).")"; // Filter is done later if ($elementtype && $elementtype != 'all') { $sql .= " WHERE elementtype = '".$this->db->escape($elementtype)."'"; // Filed with object->table_element } @@ -2405,7 +2406,7 @@ class ExtraFields } else { $sql .= " WHERE ".$this->db->sanitize($selectkey)." = '".$this->db->escape($value)."'"; } - //$sql.= ' AND entity = '.$conf->entity; + //$sql.= ' AND entity = '.((int) $conf->entity); dol_syslog(get_class($this).':showOutputField:$type=sellist', LOG_DEBUG); diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php index 14e4e0231f4..4b09f083edf 100644 --- a/htdocs/core/class/html.form.class.php +++ b/htdocs/core/class/html.form.class.php @@ -2066,7 +2066,7 @@ class Form } if ($filter) { // $filter is safe because, it has been tested by testSqlAndScriptInject() and sanitized by forgeSQLFromUniversalSearchCriteria() - $sqlwhere = $filter; + $sqlwhere = $filter; // @phan-suppress-current-line SqlInjection $sql .= " AND (" . $sqlwhere . ")"; } if (!$user->hasRight('societe', 'client', 'voir')) { @@ -2336,7 +2336,7 @@ class Form if ($filter) { // $filter is safe because, if it contains '(' or ')', it has been sanitized by testSqlAndScriptInject() and forgeSQLFromUniversalSearchCriteria() // if not, by testSqlAndScriptInject() only. - $sanitizedfilter = $filter; + $sanitizedfilter = $filter; // @phan-suppress-current-line SqlInjection $sql .= " AND (" . $sanitizedfilter . ")"; } // Add where from hooks @@ -2542,7 +2542,7 @@ class Form $sql .= " WHERE re.fk_soc = " . (int) $socid; $sql .= " AND re.entity = " . ((int) $conf->entity); if ($filter) { - $sanitizedfilter = $filter; + $sanitizedfilter = $filter; // @phan-suppress-current-line SqlInjection $sql .= " AND " . $sanitizedfilter; } $sql .= " ORDER BY re.description ASC"; @@ -7597,7 +7597,7 @@ class Form $newfilter .= ' AND fk_facture IS NULL AND fk_facture_line IS NULL'; // Customer discounts available } if ($filter) { - $sanitizedfilter = $filter; + $sanitizedfilter = $filter; // @phan-suppress-current-line SqlInjection $newfilter .= ' AND (' . $sanitizedfilter . ')'; } // output the combo of discounts @@ -9885,6 +9885,7 @@ class Form if (!empty($objecttmp->parent_element)) { // If parent_element is defined '@phan-var-force CommonObjectLine $objecttmp'; $parent_properties = getElementProperties($objecttmp->parent_element); + // @phan-suppress-next-line SqlInjection $sql .= " INNER JOIN " . $this->db->prefix() . $this->db->sanitize($parent_properties['table_element']) . " as o ON o.rowid = t.".$objecttmp->fk_parent_attribute; } if (!empty($objecttmp->parent_element) && in_array($objecttmp->parent_element, ['commande', 'propal', 'facture', 'expedition'])) { @@ -12021,7 +12022,7 @@ class Form if (isModEnabled('multicompany') && $conf->entity == 1 && $user->admin && !$user->entity) { $sql .= " LEFT JOIN " . $this->db->prefix() . "entity as e ON e.rowid=ug.entity"; if ($force_entity) { - $sql .= " WHERE ug.entity IN (0, " . $force_entity . ")"; + $sql .= " WHERE ug.entity IN (0, " . ((int) $force_entity) . ")"; } else { $sql .= " WHERE ug.entity IS NOT NULL"; } diff --git a/htdocs/core/class/html.formticket.class.php b/htdocs/core/class/html.formticket.class.php index f9cd0a81d21..a80e0d94e6e 100644 --- a/htdocs/core/class/html.formticket.class.php +++ b/htdocs/core/class/html.formticket.class.php @@ -1213,7 +1213,7 @@ class FormTicket if (!empty($arrayidused)) { $sql .= " AND ctc.fk_parent IN ( "; foreach ($arrayidused as $idused) { - $sql .= $idused.", "; + $sql .= ((int) $idused).", "; } $sql = substr($sql, 0, -2); $sql .= ")"; diff --git a/htdocs/core/lib/admin.lib.php b/htdocs/core/lib/admin.lib.php index 43af9790134..1dc3846227b 100644 --- a/htdocs/core/lib/admin.lib.php +++ b/htdocs/core/lib/admin.lib.php @@ -256,7 +256,7 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler // If string contains the end of request string (';'), we save it into $arraysql. // Found new request if ($buffer) { - $arraysql[$i] = $buffer; + $arraysql[$i] = $buffer; // @phan-suppress-current-line SqlInjection } $i++; $buffer = ''; @@ -264,7 +264,7 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler } if ($buffer) { - $arraysql[$i] = $buffer; + $arraysql[$i] = $buffer; // @phan-suppress-current-line SqlInjection } fclose($fp); } else { @@ -302,7 +302,7 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler // Replace __+MAX_llx_table__ with +999 $from = '__+MAX_'.$table.'__'; $to = '+'.$listofmaxrowid[$table]; - $newsql = str_replace($from, $to, $newsql); + $newsql = str_replace($from, $to, $newsql); // @phan-suppress-current-line SqlInjection dol_syslog('Admin.lib::run_sql New Request '.($i + 1).' (replacing '.$from.' to '.$to.')', LOG_DEBUG); $arraysql[$i] = $newsql; @@ -327,7 +327,7 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler // Loop on each request to execute request $cursorinsert = 0; $listofinsertedrowid = array(); - $keyforsql = md5($sqlfile); + $keyforsqlfile = md5($sqlfile); foreach ($arraysql as $i => $sql) { if ($sql) { // Test if the SQL is allowed SQL @@ -414,11 +414,11 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler $sql = preg_replace('/__DATABASE__/i', $db->escape($database), $sql); } - $newsql = preg_replace('/__ENTITY__/i', (!empty($entity) ? $entity : (string) $conf->entity), $sql); + $newsql = preg_replace('/__ENTITY__/i', (!empty($entity) ? ((int) $entity) : (string) ((int) $conf->entity)), $sql); // Add log of request if (!$silent) { - print '\n"; + print '\n"; } dol_syslog('Admin.lib::run_sql Request '.($i + 1), LOG_DEBUG); $sqlmodified = 0; @@ -428,9 +428,9 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler $num = count($reg[0]); for ($j = 0; $j < $num; $j++) { - $from = $reg[0][$j]; + $from = $reg[0][$j]; // @phan-suppress-current-line SqlInjection $to = $db->encrypt($reg[1][$j]); - $newsql = str_replace($from, $to, $newsql); + $newsql = str_replace($from, $to, $newsql); // @phan-suppress-current-line SqlInjection } $sqlmodified++; } @@ -440,9 +440,9 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler $num = count($reg[0]); for ($j = 0; $j < $num; $j++) { - $from = $reg[0][$j]; + $from = $reg[0][$j]; // @phan-suppress-current-line SqlInjection $to = $db->decrypt($reg[1][$j]); - $newsql = str_replace($from, $to, $newsql); + $newsql = str_replace($from, $to, $newsql); // @phan-suppress-current-line SqlInjection } $sqlmodified++; } @@ -460,9 +460,9 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler break; } - $from = '__'.$cursor.'__'; + $from = '__'.$cursor.'__'; // @phan-suppress-current-line SqlInjection $to = $listofinsertedrowid[$cursor]; - $newsql = str_replace($from, $to, $newsql); + $newsql = str_replace($from, $to, $newsql); // @phan-suppress-current-line SqlInjection $sqlmodified++; } @@ -537,19 +537,19 @@ function run_sql($sqlfile, $silent = 1, $entity = 0, $usesavepoint = 1, $handler //if (!empty($conf->use_javascript_ajax)) { // use_javascript_ajax is not defined print ''; if (count($arraysql)) { - print ' - '.$langs->trans("ShowHideDetails").''; + print ' - '.$langs->trans("ShowHideDetails").''; } else { print ' - '.$langs->trans("ScriptIsEmpty").''; } @@ -1494,7 +1494,7 @@ function complete_dictionary_with_modules(&$taborder, &$tabname, &$tablib, &$tab if (!empty($objMod->dictionaries)) { //var_dump($objMod->dictionaries['tabname']); - $nbtabname = $nbtablib = $nbtabsql = $nbtabsqlsort = $nbtabfield = $nbtabfieldvalue = $nbtabfieldinsert = $nbtabrowid = $nbtabcond = $nbtabfieldcheck = $nbtabhelp = 0; + $nbtabname = $nbtablib = $nbtabsqlsort = $nbtabfield = $nbtabfieldvalue = $nbtabfieldinsert = $nbtabrowid = $nbtabcond = $nbtabfieldcheck = $nbtabhelp = $nbtabsql = 0; $tabnamerelwithkey = array(); foreach ($objMod->dictionaries['tabname'] as $key => $val) { $tmptablename = preg_replace('/'.MAIN_DB_PREFIX.'/', '', $val); @@ -1513,14 +1513,14 @@ function complete_dictionary_with_modules(&$taborder, &$tabname, &$tablib, &$tab foreach ($objMod->dictionaries['tabsql'] as $key => $val) { $tmptablename = preg_replace('/'.MAIN_DB_PREFIX.'/', '', $tabnamerelwithkey[$key]); $nbtabsql++; - $tabsql[] = $val; - $tabcomplete[$tmptablename]['sql'] = $val; + $tabsql[] = $val; // @phan-suppress-current-line SqlInjection + $tabcomplete[$tmptablename]['sql'] = $val; // @phan-suppress-current-line SqlInjection } foreach ($objMod->dictionaries['tabsqlsort'] as $key => $val) { $tmptablename = preg_replace('/'.MAIN_DB_PREFIX.'/', '', $tabnamerelwithkey[$key]); $nbtabsqlsort++; - $tabsqlsort[] = $val; - $tabcomplete[$tmptablename]['sqlsort'] = $val; + $tabsqlsort[] = $val; // @phan-suppress-current-line SqlInjection + $tabcomplete[$tmptablename]['sqlsort'] = $val; // @phan-suppress-current-line SqlInjection } foreach ($objMod->dictionaries['tabfield'] as $key => $val) { $tmptablename = preg_replace('/'.MAIN_DB_PREFIX.'/', '', $tabnamerelwithkey[$key]); @@ -1537,14 +1537,14 @@ function complete_dictionary_with_modules(&$taborder, &$tabname, &$tablib, &$tab foreach ($objMod->dictionaries['tabfieldinsert'] as $key => $val) { $tmptablename = preg_replace('/'.MAIN_DB_PREFIX.'/', '', $tabnamerelwithkey[$key]); $nbtabfieldinsert++; - $tabfieldinsert[] = $val; - $tabcomplete[$tmptablename]['fieldinsert'] = $val; + $tabfieldinsert[] = $val; // @phan-suppress-current-line SqlInjection + $tabcomplete[$tmptablename]['fieldinsert'] = $val; // @phan-suppress-current-line SqlInjection } foreach ($objMod->dictionaries['tabrowid'] as $key => $val) { $tmptablename = preg_replace('/'.MAIN_DB_PREFIX.'/', '', $tabnamerelwithkey[$key]); $nbtabrowid++; - $tabrowid[] = $val; - $tabcomplete[$tmptablename]['rowid'] = $val; + $tabrowid[] = (int) $val; + $tabcomplete[$tmptablename]['rowid'] = (int) $val; } foreach ($objMod->dictionaries['tabcond'] as $key => $val) { $tmptablename = preg_replace('/'.MAIN_DB_PREFIX.'/', '', $tabnamerelwithkey[$key]); @@ -1821,7 +1821,7 @@ function form_constantes($tableau, $strictw3c = 2, $helptext = '', $text = '') $sql .= ", note"; $sql .= " FROM ".MAIN_DB_PREFIX."const"; $sql .= " WHERE ".$db->decrypt('name')." = '".$db->escape($const)."'"; - $sql .= " AND entity IN (0, ".$conf->entity.")"; + $sql .= " AND entity IN (0, ".((int) $conf->entity).")"; $sql .= " ORDER BY name ASC, entity DESC"; $result = $db->query($sql); diff --git a/htdocs/core/lib/company.lib.php b/htdocs/core/lib/company.lib.php index 79659d031ef..e6996b6c135 100644 --- a/htdocs/core/lib/company.lib.php +++ b/htdocs/core/lib/company.lib.php @@ -2133,7 +2133,7 @@ function show_actions_done($conf, $langs, $db, $filterobj, $objcon = null, $nopr && $filterobj->table_element && $filterobj->element ) { // Generic case (if there is a $filterobj and a field rowid and (ref or label) exists. - $sql .= " AND a.fk_element = o.rowid AND a.elementtype = '" . $db->escape($filterobj->element) . ($module ? "@" . $module : "") . "'"; + $sql .= " AND a.fk_element = o.rowid AND a.elementtype = '" . $db->escape($filterobj->element) . ($module ? "@" . $db->escape($module) : "") . "'"; if ($filterobj->id) { $sql .= " AND a.fk_element = " . ((int) $filterobj->id); } @@ -2225,7 +2225,7 @@ function show_actions_done($conf, $langs, $db, $filterobj, $objcon = null, $nopr $limit = $MAXWITHOUTPAGINATION; // Complete request and execute it with limit - $sql .= $db->order($sortfield_new, $sortorder); + $sql .= $db->order($sortfield_new, $sortorder); // @phan-suppress-current-line SqlInjection if ($limit) { // @phpstan-ignore-line $sql .= $db->plimit($limit + 1, $offset); } diff --git a/htdocs/core/lib/functions.lib.php b/htdocs/core/lib/functions.lib.php index 101f1537cc3..6d1dca52be0 100644 --- a/htdocs/core/lib/functions.lib.php +++ b/htdocs/core/lib/functions.lib.php @@ -8869,7 +8869,7 @@ function yn($yesno, $format = 1, $color = 0) * @param int $withoutslash 0=With slash at end (except if '/', we return ''), 1=without slash at end * @param ?CommonObject $object Object to use to get ref to forge the path. * @param string $modulepart Type of object ('invoice_supplier, 'donation', 'invoice', ...'). Use '' for autodetect from $object. - * @return string Dir to use ending. Example '' or '1/' or '1/2/' + * @return string Sanitized Dir to use ending. Example '' or '1/' or '1/2/' * @see getMultidirOutput() */ function get_exdir($num, $level, $alpha, $withoutslash, $object, $modulepart = '') @@ -8896,7 +8896,7 @@ function get_exdir($num, $level, $alpha, $withoutslash, $object, $modulepart = ' if (!empty($level) && array_key_exists($modulepart, $arrayforoldpath)) { // This part should be removed once all code is using "get_exdir" to forge path, with parameter $object and $modulepart provided. if (empty($num) && is_object($object)) { - $num = $object->id; + $num = ((int) $object->id); } if (empty($alpha)) { $num = preg_replace('/([^0-9])/i', '', $num); @@ -8921,7 +8921,7 @@ function get_exdir($num, $level, $alpha, $withoutslash, $object, $modulepart = ' // Special case for thirdparty, where the ref is a company name that is not unique so path on disk is using the ID instead of the ref $path = dol_sanitizeFileName((string) $object->id); } else { - $path = dol_sanitizeFileName(empty($object->ref) ? (string) ((is_object($object) && property_exists($object, 'id')) ? $object->id : '') : $object->ref); + $path = dol_sanitizeFileName(empty($object->ref) ? (string) ((is_object($object) && property_exists($object, 'id')) ? ((int) $object->id) : '') : $object->ref); } } @@ -11855,7 +11855,7 @@ function dol_getIdFromCode($db, $key, $tablename, $fieldkey = 'code', $fieldid = $sql .= " AND entity IN (" . getEntity($tablename) . ")"; } if ($filters) { - $sql .= $filters; + $sql .= $filters; // @phan-suppress-current-line SqlInjection } $resql = $db->query($sql); @@ -16490,11 +16490,11 @@ function show_actions_messaging($conf, $langs, $db, $filterobj, $objcon = null, $sortfield_list = explode(',', $sortfield); $sortfield_label_list = array('a.id' => 'id', 'a.datep' => 'dp', 'a.percent' => 'percent'); - $sortfield_new_list = array(); + $sanitized_sortfield_new_list = array(); foreach ($sortfield_list as $sortfield_value) { - $sortfield_new_list[] = $sortfield_label_list[trim($sortfield_value)]; + $sanitized_sortfield_new_list[] = $sortfield_label_list[trim($sortfield_value)]; //@phan-suppress-current-line SqlInjection } - $sortfield_new = implode(',', $sortfield_new_list); + $sanitized_sortfield_new = implode(',', $sanitized_sortfield_new_list); $sql = null; $sql2 = null; @@ -16729,7 +16729,7 @@ function show_actions_messaging($conf, $langs, $db, $filterobj, $objcon = null, $limit = $MAXWITHOUTPAGINATION; // Complete request and execute it with limit - $sql .= $db->order($sortfield_new, $sortorder); + $sql .= $db->order($sanitized_sortfield_new, $sortorder); if ($limit) { $sql .= $db->plimit($limit + 1, $offset); } diff --git a/htdocs/core/modules/DolibarrModules.class.php b/htdocs/core/modules/DolibarrModules.class.php index 69c01f6ca7a..ba3d827ae50 100644 --- a/htdocs/core/modules/DolibarrModules.class.php +++ b/htdocs/core/modules/DolibarrModules.class.php @@ -607,15 +607,15 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $num = count($array_sql); for ($i = 0; $i < $num; $i++) { if (!$err) { - $val = $array_sql[$i]; - $sql = $val; + $sql = $array_sql[$i]; + $val = $sql; $ignoreerror = 0; if (is_array($val)) { $sql = $val['sql']; $ignoreerror = $val['ignoreerror'] ?? 0; } // Add current entity id - $sql = str_replace('__ENTITY__', (string) $conf->entity, $sql); + $sql = str_replace('__ENTITY__', (string) ((int) $conf->entity), $sql); dol_syslog(get_class($this)."::_init ignoreerror=".$ignoreerror, LOG_DEBUG); $result = $this->db->query($sql, $ignoreerror); @@ -1148,7 +1148,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql = "SELECT tms, note FROM ".MAIN_DB_PREFIX."const"; $sql .= " WHERE ".$this->db->decrypt('name')." = '".$this->db->escape($this->const_name)."'"; - $sql .= " AND entity IN (0, ".$conf->entity.")"; + $sql .= " AND entity IN (0, ".((int) $conf->entity).")"; dol_syslog(get_class($this)."::getLastActiveDate", LOG_DEBUG); $resql = $this->db->query($sql); @@ -1192,7 +1192,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql = "DELETE FROM ".MAIN_DB_PREFIX."const"; $sql .= " WHERE ".$this->db->decrypt('name')." = '".$this->db->escape($this->const_name)."'"; - $sql .= " AND entity IN (0, ".$entity.")"; + $sql .= " AND entity IN (0, ".((int) $entity).")"; dol_syslog(get_class($this)."::_active delete activation constant", LOG_DEBUG); $resql = $this->db->query($sql); @@ -1242,7 +1242,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql = "DELETE FROM ".MAIN_DB_PREFIX."const"; $sql .= " WHERE ".$this->db->decrypt('name')." = '".$this->db->escape($this->const_name)."'"; - $sql .= " AND entity IN (0, ".$entity.")"; + $sql .= " AND entity IN (0, ".((int) $entity).")"; dol_syslog(get_class($this)."::_unactive", LOG_DEBUG); $this->db->query($sql); @@ -1287,7 +1287,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it foreach ($conf->file->dol_document_root as $dirroot) { if ($ok == 1) { - $dirsql = $dirroot.$reldir; + $dirsql = $dirroot.$reldir; // Not sql, just sql file @phan-suppress-current-line SqlInjection $ok = 0; // We will loop on xxx/, xxx/tables/, xxx/data/ @@ -1480,7 +1480,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it // Search if boxes def already present $sql = "SELECT count(*) as nb FROM ".MAIN_DB_PREFIX."boxes_def"; $sql .= " WHERE file = '".$this->db->escape($file)."'"; - $sql .= " AND entity = ".$conf->entity; + $sql .= " AND entity = ".((int) $conf->entity); if ($note) { $sql .= " AND note ='".$this->db->escape($note)."'"; } @@ -1494,7 +1494,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it if (!$err) { $sql = "INSERT INTO ".MAIN_DB_PREFIX."boxes_def (file, entity, note)"; $sql .= " VALUES ('".$this->db->escape($file)."', "; - $sql .= $conf->entity.", "; + $sql .= ((int) $conf->entity).", "; $sql .= $note ? "'".$this->db->escape($note)."'" : "null"; $sql .= ")"; @@ -1585,13 +1585,13 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql .= "SELECT ".MAIN_DB_PREFIX."boxes_def.rowid "; $sql .= "FROM ".MAIN_DB_PREFIX."boxes_def "; $sql .= "WHERE ".MAIN_DB_PREFIX."boxes_def.file = '".$this->db->escape($file)."') "; - $sql .= "AND ".MAIN_DB_PREFIX."boxes.entity = ".$conf->entity; + $sql .= "AND ".MAIN_DB_PREFIX."boxes.entity = ".((int) $conf->entity); } else { $sql = "DELETE FROM ".MAIN_DB_PREFIX."boxes"; $sql .= " USING ".MAIN_DB_PREFIX."boxes, ".MAIN_DB_PREFIX."boxes_def"; $sql .= " WHERE ".MAIN_DB_PREFIX."boxes.box_id = ".MAIN_DB_PREFIX."boxes_def.rowid"; $sql .= " AND ".MAIN_DB_PREFIX."boxes_def.file = '".$this->db->escape($file)."'"; - $sql .= " AND ".MAIN_DB_PREFIX."boxes.entity = ".$conf->entity; + $sql .= " AND ".MAIN_DB_PREFIX."boxes.entity = ".((int) $conf->entity); } dol_syslog(get_class($this)."::delete_boxes", LOG_DEBUG); @@ -1603,7 +1603,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql = "DELETE FROM ".MAIN_DB_PREFIX."boxes_def"; $sql .= " WHERE file = '".$this->db->escape($file)."'"; - $sql .= " AND entity = ".$conf->entity; // Do not use getEntity here, we want to delete only in current company + $sql .= " AND entity = ".((int) $conf->entity); // Do not use getEntity here, we want to delete only in current company dol_syslog(get_class($this)."::delete_boxes", LOG_DEBUG); $resql = $this->db->query($sql); @@ -1753,7 +1753,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it if (is_array($this->cronjobs)) { $sql = "DELETE FROM ".MAIN_DB_PREFIX."cronjob"; $sql .= " WHERE module_name = '".$this->db->escape(empty($this->rights_class) ? strtolower($this->name) : $this->rights_class)."'"; - $sql .= " AND entity = ".$conf->entity; + $sql .= " AND entity = ".((int) $conf->entity); $sql .= " AND test = '1'"; // We delete on lines that are not set with a complete test that is '$conf->module->enabled' so when module is disabled, the cron is also removed. // For crons declared with a '$conf->module->enabled', there is no need to delete the line, so we don't loose setup if we reenable module. @@ -1962,7 +1962,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it if ($deleteonunactive) { $sql = "DELETE FROM ".MAIN_DB_PREFIX."const"; $sql .= " WHERE ".$this->db->decrypt('name')." = '".$this->db->escape($name)."'"; - $sql .= " AND entity in (0, ".$conf->entity.")"; + $sql .= " AND entity in (0, ".((int) $conf->entity).")"; dol_syslog(get_class($this)."::delete_const", LOG_DEBUG); if (!$this->db->query($sql)) { $this->error = $this->db->lasterror(); @@ -2296,7 +2296,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql = "DELETE FROM ".MAIN_DB_PREFIX."menu"; $sql .= " WHERE module = '".$this->db->escape($module)."'"; $sql .= " AND menu_handler = 'all'"; // We delete only lines that were added manually or by the module activation. We keep entry added by menuhandler like 'auguria' - $sql .= " AND entity IN (0, ".$conf->entity.")"; + $sql .= " AND entity IN (0, ".((int) $conf->entity).")"; dol_syslog(get_class($this)."::delete_menus", LOG_DEBUG); $resql = $this->db->query($sql); @@ -2393,7 +2393,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql = "SELECT count(*)"; $sql .= " FROM ".MAIN_DB_PREFIX."const"; $sql .= " WHERE ".$this->db->decrypt('name')." = '".$this->db->escape($name)."'"; - $sql .= " AND entity = ".$conf->entity; + $sql .= " AND entity = ".((int) $conf->entity); dol_syslog(get_class($this)."::insert_dirs", LOG_DEBUG); $result = $this->db->query($sql); @@ -2431,7 +2431,7 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it $sql = "DELETE FROM ".MAIN_DB_PREFIX."const"; $sql .= " WHERE ".$this->db->decrypt('name')." LIKE '".$this->db->escape($this->const_name)."_DIR_%'"; - $sql .= " AND entity = ".$conf->entity; + $sql .= " AND entity = ".((int) $conf->entity); dol_syslog(get_class($this)."::delete_dirs", LOG_DEBUG); if (!$this->db->query($sql)) { diff --git a/htdocs/emailcollector/class/emailcollector.class.php b/htdocs/emailcollector/class/emailcollector.class.php index ba03f21be9e..e074a3e66e4 100644 --- a/htdocs/emailcollector/class/emailcollector.class.php +++ b/htdocs/emailcollector/class/emailcollector.class.php @@ -2288,10 +2288,10 @@ class EmailCollector extends CommonObject $reg = array(); if (preg_match('/^(.*)<(.*)>$/', $fromstring, $reg)) { - $from = $reg[2]; + $email_from = $reg[2]; $fromtext = $reg[1]; } else { - $from = $fromstring; + $email_from = $fromstring; $fromtext = ''; } if (preg_match('/^(.*)<(.*)>$/', $replytostring, $reg)) { @@ -2315,7 +2315,7 @@ class EmailCollector extends CommonObject } if (empty($emailsToCheck)) { - foreach (array($from, $replyto) as $tmpEmail) { + foreach (array($email_from, $replyto) as $tmpEmail) { $tmpEmail = strtolower(trim((string) $tmpEmail)); if ($tmpEmail !== '' && strpos($tmpEmail, '@') !== false) { $emailsToCheck[$tmpEmail] = true; @@ -2635,28 +2635,28 @@ class EmailCollector extends CommonObject } if (empty($contactid)) { // Try to find contact using email - $result = $contactstatic->fetch(0, null, '', $from); + $result = $contactstatic->fetch(0, null, '', $email_from); if ($result > 0) { - dol_syslog("We found a contact with the email ".$from); + dol_syslog("We found a contact with the email ".$email_from); $contactid = $contactstatic->id; - $contactfoundby = 'email of contact ('.$from.')'; + $contactfoundby = 'email of contact ('.$email_from.')'; if (empty($thirdpartyid) && $contactstatic->socid > 0) { $result = $thirdpartystatic->fetch($contactstatic->socid); if ($result > 0) { $thirdpartyid = $thirdpartystatic->id; - $thirdpartyfoundby = 'email of contact ('.$from.')'; + $thirdpartyfoundby = 'email of contact ('.$email_from.')'; } } } } if (empty($thirdpartyid)) { // Try to find thirdparty using email - $result = $thirdpartystatic->fetch(0, '', '', '', '', '', '', '', '', '', $from); + $result = $thirdpartystatic->fetch(0, '', '', '', '', '', '', '', '', '', $email_from); if ($result > 0) { - dol_syslog("We found a thirdparty with the email ".$from); + dol_syslog("We found a thirdparty with the email ".$email_from); $thirdpartyid = $thirdpartystatic->id; - $thirdpartyfoundby = 'email ('.$from.')'; + $thirdpartyfoundby = 'email ('.$email_from.')'; } } @@ -3005,7 +3005,7 @@ class EmailCollector extends CommonObject } else { $thirdpartystatic->name_alias = (empty($replytostring) ? (empty($fromtext) ? '' : $fromtext) : $replytostring); } - $thirdpartystatic->email = (empty($emailtouseforthirdparty) ? (empty($replyto) ? (empty($from) ? '' : $from) : $replyto) : $emailtouseforthirdparty); + $thirdpartystatic->email = (empty($emailtouseforthirdparty) ? (empty($replyto) ? (empty($email_from) ? '' : $email_from) : $replyto) : $emailtouseforthirdparty); // Overwrite values with values extracted from source email $errorforthisaction = $this->overwritePropertiesOfObject($thirdpartystatic, $operation['actionparam'], $messagetext, $subject, $header, $operationslog); @@ -3051,26 +3051,26 @@ class EmailCollector extends CommonObject if ($errorforthisaction) { $errorforactions++; } else { - if (!empty($contact_static->email) && $contact_static->email != $from) { - $from = $contact_static->email; + if (!empty($contact_static->email) && $contact_static->email != $email_from) { + $email_from = $contact_static->email; } - $from = (string) $from; + $email_from = (string) $email_from; - $from = (string) $from; + $email_from = (string) $email_from; - $result = $contactstatic->fetch(0, null, '', $from); + $result = $contactstatic->fetch(0, null, '', $email_from); if ($result < 0) { $errorforactions++; - $this->error = 'Error when getting contact with email ' . $from; + $this->error = 'Error when getting contact with email ' . $email_from; $this->errors[] = $this->error; break; } elseif ($result == 0) { - dol_syslog("Contact with email " . $from . " was not found. We try to create it."); + dol_syslog("Contact with email " . $email_from . " was not found. We try to create it."); $contactstatic = new Contact($this->db); // Create contact - $contactstatic->email = $from; - $operationslog .= '
We set property email='.dol_escape_htmltag($from); + $contactstatic->email = $email_from; + $operationslog .= '
We set property email='.dol_escape_htmltag($email_from); // Overwrite values with values extracted from source email $errorforthisaction = $this->overwritePropertiesOfObject($contactstatic, $operation['actionparam'], $messagetext, $subject, $header, $operationslog); @@ -3195,7 +3195,7 @@ class EmailCollector extends CommonObject // Insert record of emails sent $actioncomm->type_code = 'AC_OTH_AUTO'; // Type of event ('AC_OTH', 'AC_OTH_AUTO', 'AC_XXX'...) $actioncomm->code = 'AC_'.$actioncode; - $actioncomm->label = $langs->trans("ActionAC_".$actioncode).' - '.$langs->trans("MailFrom").' '.$from; + $actioncomm->label = $langs->trans("ActionAC_".$actioncode).' - '.$langs->trans("MailFrom").' '.$email_from; $actioncomm->note_private = $descriptionfull; $actioncomm->fk_project = $projectstatic->id; $actioncomm->datep = $dateemail; // date of email @@ -3294,10 +3294,10 @@ class EmailCollector extends CommonObject } } if (count($data) > 0) { - $sql = "SELECT rowid as id FROM ".MAIN_DB_PREFIX."user WHERE email LIKE '%".$this->db->escape($from)."%'"; + $sql = "SELECT rowid as id FROM ".MAIN_DB_PREFIX."user WHERE email LIKE '%".$this->db->escape($email_from)."%'"; $resql = $this->db->query($sql); if ($this->db->num_rows($resql) == 0) { - $this->errors[] = "User Not allowed to add documents ({$from})"; + $this->errors[] = "User Not allowed to add documents ({$email_from})"; } $arrayobject = array( 'propale' => array('table' => 'propal', @@ -3641,7 +3641,7 @@ class EmailCollector extends CommonObject $tickettocreate->type_code = (getDolGlobalString('MAIN_EMAILCOLLECTOR_TICKET_TYPE_CODE', dol_getIdFromCode($this->db, 1, 'c_ticket_type', 'use_default', 'code', 1))); $tickettocreate->category_code = (getDolGlobalString('MAIN_EMAILCOLLECTOR_TICKET_CATEGORY_CODE', dol_getIdFromCode($this->db, 1, 'c_ticket_category', 'use_default', 'code', 1))); $tickettocreate->severity_code = (getDolGlobalString('MAIN_EMAILCOLLECTOR_TICKET_SEVERITY_CODE', dol_getIdFromCode($this->db, 1, 'c_ticket_severity', 'use_default', 'code', 1))); - $tickettocreate->origin_email = $from; + $tickettocreate->origin_email = $email_from; $tickettocreate->origin_replyto = (!empty($replyto) ? $replyto : null); $tickettocreate->origin_references = (!empty($headers['References']) ? $headers['References'] : null); $tickettocreate->fk_user_create = $user->id; @@ -3723,7 +3723,7 @@ class EmailCollector extends CommonObject // Add sender to context array to make sure that confirmation e-mail can be sent by trigger script $sender_contact = new Contact($this->db); - $sender_contact->fetch(0, null, '', $from); + $sender_contact->fetch(0, null, '', $email_from); if (!empty($sender_contact->id)) { $tickettocreate->context['contact_id'] = $sender_contact->id; } @@ -3791,8 +3791,8 @@ class EmailCollector extends CommonObject $candidaturetocreate->type_code = 0; $candidaturetocreate->category_code = null; $candidaturetocreate->severity_code = null; - $candidaturetocreate->email = $from; - //$candidaturetocreate->lastname = $langs->trans("Anonymous").' - '.$from; + $candidaturetocreate->email = $email_from; + //$candidaturetocreate->lastname = $langs->trans("Anonymous").' - '.$email_from; $candidaturetocreate->fk_user_creat = $user->id; $candidaturetocreate->date_creation = dol_now(); $candidaturetocreate->fk_project = $projectstatic->id; @@ -3877,7 +3877,7 @@ class EmailCollector extends CommonObject 'imapemail' => $imapemail, 'overview' => $overview, - 'from' => $from, + 'from' => $email_from, 'fromtext' => $fromtext, 'actionparam' => $operation['actionparam'], diff --git a/htdocs/fourn/class/fournisseur.commande.class.php b/htdocs/fourn/class/fournisseur.commande.class.php index 9ac2e08cd39..3ea66560e45 100644 --- a/htdocs/fourn/class/fournisseur.commande.class.php +++ b/htdocs/fourn/class/fournisseur.commande.class.php @@ -864,7 +864,7 @@ class CommandeFournisseur extends CommonOrder $this->error = $this->db->lasterror(); } $sql = 'UPDATE '.$this->db->prefix()."ecm_files set filepath = 'fournisseur/commande/".$this->db->escape($this->newref)."'"; - $sql .= " WHERE filepath = 'fournisseur/commande/".$this->db->escape($this->ref)."' and entity = ".$conf->entity; + $sql .= " WHERE filepath = 'fournisseur/commande/".$this->db->escape($this->ref)."' and entity = ".((int) $conf->entity); $resql = $this->db->query($sql); if (!$resql) { $error++; @@ -1553,7 +1553,7 @@ class CommandeFournisseur extends CommonOrder } $sql = "UPDATE ".$this->db->prefix()."commande_fournisseur"; - $sql .= " SET fk_statut=".self::STATUS_ORDERSENT.", fk_input_method=".$methode.", date_commande='".$this->db->idate($date)."', "; + $sql .= " SET fk_statut=".self::STATUS_ORDERSENT.", fk_input_method=".((int) $methode).", date_commande='".$this->db->idate($date)."', "; $sql .= " note_private='".$this->db->escape((string) $newnoteprivate)."'"; $sql .= " WHERE rowid=".((int) $this->id); @@ -1674,10 +1674,10 @@ class CommandeFournisseur extends CommonOrder $sql .= ", ".self::STATUS_DRAFT; $sql .= ", ".((int) $this->source); $sql .= ", '".$this->db->escape(getDolGlobalString('COMMANDE_SUPPLIER_ADDON_PDF'))."'"; - $sql .= ", ".($this->mode_reglement_id > 0 ? $this->mode_reglement_id : 'null'); + $sql .= ", ".($this->mode_reglement_id > 0 ? ((int) $this->mode_reglement_id) : 'null'); $sql .= ", ".(!empty($this->deposit_percent) ? "'" . $this->db->escape($this->deposit_percent) . "'" : "null"); - $sql .= ", ".($this->cond_reglement_id > 0 ? $this->cond_reglement_id : 'null'); - $sql .= ", ".($this->fk_account > 0 ? $this->fk_account : 'NULL'); + $sql .= ", ".($this->cond_reglement_id > 0 ? ((int) $this->cond_reglement_id) : 'null'); + $sql .= ", ".($this->fk_account > 0 ? ((int) $this->fk_account) : 'NULL'); $sql .= ", ".(int) $this->fk_incoterms; $sql .= ", '".$this->db->escape($this->location_incoterms)."'"; $sql .= ", ".(int) $this->fk_multicurrency; @@ -1737,7 +1737,7 @@ class CommandeFournisseur extends CommonOrder } $sql = "UPDATE ".$this->db->prefix()."commande_fournisseur"; - $sql .= " SET ref='(PROV".$this->id.")'"; + $sql .= " SET ref='(PROV".((int) $this->id).")'"; $sql .= " WHERE rowid=".((int) $this->id); dol_syslog(get_class($this)."::create", LOG_DEBUG); @@ -1849,24 +1849,24 @@ class CommandeFournisseur extends CommonOrder $sql .= " ref=".(isset($this->ref) ? "'".$this->db->escape($this->ref)."'" : "null").","; $sql .= " ref_supplier=".(isset($this->ref_supplier) ? "'".$this->db->escape($this->ref_supplier)."'" : "null").","; $sql .= " ref_ext=".(isset($this->ref_ext) ? "'".$this->db->escape($this->ref_ext)."'" : "null").","; - $sql .= " fk_soc=".(isset($this->socid) ? $this->socid : "null").","; + $sql .= " fk_soc=".(isset($this->socid) ? ((int) $this->socid) : "null").","; $sql .= " date_commande=".(strval($this->date_commande) != '' ? "'".$this->db->idate($this->date_commande)."'" : 'null').","; $sql .= " date_valid=".(strval($this->date_validation) != '' ? "'".$this->db->idate($this->date_validation)."'" : 'null').","; - $sql .= " total_tva=".(isset($this->total_tva) ? $this->total_tva : "null").","; - $sql .= " localtax1=".(isset($this->total_localtax1) ? $this->total_localtax1 : "null").","; - $sql .= " localtax2=".(isset($this->total_localtax2) ? $this->total_localtax2 : "null").","; - $sql .= " total_ht=".(isset($this->total_ht) ? $this->total_ht : "null").","; - $sql .= " total_ttc=".(isset($this->total_ttc) ? $this->total_ttc : "null").","; - $sql .= " fk_statut=".(isset($this->status) ? $this->status : "null").","; - $sql .= " fk_user_author=".(isset($this->user_author_id) ? $this->user_author_id : "null").","; - $sql .= " fk_user_valid=".(isset($this->user_validation_id) && $this->user_validation_id > 0 ? $this->user_validation_id : "null").","; - $sql .= " fk_projet=".((!empty($this->fk_project) && $this->fk_project > 0) ? $this->fk_project : "null").","; - $sql .= " fk_cond_reglement=".(isset($this->cond_reglement_id) ? $this->cond_reglement_id : "null").","; - $sql .= " deposit_percent=".(!empty($this->deposit_percent) ? strval($this->deposit_percent) : "null").","; - $sql .= " fk_mode_reglement=".(isset($this->mode_reglement_id) ? $this->mode_reglement_id : "null").","; + $sql .= " total_tva=".(isset($this->total_tva) ? ((float) $this->total_tva) : "null").","; + $sql .= " localtax1=".(isset($this->total_localtax1) ? ((float) $this->total_localtax1) : "null").","; + $sql .= " localtax2=".(isset($this->total_localtax2) ? ((float) $this->total_localtax2) : "null").","; + $sql .= " total_ht=".(isset($this->total_ht) ? ((float) $this->total_ht) : "null").","; + $sql .= " total_ttc=".(isset($this->total_ttc) ? ((float) $this->total_ttc) : "null").","; + $sql .= " fk_statut=".(isset($this->status) ? ((int) $this->status) : "null").","; + $sql .= " fk_user_author=".(isset($this->user_author_id) ? ((int) $this->user_author_id) : "null").","; + $sql .= " fk_user_valid=".(isset($this->user_validation_id) && $this->user_validation_id > 0 ? ((int) $this->user_validation_id) : "null").","; + $sql .= " fk_projet=".((!empty($this->fk_project) && $this->fk_project > 0) ? ((int) $this->fk_project) : "null").","; + $sql .= " fk_cond_reglement=".(isset($this->cond_reglement_id) ? ((int) $this->cond_reglement_id) : "null").","; + $sql .= " deposit_percent=".(!empty($this->deposit_percent) ? ((float) $this->deposit_percent) : "null").","; + $sql .= " fk_mode_reglement=".(isset($this->mode_reglement_id) ? ((int) $this->mode_reglement_id) : "null").","; $sql .= " date_livraison=".(strval($this->delivery_date) != '' ? "'".$this->db->idate($this->delivery_date)."'" : 'null').","; //$sql .= " fk_shipping_method=".(isset($this->shipping_method_id) ? $this->shipping_method_id : "null").","; - $sql .= " fk_account=".($this->fk_account > 0 ? $this->fk_account : "null").","; + $sql .= " fk_account=".($this->fk_account > 0 ? ((int) $this->fk_account) : "null").","; //$sql .= " fk_input_reason=".($this->demand_reason_id > 0 ? $this->demand_reason_id : "null").","; $sql .= " note_private=".(isset($this->note_private) ? "'".$this->db->escape($this->note_private)."'" : "null").","; $sql .= " note_public=".(isset($this->note_public) ? "'".$this->db->escape($this->note_public)."'" : "null").","; @@ -2391,7 +2391,7 @@ class CommandeFournisseur extends CommonOrder $sql = "INSERT INTO ".$this->db->prefix()."receptiondet_batch"; $sql .= " (fk_element, fk_product, qty, fk_entrepot, fk_user, datec, fk_elementdet, status, comment, eatby, sellby, batch, fk_reception) VALUES"; - $sql .= " ('".$this->id."','".$product."','".$qty."',".($entrepot > 0 ? "'".$entrepot."'" : "null").",'".$user->id."','".$this->db->idate($now)."','".$fk_commandefourndet."', ".$dispatchstatus.", '".$this->db->escape($comment)."', "; + $sql .= " ('".((int) $this->id)."','".((int) $product)."','".((float) $qty)."',".($entrepot > 0 ? "'".((int) $entrepot)."'" : "null").",'".((int) $user->id)."','".$this->db->idate($now)."','".((int) $fk_commandefourndet)."', ".((int) $dispatchstatus).", '".$this->db->escape($comment)."', "; $sql .= ($eatby ? "'".$this->db->idate($eatby)."'" : "null").", ".($sellby ? "'".$this->db->idate($sellby)."'" : "null").", ".($batch ? "'".$this->db->escape($batch)."'" : "null").", ".($fk_reception > 0 ? "'".$this->db->escape((string) $fk_reception)."'" : "null"); $sql .= ")"; @@ -3510,7 +3510,7 @@ class CommandeFournisseur extends CommonOrder if (empty($user->socid) && !$user->hasRight("societe", "client", "voir") && !$user->socid) { $sql .= " JOIN ".$this->db->prefix()."societe_commerciaux as sc ON c.fk_soc = sc.fk_soc AND sc.fk_user = ".((int) $user->id); } - $sql .= " WHERE c.entity = ".$conf->entity; + $sql .= " WHERE c.entity = ".((int) $conf->entity); if ($mode === 'awaiting') { $sql .= " AND c.fk_statut IN (".self::STATUS_ORDERSENT.", ".self::STATUS_RECEIVED_PARTIALLY.")"; } else { diff --git a/htdocs/install/upgrade2.php b/htdocs/install/upgrade2.php index db1de33ce7d..0c1cab42fac 100644 --- a/htdocs/install/upgrade2.php +++ b/htdocs/install/upgrade2.php @@ -1363,7 +1363,7 @@ function migrate_links_transfert($db, $langs, $conf) $sql .= "fk_bank, url_id, url, label, type"; $sql .= ")"; $sql .= " VALUES ("; - $sql .= $obj->barowid.",".$obj->bbrowid.", '/compta/bank/line.php?rowid=', '(banktransfert)', 'banktransfert'"; + $sql .= ((int) $obj->barowid).",".((int) $obj->bbrowid).", '/compta/bank/line.php?rowid=', '(banktransfert)', 'banktransfert'"; $sql .= ")"; //print $sql.'
'; @@ -1654,9 +1654,9 @@ function migrate_paiementfourn_facturefourn($db, $langs, $conf) print ''; $insert_sql = 'INSERT INTO '.MAIN_DB_PREFIX.'paiementfourn_facturefourn SET '; - $insert_sql .= ' fk_paiementfourn = \''.$select_obj->rowid.'\','; - $insert_sql .= ' fk_facturefourn = \''.$select_obj->fk_facture_fourn.'\','; - $insert_sql .= ' amount = \''.$select_obj->amount.'\''; + $insert_sql .= ' fk_paiementfourn = \''.((int) $select_obj->rowid).'\','; + $insert_sql .= ' fk_facturefourn = \''.((int) $select_obj->fk_facture_fourn).'\','; + $insert_sql .= ' amount = \''.((float) $select_obj->amount).'\''; $insert_resql = $db->query($insert_sql); if ($insert_resql) { @@ -2838,9 +2838,9 @@ function migrate_project_user_resp($db, $langs, $conf) $sql2 .= ") VALUES ("; $sql2 .= "'".$db->idate(dol_now())."'"; $sql2 .= ", '4'"; - $sql2 .= ", ".$obj->rowid; + $sql2 .= ", ".((int) $obj->rowid); $sql2 .= ", '160'"; - $sql2 .= ", ".$obj->fk_user_resp; + $sql2 .= ", ".((int) $obj->fk_user_resp); $sql2 .= ")"; if ($obj->fk_user_resp > 0) { @@ -2917,9 +2917,9 @@ function migrate_project_task_actors($db, $langs, $conf) $sql2 .= ") VALUES ("; $sql2 .= "'".$db->idate(dol_now())."'"; $sql2 .= ", '4'"; - $sql2 .= ", ".$obj->fk_project_task; + $sql2 .= ", ".((int) $obj->fk_project_task); $sql2 .= ", '180'"; - $sql2 .= ", ".$obj->fk_user; + $sql2 .= ", ".((int) $obj->fk_user); $sql2 .= ")"; $resql2 = $db->query($sql2); @@ -3269,9 +3269,9 @@ function migrate_shipping_delivery($db, $langs, $conf) $sqlInsert .= ", fk_target"; $sqlInsert .= ", targettype"; $sqlInsert .= ") VALUES ("; - $sqlInsert .= $obj->fk_expedition; + $sqlInsert .= ((int) $obj->fk_expedition); $sqlInsert .= ", 'shipping'"; - $sqlInsert .= ", ".$obj->rowid; + $sqlInsert .= ", ".((int) $obj->rowid); $sqlInsert .= ", 'delivery'"; $sqlInsert .= ")"; @@ -3953,7 +3953,7 @@ function migrate_remise_entity($db, $langs, $conf) $obj = $db->fetch_object($resql); $sqlUpdate = "UPDATE ".MAIN_DB_PREFIX."societe_remise SET"; - $sqlUpdate .= " entity = ".$obj->entity; + $sqlUpdate .= " entity = ".((int) $obj->entity); $sqlUpdate .= " WHERE rowid = ".((int) $obj->rowid); $result = $db->query($sqlUpdate); diff --git a/htdocs/product/class/product.class.php b/htdocs/product/class/product.class.php index 773b3d7a4aa..62934b08a82 100644 --- a/htdocs/product/class/product.class.php +++ b/htdocs/product/class/product.class.php @@ -1243,7 +1243,7 @@ class Product extends CommonObject $sql .= ", accountancy_code_sell_intra"; $sql .= ", accountancy_code_sell_export"; $sql .= ") VALUES ("; - $sql .= $this->id; + $sql .= ((int) $this->id); $sql .= ", " . ((int) $conf->entity); $sql .= ", '" . $this->db->escape($this->accountancy_code_buy) . "'"; $sql .= ", '" . $this->db->escape($this->accountancy_code_buy_intra) . "'"; @@ -4656,7 +4656,7 @@ class Product extends CommonObject $sql .= " AND f.fk_soc = sc.fk_soc AND sc.fk_user = ".((int) $user->id); } if ($socid > 0) { - $sql .= " AND f.fk_soc = $socid"; + $sql .= " AND f.fk_soc = ".((int) $socid); } $sql .= $morefilter; $sql .= " GROUP BY date_format(f.datef,'%Y%m')"; @@ -4709,7 +4709,7 @@ class Product extends CommonObject $sql .= " AND f.fk_soc = sc.fk_soc AND sc.fk_user = ".((int) $user->id); } if ($socid > 0) { - $sql .= " AND f.fk_soc = $socid"; + $sql .= " AND f.fk_soc = ".((int) $socid); } $sql .= $morefilter; $sql .= " GROUP BY date_format(f.datef,'%Y%m')"; diff --git a/htdocs/public/members/new.php b/htdocs/public/members/new.php index 8046c34a0f5..7e8cd12aaa5 100644 --- a/htdocs/public/members/new.php +++ b/htdocs/public/members/new.php @@ -545,11 +545,11 @@ if (empty($reshook) && $action == 'add') { // Test on permission not required he } $to = $adh->makeSubstitution(getDolGlobalString('MAIN_INFO_SOCIETE_MAIL')); - $from = getDolGlobalString('ADHERENT_MAIL_FROM', $conf->email_from); + $email_from = getDolGlobalString('ADHERENT_MAIL_FROM', $conf->email_from); $mailfile = new CMailFile( '['.$appli.'] ' . getDolGlobalString('ADHERENT_AUTOREGISTER_NOTIF_MAIL_SUBJECT'), $to, - $from, + $email_from, $adh->makeSubstitution(getDolGlobalString('ADHERENT_AUTOREGISTER_NOTIF_MAIL')), array(), array(), @@ -561,7 +561,7 @@ if (empty($reshook) && $action == 'add') { // Test on permission not required he ); if (!$mailfile->sendfile()) { - dol_syslog($langs->trans("ErrorFailedToSendMail", $from, $to), LOG_ERR); + dol_syslog($langs->trans("ErrorFailedToSendMail", $email_from, $to), LOG_ERR); } } diff --git a/htdocs/societe/class/societe.class.php b/htdocs/societe/class/societe.class.php index 8ec9caa8fcb..e14d667400a 100644 --- a/htdocs/societe/class/societe.class.php +++ b/htdocs/societe/class/societe.class.php @@ -641,19 +641,19 @@ class Societe extends CommonObject /** * 0=no customer, 1=customer, 2=prospect, 3=customer and prospect - * @var int + * @var int<0,3> */ public $client = 0; /** * 0=no prospect, 1=prospect - * @var int + * @var int<0,1> */ public $prospect = 0; /** * 0=no supplier, 1=supplier - * @var int + * @var int<0,1> */ public $fournisseur; @@ -1650,7 +1650,7 @@ class Societe extends CommonObject $sql .= ",socialnetworks = '".$this->db->escape(json_encode($this->socialnetworks))."'"; $sql .= ",url = ".(!empty($this->url) ? "'".$this->db->escape($this->url)."'" : "null"); - $sql .= ",parent = ".($this->parent > 0 ? $this->parent : "null"); + $sql .= ",parent = ".($this->parent > 0 ? ((int) $this->parent) : "null"); $sql .= ",note_private = ".(!empty($this->note_private) ? "'".$this->db->escape($this->note_private)."'" : "null"); $sql .= ",note_public = ".(!empty($this->note_public) ? "'".$this->db->escape($this->note_public)."'" : "null"); @@ -1718,15 +1718,15 @@ class Societe extends CommonObject $sql .= ",fk_shipping_method = ".(!empty($this->shipping_method_id) ? "'".$this->db->escape((string) $this->shipping_method_id)."'" : "null"); $sql .= ",fk_account = ".($this->fk_account > 0 ? (int) $this->fk_account : "null"); - $sql .= ",client = ".(!empty($this->client) ? $this->client : 0); - $sql .= ",fournisseur = ".(!empty($this->fournisseur) ? $this->fournisseur : 0); + $sql .= ",client = ".(!empty($this->client) ? ((int) $this->client) : 0); + $sql .= ",fournisseur = ".(!empty($this->fournisseur) ? ((int) $this->fournisseur) : 0); $sql .= ",barcode = ".(!empty($this->barcode) ? "'".$this->db->escape($this->barcode)."'" : "null"); $sql .= ",default_lang = ".(!empty($this->default_lang) ? "'".$this->db->escape($this->default_lang)."'" : "null"); $sql .= ",logo = ".(!empty($this->logo) ? "'".$this->db->escape($this->logo)."'" : "null"); $sql .= ",logo_squarred = ".(!empty($this->logo_squarred) ? "'".$this->db->escape($this->logo_squarred)."'" : "null"); - $sql .= ",outstanding_limit= ".($this->outstanding_limit != '' ? $this->outstanding_limit : 'null'); - $sql .= ",order_min_amount= ".($this->order_min_amount != '' ? $this->order_min_amount : 'null'); - $sql .= ",supplier_order_min_amount= ".($this->supplier_order_min_amount != '' ? $this->supplier_order_min_amount : 'null'); + $sql .= ",outstanding_limit= ".($this->outstanding_limit != '' ? ((float) $this->outstanding_limit) : 'null'); + $sql .= ",order_min_amount= ".($this->order_min_amount != '' ? ((float) $this->order_min_amount) : 'null'); + $sql .= ",supplier_order_min_amount= ".($this->supplier_order_min_amount != '' ? ((float) $this->supplier_order_min_amount) : 'null'); $sql .= ",fk_prospectlevel='".$this->db->escape($this->fk_prospectlevel)."'"; if (!getDolGlobalString('MAIN_COMPANY_PERENTITY_SHARED')) { $sql .= ", accountancy_code_buy = '" . $this->db->escape($this->accountancy_code_buy) . "'"; @@ -1755,7 +1755,7 @@ class Societe extends CommonObject if ($supplier) { $sql .= ", code_fournisseur = ".(!empty($this->code_fournisseur) ? "'".$this->db->escape($this->code_fournisseur)."'" : "null"); } - $sql .= ", fk_user_modif = ".($user->id > 0 ? $user->id : "null"); + $sql .= ", fk_user_modif = ".($user->id > 0 ? ((int) $user->id) : "null"); $sql .= ", fk_multicurrency = ".(int) $this->fk_multicurrency; $sql .= ", multicurrency_code = '".$this->db->escape($this->multicurrency_code)."'"; $sql .= ", model_pdf = '".$this->db->escape($this->model_pdf)."'"; @@ -1835,7 +1835,7 @@ class Societe extends CommonObject $sql .= ", accountancy_code_buy"; $sql .= ", accountancy_code_sell"; $sql .= ") VALUES ("; - $sql .= $this->id; + $sql .= ((int) $this->id); $sql .= ", ".((int) $conf->entity); $sql .= ", ".(empty($this->vat_reverse_charge) ? '0' : '1'); $sql .= ", '".$this->db->escape($this->accountancy_code_customer_general)."'"; @@ -2831,7 +2831,7 @@ class Societe extends CommonObject } else { $sql .= " FROM ".$this->db->prefix()."facture as f"; } - $sql .= " WHERE f.entity = ".$conf->entity; + $sql .= " WHERE f.entity = ".((int) $conf->entity); $sql .= " AND paye = 0 AND fk_statut = 1"; $sql .= " AND f.type = ".CommonInvoice::TYPE_CREDIT_NOTE; $sql .= " AND NOT EXISTS (SELECT rowid FROM ".$this->db->prefix()."societe_remise_except as rc"; @@ -4081,7 +4081,7 @@ class Societe extends CommonObject } } - $sql = 'UPDATE '.MAIN_DB_PREFIX.'societe SET parent = '.($id > 0 ? $id : 'null').' WHERE rowid = '.((int) $this->id); + $sql = 'UPDATE '.MAIN_DB_PREFIX.'societe SET parent = '.($id > 0 ? ((int) $id) : 'null').' WHERE rowid = '.((int) $this->id); $resql = $this->db->query($sql); if ($resql) { @@ -4272,7 +4272,7 @@ class Societe extends CommonObject //Verify duplicate entries $sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."societe WHERE ".$field." = '".$this->db->escape($value)."' AND entity IN (".getEntity('societe').")"; if ($socid) { - $sql .= " AND rowid <> ".$socid; + $sql .= " AND rowid <> ".((int) $socid); } $resql = $this->db->query($sql); if ($resql) { diff --git a/htdocs/ticket/class/ticket.class.php b/htdocs/ticket/class/ticket.class.php index 7ac22d6f57c..2a51e901b24 100644 --- a/htdocs/ticket/class/ticket.class.php +++ b/htdocs/ticket/class/ticket.class.php @@ -6,7 +6,7 @@ * Copyright (C) 2023-2025 Charlene Benke * Copyright (C) 2023-2024 Benjamin Falière * Copyright (C) 2024 William Mead - * Copyright (C) 2024-2025 MDW + * Copyright (C) 2024-2026 MDW * Copyright (C) 2026 Jon Bendtsen * * This program is free software; you can redistribute it and/or modify @@ -1866,7 +1866,7 @@ class Ticket extends CommonObject if (getDolGlobalString('TICKET_AUTO_READ_WHEN_ASSIGN')) { $newstatus = Ticket::STATUS_READ; } - $sql .= " SET fk_user_assign=".((int) $id_assign_user).", fk_statut = ".$newstatus; + $sql .= " SET fk_user_assign=".((int) $id_assign_user).", fk_statut = ".((int) $newstatus); } else { $sql .= " SET fk_user_assign=null, fk_statut = ".Ticket::STATUS_READ; } @@ -2965,12 +2965,12 @@ class Ticket extends CommonObject $message .= '

'; $message .= $langs->trans('TicketNotificationEmailBodyInfosTrackUrlinternal').' : '.$object->track_id.''; - $from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); + $email_from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); $replyto = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_REPLYTO'); // don't try to send email if no recipient - $this->sendTicketMessageByEmail($subject, $message, 0, $sendto, $listofpaths, $listofmimes, $listofnames, array(), $from, $replyto); + $this->sendTicketMessageByEmail($subject, $message, 0, $sendto, $listofpaths, $listofmimes, $listofnames, array(), $email_from, $replyto); } } } else { @@ -3054,13 +3054,13 @@ class Ticket extends CommonObject $sendtocc = explode(',', getDolGlobalString("TICKET_SEND_INTERNAL_CC")); } - $from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); + $email_from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); $replyto = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_REPLYTO'); // don't try to send email if no recipient if (!empty($sendto)) { - $this->sendTicketMessageByEmail($subject, $message, 0, $sendto, $listofpaths, $listofmimes, $listofnames, $sendtocc, $from, $replyto); + $this->sendTicketMessageByEmail($subject, $message, 0, $sendto, $listofpaths, $listofmimes, $listofnames, $sendtocc, $email_from, $replyto); } } @@ -3174,11 +3174,11 @@ class Ticket extends CommonObject // Don't try to send email when no recipient if (!empty($sendto)) { - $from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); + $email_from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); $replyto = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_REPLYTO'); - $result = $this->sendTicketMessageByEmail($subject, $message, 0, $sendto, $listofpaths, $listofmimes, $listofnames, $sendtocc, $from, $replyto); + $result = $this->sendTicketMessageByEmail($subject, $message, 0, $sendto, $listofpaths, $listofmimes, $listofnames, $sendtocc, $email_from, $replyto); if ($result) { // update last_msg_sent date of ticket (for last message sent to external users) $this->date_last_msg_sent = dol_now(); @@ -3189,7 +3189,7 @@ class Ticket extends CommonObject $sql = "UPDATE ".MAIN_DB_PREFIX."actioncomm"; $sql .= " SET email_msgid = '".$this->db->escape($this->email_msgid)."',"; $sql .= " email_subject = '".$this->db->escape($subject)."',"; - $sql .= " email_from = '".$this->db->escape($from)."',"; + $sql .= " email_from = '".$this->db->escape($email_from)."',"; $sql .= " email_to = '".$this->db->escape(implode(',', $sendto))."',"; $sql .= " email_tocc = '".$this->db->escape(implode(',', $sendtocc))."',"; $sql .= " reply_to = '".$this->db->escape($replyto)."'"; @@ -3240,11 +3240,11 @@ class Ticket extends CommonObject * @param string[] $mimetype_list List of MIME type of attached files * @param string[] $mimefilename_list List of attached file name in message * @param array $array_receiver_cc Array of receiver in CC. Example array('john@doe.com') - * @param string $from Email from + * @param string $email_from Email from * @param string $replyto Reply to * @return boolean True if mail sent to at least one receiver, false otherwise */ - public function sendTicketMessageByEmail($subject, $message, $send_internal_cc = 0, $array_receiver = array(), $filename_list = array(), $mimetype_list = array(), $mimefilename_list = array(), $array_receiver_cc = array(), $from = '', $replyto = '') + public function sendTicketMessageByEmail($subject, $message, $send_internal_cc = 0, $array_receiver = array(), $filename_list = array(), $mimetype_list = array(), $mimefilename_list = array(), $array_receiver_cc = array(), $email_from = '', $replyto = '') { global $conf, $langs, $user, $hookmanager; @@ -3275,15 +3275,15 @@ class Ticket extends CommonObject $sendtocc .= ($sendtocc ? ',' : '').implode(',', $array_receiver_cc); } - if (empty($from)) { - $from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); + if (empty($email_from)) { + $email_from = getDolGlobalString('TICKET_NOTIFICATION_EMAIL_FROM'); } - $parameters = array('from' => $from); + $parameters = array('from' => $email_from); $action = ''; $reshook = $hookmanager->executeHooks('getTicketMessageEmailFrom', $parameters, $this, $action); if ($reshook && !empty($hookmanager->resArray['from'])) { - $from = $hookmanager->resArray['from']; + $email_from = $hookmanager->resArray['from']; } $is_sent = false; @@ -3338,7 +3338,7 @@ class Ticket extends CommonObject $sendcontext = 'ticket'; // Send email - $mailfile = new CMailFile($subject, $receiverstring, $from, $message, $filepath, $mimetype, $filename, $sendtocc, '', $deliveryreceipt, -1, '', '', $trackid, $moreinheader, $sendcontext, $replyto, $upload_dir_tmp); + $mailfile = new CMailFile($subject, $receiverstring, $email_from, $message, $filepath, $mimetype, $filename, $sendtocc, '', $deliveryreceipt, -1, '', '', $trackid, $moreinheader, $sendcontext, $replyto, $upload_dir_tmp); if ($mailfile->error) { @@ -3347,20 +3347,20 @@ class Ticket extends CommonObject $result = $mailfile->sendfile(); if ($result) { - setEventMessages($langs->trans('MailSuccessfulySent', $mailfile->getValidAddress($from, 2), $mailfile->getValidAddress($receiverstring, 2)), null, 'mesgs'); + setEventMessages($langs->trans('MailSuccessfulySent', $mailfile->getValidAddress($email_from, 2), $mailfile->getValidAddress($receiverstring, 2)), null, 'mesgs'); $is_sent = true; $this->email_msgid = $mailfile->msgid; } else { $langs->load("other"); if ($mailfile->error) { - setEventMessages($langs->trans('ErrorFailedToSendMail', $from, $receiverstring), null, 'errors'); - dol_syslog('ErrorFailedToSendMail from='.$from.' to='.$receiverstring.' : '.$mailfile->error); + setEventMessages($langs->trans('ErrorFailedToSendMail', $email_from, $receiverstring), null, 'errors'); + dol_syslog('ErrorFailedToSendMail from='.$email_from.' to='.$receiverstring.' : '.$mailfile->error); } elseif (getDolGlobalString('MAIN_DISABLE_ALL_MAILS')) { setEventMessages('No mail sent. Feature is disabled by option MAIN_DISABLE_ALL_MAILS', null, 'errors'); } else { - setEventMessages($langs->trans('ErrorFailedToSendMail', $from, $receiverstring), null, 'errors'); - dol_syslog('ErrorFailedToSendMail from='.$from.' to='.$receiverstring.' : (no error details)', LOG_WARNING); + setEventMessages($langs->trans('ErrorFailedToSendMail', $email_from, $receiverstring), null, 'errors'); + dol_syslog('ErrorFailedToSendMail from='.$email_from.' to='.$receiverstring.' : (no error details)', LOG_WARNING); } } } From 4c903ef62a7ead9aa4cf7eae8d358068855d5de5 Mon Sep 17 00:00:00 2001 From: bomuux Date: Sun, 19 Jul 2026 15:58:14 +0200 Subject: [PATCH 215/307] FIX #37381 Cancelling shipping shouldn't remove its linked objects association (#39212) * FIX #37381 * Fix #37381 Cancelling shipping shouldn't remove its linked objects association #37381 --- htdocs/expedition/class/expedition.class.php | 89 +++++++++----------- 1 file changed, 39 insertions(+), 50 deletions(-) diff --git a/htdocs/expedition/class/expedition.class.php b/htdocs/expedition/class/expedition.class.php index c04b223976a..57425f0e1ea 100644 --- a/htdocs/expedition/class/expedition.class.php +++ b/htdocs/expedition/class/expedition.class.php @@ -1885,66 +1885,55 @@ class Expedition extends CommonObject } } + // No delete expedition if (!$error) { - // Delete linked object - $res = $this->deleteObjectLinked(); - if ($res < 0) { - $error++; - } + $sql = "SELECT rowid FROM ".$this->db->prefix()."expedition"; + $sql .= " WHERE rowid = ".((int) $this->id); - // No delete expedition - if (!$error) { - $sql = "SELECT rowid FROM ".$this->db->prefix()."expedition"; - $sql .= " WHERE rowid = ".((int) $this->id); - - if ($this->db->query($sql)) { - if (!empty($this->origin) && $this->origin_id > 0) { - $this->fetch_origin(); - $origin_object = $this->origin_object; - '@phan-var-force Facture|Commande $origin_object'; - /** @var Commande $origin_object */ - if ($origin_object->status == Commande::STATUS_SHIPMENTONPROCESS) { // If order source of shipment is "shipment in progress" - // Check if there is no more shipment. If not, we can move back status of order to "validated" instead of "shipment in progress" - $origin_object->loadExpeditions(); - if (count($origin_object->expeditions) <= 0) { - $origin_object->setStatut(Commande::STATUS_VALIDATED); - } + if ($this->db->query($sql)) { + if (!empty($this->origin) && $this->origin_id > 0) { + $this->fetch_origin(); + $origin_object = $this->origin_object; + '@phan-var-force Facture|Commande $origin_object'; + /** @var Commande $origin_object */ + if ($origin_object->status == Commande::STATUS_SHIPMENTONPROCESS) { // If order source of shipment is "shipment in progress" + // Check if there is no more shipment. If not, we can move back status of order to "validated" instead of "shipment in progress" + $origin_object->loadExpeditions(); + if (count($origin_object->expeditions) <= 0) { + $origin_object->setStatut(Commande::STATUS_VALIDATED); } } - - $this->db->commit(); - - // We delete PDFs - $ref = dol_sanitizeFileName($this->ref); - if (!empty($conf->expedition->dir_output)) { - $dir = $conf->expedition->dir_output.'/sending/'.$ref; - $file = $dir.'/'.$ref.'.pdf'; - if (file_exists($file)) { - if (!dol_delete_file($file)) { - return 0; - } - } - if (file_exists($dir)) { - if (!dol_delete_dir_recursive($dir)) { - $this->error = $langs->trans("ErrorCanNotDeleteDir", $dir); - return 0; - } - } - } - - return 1; - } else { - $this->error = $this->db->lasterror()." - sql=$sql"; - $this->db->rollback(); - return -3; } + + $this->db->commit(); + + // We delete PDFs + $ref = dol_sanitizeFileName($this->ref); + if (!empty($conf->expedition->dir_output)) { + $dir = $conf->expedition->dir_output.'/sending/'.$ref; + $file = $dir.'/'.$ref.'.pdf'; + if (file_exists($file)) { + if (!dol_delete_file($file)) { + return 0; + } + } + if (file_exists($dir)) { + if (!dol_delete_dir_recursive($dir)) { + $this->error = $langs->trans("ErrorCanNotDeleteDir", $dir); + return 0; + } + } + } + + return 1; } else { + $this->error = $this->db->lasterror()." - sql=$sql"; $this->db->rollback(); - return -2; + return -3; } } else { $this->db->rollback(); - return -1; + return -2; } } From b7d60cdd0690234799bca3d051d0db10fa7ab6ee Mon Sep 17 00:00:00 2001 From: atm-jonathan <146709163+atm-jonathan@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:10:04 +0200 Subject: [PATCH 216/307] FIX : broken jquery selector prevents default model auto-select (#39196) The selector $("[name='type'") was missing its closing bracket, so the change handler that auto-selects the PDF model per invoice type (INVOICE_USE_DEFAULT_DOCUMENT) never bound to the type radios. Also trigger change on #model after .val() so the select2 widget refreshes its displayed label, not just the underlying select value. --- htdocs/compta/facture/card.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/compta/facture/card.php b/htdocs/compta/facture/card.php index fad6020f714..7c2d694897d 100644 --- a/htdocs/compta/facture/card.php +++ b/htdocs/compta/facture/card.php @@ -4414,17 +4414,17 @@ if ($action == 'create') { print ''; } From ee47cf8c5ee9b6f285e005362e8e05789e85fb35 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 05:41:18 +0200 Subject: [PATCH 288/307] Doc --- .../comm/mailing/class/advtargetemailing.class.php | 2 -- htdocs/comm/mailing/targetemailing.php | 14 +++++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/htdocs/comm/mailing/class/advtargetemailing.class.php b/htdocs/comm/mailing/class/advtargetemailing.class.php index 954c9a3740a..7cd80025f44 100644 --- a/htdocs/comm/mailing/class/advtargetemailing.class.php +++ b/htdocs/comm/mailing/class/advtargetemailing.class.php @@ -714,8 +714,6 @@ class AdvanceTargetingMailing extends CommonObject public function query_contact($arrayquery, $withThirdpartyFilter = 0) { // phpcs:enable - global $langs, $conf; - $sql = "SELECT"; $sql .= " t.rowid"; $sql .= " FROM ".MAIN_DB_PREFIX."socpeople as t"; diff --git a/htdocs/comm/mailing/targetemailing.php b/htdocs/comm/mailing/targetemailing.php index b3f67677724..ea986d16006 100644 --- a/htdocs/comm/mailing/targetemailing.php +++ b/htdocs/comm/mailing/targetemailing.php @@ -501,8 +501,6 @@ if ($object->fetch($id) >= 0) { print dol_get_fiche_end(); - print '
'; - $newcardbutton = ''; $allowaddtarget = ($object->status == $object::STATUS_DRAFT); @@ -515,14 +513,19 @@ if ($object->fetch($id) >= 0) { // Show email selectors if ($allowaddtarget && $user->hasRight('mailing', 'creer')) { - print load_fiche_titre($langs->trans("ToAddRecipientsChooseHere").'...', ($user->admin ? info_admin($langs->trans("YouCanAddYourOwnPredefindedListHere"), 1) : ''), ''); + print '
'; + print $langs->trans("ToAddRecipientsChooseHere").'...'; + //print ($user->admin ? info_admin($langs->trans("YouCanAddYourOwnPredefindedListHere"), 1) : ''); + print '
'; print '
'; print '
'; print '
'; print '
'; - print '
'.$langs->trans("RecipientSelectionModules").'
'; + print '
'.$langs->trans("RecipientSelectionModules"); + print ($user->admin ? info_admin($langs->trans("YouCanAddYourOwnPredefindedListHere"), 1) : ''); + print '
'; print '
'; if ($object->messtype != 'sms') { print $langs->trans("NbOfUniqueEMails"); @@ -533,7 +536,7 @@ if ($object->fetch($id) >= 0) { print '
'.$langs->trans("Filters").'
'; if ($object->messtype != 'sms') { print '              
'.$langs->trans("EvenUnsubscribe").' '; - print ajax_object_onoff($object, 'evenunsubscribe', 'evenunsubscribe', 'EvenUnsubscribe:switch_on:warning', 'EvenUnsubscribe', array(), 'small valignmiddle reposition', '', 1); + print ajax_object_onoff($object, 'evenunsubscribe', 'evenunsubscribe', 'EvenUnsubscribe:switch_on:warning', 'EvenUnsubscribe', array(), 'small valignmiddle reposition', '', 1, 'allowaddtarget=1'); print '
'; } print '
'; @@ -596,6 +599,7 @@ if ($object->fetch($id) >= 0) { print ''; print ''; print ''; + //print ''; } else { print '
'; } From baf8cdf6187cb45a431de9c71c8bf15e72f22dab Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 05:56:35 +0200 Subject: [PATCH 289/307] Debug v24 --- htdocs/comm/mailing/advtargetemailing.php | 10 +++++----- htdocs/main.inc.php | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/htdocs/comm/mailing/advtargetemailing.php b/htdocs/comm/mailing/advtargetemailing.php index 78729f0d737..cd803c36e5d 100644 --- a/htdocs/comm/mailing/advtargetemailing.php +++ b/htdocs/comm/mailing/advtargetemailing.php @@ -617,11 +617,9 @@ if ($object->fetch($id) >= 0) { print dol_get_fiche_end(); - print '
'; - // Show email selectors - if ($object->status == 0 && $user->hasRight('mailing', 'creer')) { + if ($object->status < 2 && $user->hasRight('mailing', 'creer')) { // @phan-assert FormAdvTargetEmailing $formadvtargetemaling // @phan-assert AdvanceTargetingMailing $advTarget @@ -666,7 +664,9 @@ if ($object->fetch($id) >= 0) { '; - print load_fiche_titre($langs->trans("AdvTgtTitle").'...', '', ''); + print '
'; + print $langs->trans("AdvTgtTitle").'...'; + print '
'; print ''."\n"; print ''."\n"; @@ -709,7 +709,7 @@ if ($object->fetch($id) >= 0) { // Customer name print '
'; +print ''; print "
'.$langs->trans("ProductRef").''; + print ''.$langs->trans($refLabelKey).''; if ($refalreadyexists) { print $langs->trans("RefAlreadyExists"); } From 4a87b0a2655fb86a2b78b92c5310b814c9b5a0e3 Mon Sep 17 00:00:00 2001 From: minimexat Date: Mon, 13 Jul 2026 16:47:49 +0200 Subject: [PATCH 132/307] FIX: #37711 add stock movement batch index (#39124) * FIX: #37711 add stock movement batch index * Update ChangeLog --------- Co-authored-by: f-hoedl Co-authored-by: Laurent Destailleur --- htdocs/install/mysql/migration/23.0.0-24.0.0.sql | 1 + htdocs/install/mysql/tables/llx_stock_mouvement.key.sql | 2 ++ 2 files changed, 3 insertions(+) diff --git a/htdocs/install/mysql/migration/23.0.0-24.0.0.sql b/htdocs/install/mysql/migration/23.0.0-24.0.0.sql index bde3df09f05..c432a6ff5e5 100644 --- a/htdocs/install/mysql/migration/23.0.0-24.0.0.sql +++ b/htdocs/install/mysql/migration/23.0.0-24.0.0.sql @@ -50,6 +50,7 @@ ALTER TABLE llx_categorie_project_task DROP FOREIGN KEY fk_categorie_project_tas -- VPGSQL8.2 DROP INDEX idx_categorie_project_fk_task; ALTER TABLE llx_categorie_project_task ADD INDEX idx_categorie_project_fk_task (fk_project_task); ALTER TABLE llx_categorie_project_task ADD CONSTRAINT fk_categorie_project_task_rowid FOREIGN KEY (fk_project_task) REFERENCES llx_projet_task (rowid); +ALTER TABLE llx_stock_mouvement ADD INDEX idx_stock_mouvement_batch_entrepot_type_datem (batch, fk_entrepot, type_mouvement, datem); -- V24 migration ALTER TABLE llx_expensereport_det ADD COLUMN tcheck_file integer DEFAULT NULL after fk_ecm_files; diff --git a/htdocs/install/mysql/tables/llx_stock_mouvement.key.sql b/htdocs/install/mysql/tables/llx_stock_mouvement.key.sql index e19d85642c0..d3a5e9e2879 100644 --- a/htdocs/install/mysql/tables/llx_stock_mouvement.key.sql +++ b/htdocs/install/mysql/tables/llx_stock_mouvement.key.sql @@ -21,3 +21,5 @@ ALTER TABLE llx_stock_mouvement ADD INDEX idx_stock_mouvement_fk_product (fk_product); ALTER TABLE llx_stock_mouvement ADD INDEX idx_stock_mouvement_fk_entrepot (fk_entrepot); + +ALTER TABLE llx_stock_mouvement ADD INDEX idx_stock_mouvement_batch_entrepot_type_datem (batch, fk_entrepot, type_mouvement, datem); From 68c0415512dbe7fd2ae997cb8b700207dfe8a83c Mon Sep 17 00:00:00 2001 From: Nathan <130627685+nathanpixodeo@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:49:55 +0700 Subject: [PATCH 133/307] fix: delete takepos supplement lines with parent (#39094) Co-authored-by: Laurent Destailleur --- htdocs/takepos/invoice.php | 61 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/htdocs/takepos/invoice.php b/htdocs/takepos/invoice.php index 512a0a5a14d..539f78bddf3 100644 --- a/htdocs/takepos/invoice.php +++ b/htdocs/takepos/invoice.php @@ -126,6 +126,57 @@ function fail($message) die($message); } +/** + * Delete an invoice line and the TakePOS supplement lines attached to it. + * + * @param Facture $invoice Invoice object + * @param int $lineid Line id to delete + * @return int Return integer <0 if KO, >0 if OK + */ +function takeposDeleteLineWithChildren($invoice, $lineid) +{ + $lineid = (int) $lineid; + if ($lineid <= 0) { + return 0; + } + + $childrenbyparent = array(); + if (is_array($invoice->lines)) { + foreach ($invoice->lines as $line) { + $parentid = (int) $line->fk_parent_line; + if ($parentid > 0) { + $childrenbyparent[$parentid][] = (int) $line->id; + } + } + } + + $linestodelete = array(); + $stack = array($lineid); + while (!empty($stack)) { + $currentlineid = array_pop($stack); + if (in_array($currentlineid, $linestodelete, true)) { + continue; + } + + $linestodelete[] = $currentlineid; + if (!empty($childrenbyparent[$currentlineid])) { + foreach ($childrenbyparent[$currentlineid] as $childlineid) { + $stack[] = $childlineid; + } + } + } + + // Delete supplements before their parent line so no orphan line remains visible on receipts. + foreach (array_reverse($linestodelete) as $deletelineid) { + $result = $invoice->deleteLine($deletelineid); + if ($result < 0) { + return $result; + } + } + + return 1; +} + $number = (float) GETPOST('number', 'alpha'); @@ -1016,7 +1067,10 @@ if (empty($reshook)) { $db->begin(); if ($idline > 0 && $placeid > 0) { // If invoice exists and a line is selected. - $invoice->deleteLine($idline); + $result = takeposDeleteLineWithChildren($invoice, $idline); + if ($result < 0) { + dol_htmloutput_errors($invoice->error, $invoice->errors, 1); + } $invoice->fetch($placeid); } elseif ($placeid > 0) { // If invoice exists but no line selected (delete from another device or with no line selected), proceed to delete the last line. $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facturedet where fk_facture = ".((int) $placeid)." ORDER BY rowid DESC"; @@ -1024,7 +1078,10 @@ if (empty($reshook)) { $obj = $db->fetch_object($resql); if ($obj) { $deletelineid = $obj->rowid; - $invoice->deleteLine($deletelineid); + $result = takeposDeleteLineWithChildren($invoice, $deletelineid); + if ($result < 0) { + dol_htmloutput_errors($invoice->error, $invoice->errors, 1); + } } $invoice->fetch($placeid); } From b2bba40457c4eb0225ac20f1fdbd19c6424a62ea Mon Sep 17 00:00:00 2001 From: Nathan <130627685+nathanpixodeo@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:22:25 +0700 Subject: [PATCH 134/307] fix: save member extrafields from subscriptions tab (#39095) Co-authored-by: Laurent Destailleur --- htdocs/adherents/subscription.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/htdocs/adherents/subscription.php b/htdocs/adherents/subscription.php index c3003137e3e..be9a5148b19 100644 --- a/htdocs/adherents/subscription.php +++ b/htdocs/adherents/subscription.php @@ -123,6 +123,11 @@ if ($id > 0 || !empty($ref)) { // Define variables to determine what the current user can do on the members $permissiontoaddmember = $user->hasRight('adherent', 'creer'); +$permissiontoeditextra = $permissiontoaddmember; +if (GETPOST('attribute', 'aZ09') && isset($extrafields->attributes[$object->table_element]['perms'][GETPOST('attribute', 'aZ09')])) { + // For action 'update_extras', is there a specific permission set for the attribute to update + $permissiontoeditextra = dol_eval((string) $extrafields->attributes[$object->table_element]['perms'][GETPOST('attribute', 'aZ09')]); +} // Security check $result = restrictedArea($user, 'adherent', $object->id, '', '', 'socid', 'rowid', 0); @@ -138,6 +143,28 @@ if ($reshook < 0) { setEventMessages($hookmanager->error, $hookmanager->errors, 'errors'); } +if (empty($reshook) && $action == 'update_extras' && $permissiontoeditextra) { + $error = 0; + $object->oldcopy = dol_clone($object, 2); // @phan-suppress-current-line PhanTypeMismatchProperty + $attribute_name = GETPOST('attribute', 'aZ09'); + + // Fill array 'array_options' with data from update form + $ret = $extrafields->setOptionalsFromPost(null, $object, $attribute_name); + if ($ret < 0) { + $error++; + } + if (!$error) { + $result = $object->updateExtraField($attribute_name, 'MEMBER_MODIFY'); + if ($result < 0) { + setEventMessages($object->error, $object->errors, 'errors'); + $error++; + } + } + if ($error) { + $action = 'edit_extras'; + } +} + // Create third party from a member if (empty($reshook) && $action == 'confirm_create_thirdparty' && $confirm == 'yes' && $user->hasRight('societe', 'creer')) { if ($result > 0) { From a8ec8b3cdf602ecc937dc3882e443b96209ebf7c Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 13 Jul 2026 23:45:41 +0200 Subject: [PATCH 135/307] Trans --- htdocs/contrat/card.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/contrat/card.php b/htdocs/contrat/card.php index a5b0a7a1241..9fd4a9c1a71 100644 --- a/htdocs/contrat/card.php +++ b/htdocs/contrat/card.php @@ -1610,7 +1610,7 @@ if ($action == 'create') { $expired= $object->getTotalizedLines(4, 1); $close= $object->getTotalizedLines(5, 0); - print '
'.$langs->trans("Lines").'
'.$langs->trans("Quantity").''.($all['total_qty'] ? price2num($all['total_qty']) : '0').''.($draft['total_qty'] ? price2num($draft['total_qty']) : '0').''.($enabled['total_qty'] ? price2num($enabled['total_qty']) : '0').'
'.$form->textwithpicto($langs->trans("PasswordToChange"), $langs->trans("ForcePasswordChange")).''; - //$permissiontoselfeditpassword = $object->hasRight('user', 'self', 'password'); - $permissiontoselfeditpassword = 1; // In creation, we suppose it to true - if ($permissiontoselfeditpassword) { // @phpstan-ignore-line because value is forced - print ''; - print ''; - } else { - print ''; - print $form->textwithpicto(''.$langs->trans("NotPossible").'', $langs->trans("UserDoesNotHaveRightsToChangeHisPassword")); - } - print '
'.$langs->trans("Password").''; @@ -1303,7 +1286,6 @@ if ($action == 'create' || $action == 'adduserldap') { } } } - // Other form for user password $parameters = array('valuetoshow' => $valuetoshow, 'password' => $password, 'caneditpasswordandsee' => $permissiontoeditpasswordandsee, 'caneditpasswordandsend' => $permissiontoeditpasswordandsend); $reshook = $hookmanager->executeHooks('printUserPasswordField', $parameters, $object, $action); // Note that $action and $object may have been modified by hook @@ -1316,6 +1298,23 @@ if ($action == 'create' || $action == 'adduserldap') { print $valuetoshow; print '
'; + //$permissiontoselfeditpassword = $object->hasRight('user', 'self', 'password'); + $permissiontoselfeditpassword = 1; // In creation, we suppose it to true + if ($permissiontoselfeditpassword) { // @phpstan-ignore-line because value is forced + print ''; + print ''; + } else { + print ''; + print $form->textwithpicto(''.$langs->trans("ForcePasswordChange").'', $langs->trans("UserDoesNotHaveRightsToChangeHisPassword")); + } + print '
'.$form->editfieldkey($form->textwithpicto($langs->trans("PasswordToChange"), $langs->trans("ForcePasswordChange")), 'forcepasswordchange', '', $object, 0).''; - $permissiontoselfeditpassword = $object->hasRight('user', 'self', 'password'); - if ($permissiontoselfeditpassword) { - if ($permissiontoedit) { - print 'force_pass_change ? ' checked="checked"' : '').'>'; - print ''; - } else { - print 'force_pass_change ? ' checked="checked"' : '').'>'; - print $langs->trans("AtNextLogin"); - } - } else { - print ''; - print ''.$langs->trans("UserDoesNotHaveRightsToChangeHisPassword").''; - } - - print '
'.$langs->trans("Password").''; @@ -2805,7 +2782,7 @@ if ($action == 'create' || $action == 'adduserldap') { if ($permissiontoeditpasswordandsee) { $valuetoshow .= ($valuetoshow ? (' '.$langs->trans("or").' ') : '').''; if (!empty($conf->use_javascript_ajax)) { - $valuetoshow .= img_picto((getDolGlobalString('USER_PASSWORD_GENERATED') === 'none' ? $langs->transnoentities('NoPasswordGenerationRuleConfigured') : $langs->transnoentities('Generate')), 'refresh', 'id="generate_password" class="paddingleft'.(getDolGlobalString('USER_PASSWORD_GENERATED') === 'none' ? ' opacitymedium' : ' linkobject').'"'); + $valuetoshow .= img_picto((getDolGlobalString('USER_PASSWORD_GENERATED') === 'none' ? $langs->transnoentities('NoPasswordGenerationRuleConfigured') : $langs->transnoentities('Generate')), 'refresh', 'id="generate_password" class="paddingleft'.(getDolGlobalString('USER_PASSWORD_GENERATED') === 'none' ? ' ' : ' linkobject').'"'); } } else { $valuetoshow .= ($valuetoshow ? (' '.$langs->trans("or").' ') : '').preg_replace('/./i', '*', $object->pass); @@ -2823,6 +2800,27 @@ if ($action == 'create' || $action == 'adduserldap') { print $valuetoshow; print "
'; + $permissiontoselfeditpassword = $object->hasRight('user', 'self', 'password'); + if ($permissiontoselfeditpassword) { + if ($permissiontoedit) { + print 'force_pass_change ? ' checked="checked"' : '').'>'; + print ''; + } else { + print 'force_pass_change ? ' checked="checked"' : '').'>'; + print $langs->trans("ForcePasswordChange"); + } + } else { + print ''; + print ''.$langs->trans("ForcePasswordChange").''; + } + + print '
'.$langs->trans("Request").' '.($i + 1)." sql='".dol_htmlentities($newsql, ENT_NOQUOTES)."'
'.$langs->trans("Request").' '.($i + 1)." sql='".dol_htmlentities($newsql, ENT_NOQUOTES)."'
'.$select_obj->rowid.''.$select_obj->fk_facture_fourn.''.$select_obj->amount.'
'.$langs->trans('ThirdPartyName'); if (!empty($array_query['cust_name'])) { - print img_picto($langs->trans('AdvTgtUse'), 'ok.png@advtargetemailing'); + print img_picto($langs->trans('AdvTgtUse'), 'tick', 'class="marginleftonly"'); } print ''."\n"; print $form->textwithpicto('', $langs->trans("AdvTgtSearchTextHelp"), 1, 'help'); diff --git a/htdocs/main.inc.php b/htdocs/main.inc.php index adb23f19f6d..1ee8a5a337d 100644 --- a/htdocs/main.inc.php +++ b/htdocs/main.inc.php @@ -380,6 +380,7 @@ if ((!defined('NOCSRFCHECK') && empty($dolibarr_nocsrfcheck) && getDolGlobalInt( 'editfile', 'editvalidator', 'file_manager', + 'getCategories', 'history', 'presend', 'presend_addmessage', From a17bfdf395ec7e99660a3b1a4ab6ece4d3f6882f Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 06:54:27 +0200 Subject: [PATCH 290/307] Debug v24 --- htdocs/admin/translation.php | 2 +- htdocs/ai/css/ai_assistant.css | 13 +++++++++++-- htdocs/ai/js/ai_assistant.js | 8 ++++---- htdocs/ai/lib/ai.lib.php | 12 ++++++++---- htdocs/core/lib/functions.lib.php | 2 +- 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/htdocs/admin/translation.php b/htdocs/admin/translation.php index 557f23a2c8e..db69903a22f 100644 --- a/htdocs/admin/translation.php +++ b/htdocs/admin/translation.php @@ -278,7 +278,7 @@ if ($transvalue) { } -print ''; +print ''; if ($optioncss != '') { print ''; } diff --git a/htdocs/ai/css/ai_assistant.css b/htdocs/ai/css/ai_assistant.css index 1bf80b2f659..16c298361b4 100644 --- a/htdocs/ai/css/ai_assistant.css +++ b/htdocs/ai/css/ai_assistant.css @@ -616,6 +616,15 @@ body.ai-fullpage { } /* Input Field */ +#user-input::placeholder { + font-size: 0.95rem; +} +@media (max-width: 768px) { + #user-input::placeholder { + opacity: 0; + } +} + #user-input { flex-grow: 1; padding: 11px 15px; @@ -744,7 +753,7 @@ body.ai-fullpage { } /* Helpers */ -.hidden { +.ai-hidden { display: none !important; } @@ -964,7 +973,7 @@ body.ai-fullpage { position: fixed; top: var(--ai-popover-top, 48px); right: 12px; - width: 380px; + width: 460px; height: min(560px, calc(100vh - var(--ai-popover-top, 48px) - 16px)); z-index: 1010; display: flex; diff --git a/htdocs/ai/js/ai_assistant.js b/htdocs/ai/js/ai_assistant.js index a0dc7f0632f..af92d8de282 100644 --- a/htdocs/ai/js/ai_assistant.js +++ b/htdocs/ai/js/ai_assistant.js @@ -175,8 +175,8 @@ export function initAiAssistant(container) { */ function updateInterfaceMode(mode) { // Reset all wrappers - micWrapper.classList.add('hidden'); - uploadWrapper.classList.add('hidden'); + micWrapper.classList.add('ai-hidden'); + uploadWrapper.classList.add('ai-hidden'); if (mode === 'text') { input.placeholder = t('TypeYourQuestion'); @@ -184,13 +184,13 @@ export function initAiAssistant(container) { } else if (mode === 'cloud' || mode === 'whisper') { // Voice Modes - micWrapper.classList.remove('hidden'); + micWrapper.classList.remove('ai-hidden'); input.placeholder = 'Type or speak...'; initEngine(mode); } else if (mode === 'local_docs' || mode === 'cloud_docs') { // Document Modes - uploadWrapper.classList.remove('hidden'); + uploadWrapper.classList.remove('ai-hidden'); input.placeholder = mode === 'local_docs' ? t('UploadLocalDoc') : t('UploadCloudDoc'); diff --git a/htdocs/ai/lib/ai.lib.php b/htdocs/ai/lib/ai.lib.php index 05c95c03182..81c79806ff5 100644 --- a/htdocs/ai/lib/ai.lib.php +++ b/htdocs/ai/lib/ai.lib.php @@ -616,7 +616,7 @@ function getAiChatAssistantConfig() */ function getAiChatAssistantHtml($mode = 'page') { - global $langs, $user; + global $langs, $user, $conf; $out = ''; @@ -711,16 +711,20 @@ function getAiChatAssistantHtml($mode = 'page') $out .= '
'; $out .= '
'; // Upload Wrapper (Visible only in Doc modes) - $out .= '

\n"; From 7660df047addf061a2156a72e2d1f52dde39ece4 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 07:13:00 +0200 Subject: [PATCH 292/307] Fix bad sanitization in advanced mailing target - Reported by Davide Saladino --- htdocs/comm/mailing/advtargetemailing.php | 4 ++-- htdocs/comm/mailing/class/advtargetemailing.class.php | 2 +- htdocs/core/lib/functions.lib.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/comm/mailing/advtargetemailing.php b/htdocs/comm/mailing/advtargetemailing.php index cd803c36e5d..7187c97a6a1 100644 --- a/htdocs/comm/mailing/advtargetemailing.php +++ b/htdocs/comm/mailing/advtargetemailing.php @@ -203,7 +203,7 @@ if ($action == 'add' && $permissiontoadd) { // Add recipients } if (preg_match("/^contact_/", $key)) { - $array_query[$key] = GETPOST($key); + $array_query[$key] = GETPOSTISARRAY($key) ? GETPOST($key, 'array:int') : GETPOST($key); $specials_date_key = array( 'contact_update_st_dt', @@ -232,7 +232,7 @@ if ($action == 'add' && $permissiontoadd) { // Add recipients } if (preg_match("/^type_of_target/", $key)) { - $array_query[$key] = GETPOST($key); + $array_query[$key] = GETPOSTINT($key); } } diff --git a/htdocs/comm/mailing/class/advtargetemailing.class.php b/htdocs/comm/mailing/class/advtargetemailing.class.php index 7cd80025f44..4e8e5e3affa 100644 --- a/htdocs/comm/mailing/class/advtargetemailing.class.php +++ b/htdocs/comm/mailing/class/advtargetemailing.class.php @@ -764,7 +764,7 @@ class AdvanceTargetingMailing extends CommonObject $sqlwhere[] = " (t.datec >= '".$this->db->idate($arrayquery['contact_create_st_dt'])."' AND t.datec <= '".$this->db->idate($arrayquery['contact_create_end_dt'])."')"; } if (!empty($arrayquery['contact_categ']) && count($arrayquery['contact_categ']) > 0) { - $sqlwhere[] = " (contactcateg.fk_categorie IN (".$this->db->escape(implode(",", $arrayquery['contact_categ']))."))"; + $sqlwhere[] = " (contactcateg.fk_categorie IN (".$this->db->sanitize(implode(",", $arrayquery['contact_categ']), 0, 0, 0, 0)."))"; } //Standard Extrafield feature diff --git a/htdocs/core/lib/functions.lib.php b/htdocs/core/lib/functions.lib.php index e1ea875756f..799989c9a4c 100644 --- a/htdocs/core/lib/functions.lib.php +++ b/htdocs/core/lib/functions.lib.php @@ -1302,7 +1302,7 @@ function GETPOST($paramname, $check = 'alphanohtml', $method = 0, $filter = null } // Check type of variable and make sanitization according to this - if (preg_match('/^array/', $check)) { // If 'array' or 'array:restricthtml' or 'array:aZ09' or 'array:intcomma' + if (preg_match('/^array/', $check)) { // If 'array' or 'array:restricthtml' or 'array:aZ09' or 'array:int' $tmpcheck = 'alphanohtml'; if ($out === null || $out === '') { $out = array(); From a4d355d24fafaccb27c1230797e7baae62279675 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 07:21:20 +0200 Subject: [PATCH 293/307] Fix module param must be sanitized with aZ09arobase - reported by Davide Saladino --- htdocs/core/js/lib_head.js.php | 3 +++ htdocs/ecm/dir_add_card.php | 2 +- htdocs/ecm/dir_card.php | 2 +- htdocs/ecm/file_card.php | 2 +- htdocs/ecm/index_auto.php | 13 ++++++------- htdocs/ecm/search.php | 2 +- htdocs/ecm/tpl/enablefiletreeajax.tpl.php | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/htdocs/core/js/lib_head.js.php b/htdocs/core/js/lib_head.js.php index 3426a83d592..93558ac2363 100644 --- a/htdocs/core/js/lib_head.js.php +++ b/htdocs/core/js/lib_head.js.php @@ -505,6 +505,9 @@ function getIntegerInString(str,i,minlength,maxlength) */ function urlencode(s) { var news = s; + if (typeof news === "number") { + news = news.toString(); + } news = news.replace(/\+/gi,'%2B'); news = news.replace(/&/gi,'%26'); return news; diff --git a/htdocs/ecm/dir_add_card.php b/htdocs/ecm/dir_add_card.php index 071ecb1a581..9e63db431f1 100644 --- a/htdocs/ecm/dir_add_card.php +++ b/htdocs/ecm/dir_add_card.php @@ -53,7 +53,7 @@ $cancel = GETPOST('cancel'); $backtopage = GETPOST('backtopage', 'alpha'); $confirm = GETPOST('confirm', 'alpha'); -$module = GETPOST('module', 'alpha'); +$module = GETPOST('module', 'aZ09arobase'); $website = GETPOST('website', 'alpha'); $pageid = GETPOSTINT('pageid'); if (empty($module)) { diff --git a/htdocs/ecm/dir_card.php b/htdocs/ecm/dir_card.php index b20a5025fd0..7245dd5e895 100644 --- a/htdocs/ecm/dir_card.php +++ b/htdocs/ecm/dir_card.php @@ -49,7 +49,7 @@ $cancel = GETPOST('cancel'); $backtopage = GETPOST('backtopage', 'alpha'); $confirm = GETPOST('confirm', 'alpha'); -$module = GETPOST('module', 'alpha'); +$module = GETPOST('module', 'aZ09arobase'); $website = GETPOST('website', 'alpha'); $pageid = GETPOSTINT('pageid'); if (empty($module)) { diff --git a/htdocs/ecm/file_card.php b/htdocs/ecm/file_card.php index ae08004edcf..760d4fb452e 100644 --- a/htdocs/ecm/file_card.php +++ b/htdocs/ecm/file_card.php @@ -48,7 +48,7 @@ $langs->loadLangs(array('ecm', 'companies', 'other', 'users', 'orders', 'propal' $action = GETPOST('action', 'aZ09'); $cancel = GETPOST('cancel', 'alpha'); $backtopage = GETPOST('backtopage', 'alpha'); -$module = GETPOST('module', 'alpha'); +$module = GETPOST('module', 'aZ09arobase'); // Get parameters $socid = GETPOSTINT("socid"); diff --git a/htdocs/ecm/index_auto.php b/htdocs/ecm/index_auto.php index da89463f76b..792efe753dd 100644 --- a/htdocs/ecm/index_auto.php +++ b/htdocs/ecm/index_auto.php @@ -28,12 +28,6 @@ // Load Dolibarr environment require '../main.inc.php'; -require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/ecm.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/treeview.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/ecm/class/ecmdirectory.class.php'; - /** * @var Conf $conf * @var DoliDB $db @@ -41,6 +35,11 @@ require_once DOL_DOCUMENT_ROOT.'/ecm/class/ecmdirectory.class.php'; * @var Translate $langs * @var User $user */ +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/ecm.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/treeview.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/ecm/class/ecmdirectory.class.php'; // Load translation files required by the page $langs->loadLangs(array("ecm", "companies", "other", "users", "orders", "propal", "bills", "contracts")); @@ -49,7 +48,7 @@ $langs->loadLangs(array("ecm", "companies", "other", "users", "orders", "propal" $socid = GETPOSTINT('socid'); $action = GETPOST('action', 'aZ09'); $section = GETPOSTINT('section') ? GETPOSTINT('section') : GETPOSTINT('section_id'); -$module = GETPOST('module', 'alpha'); +$module = GETPOST('module', 'aZ09arobase'); if (!$section) { $section = 0; } diff --git a/htdocs/ecm/search.php b/htdocs/ecm/search.php index 4794c95c8da..76788029518 100644 --- a/htdocs/ecm/search.php +++ b/htdocs/ecm/search.php @@ -60,7 +60,7 @@ if (!$section) { $section = 0; } -$module = GETPOST('module', 'alpha'); +$module = GETPOST('module', 'aZ09arobase'); $website = GETPOST('website', 'alpha'); $pageid = GETPOSTINT('pageid'); if (empty($module)) { diff --git a/htdocs/ecm/tpl/enablefiletreeajax.tpl.php b/htdocs/ecm/tpl/enablefiletreeajax.tpl.php index 30181d55171..67288acf25c 100644 --- a/htdocs/ecm/tpl/enablefiletreeajax.tpl.php +++ b/htdocs/ecm/tpl/enablefiletreeajax.tpl.php @@ -115,7 +115,7 @@ function loadandshowpreview(filedirname,section) $('#ecmfileview').empty(); - var url = '?action=preview&module=§ion='+section+'&file='+urlencode(filedirname); + var url = '§ion='+urlencode(section)+'&file='+urlencode(filedirname); $.get(url, function(data) { //alert('Load of url '+url+' was performed : '+data); pos=data.indexOf("TYPE=directory",0); From fdaa30b406e14a37ff074a052755bc5541e14f08 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 09:47:32 +0200 Subject: [PATCH 294/307] Debug setup install. Show warnings on problems. --- htdocs/admin/company.php | 10 ++++++++-- htdocs/admin/company_socialnetworks.php | 4 ++-- htdocs/admin/openinghours.php | 15 +++++++-------- htdocs/admin/subcontractors.php | 6 ++---- htdocs/install/default.css | 9 +++++++++ htdocs/install/inc.php | 16 +++++++++++++--- htdocs/install/step5.php | 8 ++++---- 7 files changed, 45 insertions(+), 23 deletions(-) diff --git a/htdocs/admin/company.php b/htdocs/admin/company.php index 758512de5e2..5c5bd9c3531 100644 --- a/htdocs/admin/company.php +++ b/htdocs/admin/company.php @@ -75,6 +75,12 @@ $hookmanager->initHooks(array('admincompany', 'globaladmin')); $object = new Societe($db); +if (!getDolGlobalString('MAIN_INFO_SOCIETE_NOM') || !getDolGlobalString('MAIN_INFO_SOCIETE_COUNTRY') || getDolGlobalString('MAIN_INFO_SOCIETE_SETUP_TODO_WARNING')) { + $setupcompanynotcomplete = 1; +} else { + $setupcompanynotcomplete = 0; +} + /* * Actions @@ -444,8 +450,8 @@ $head = company_admin_prepare_head(); print dol_get_fiche_head($head, 'company', '', -1, ''); -print ''.$langs->trans("CompanyFundationDesc", $langs->transnoentities("Save"))."
\n"; -print "

\n"; +print '
'.$langs->trans("CompanyFundationDesc", $langs->transnoentities("Save"))."
\n"; +print "
\n"; // Edit parameters diff --git a/htdocs/admin/company_socialnetworks.php b/htdocs/admin/company_socialnetworks.php index 18b5493d9eb..5ce5e5bfd83 100644 --- a/htdocs/admin/company_socialnetworks.php +++ b/htdocs/admin/company_socialnetworks.php @@ -103,8 +103,8 @@ $head = company_admin_prepare_head(); print dol_get_fiche_head($head, 'socialnetworks', '', -1, ''); -print ''.$langs->trans("CompanyFundationDesc", $langs->transnoentities("Save"))."
\n"; -print ''.$langs->trans("MoreNetworksAvailableWithModule")."
\n"; +print '
'.$langs->trans("CompanyFundationDesc", $langs->transnoentities("Save"))."
\n"; +print $langs->trans("MoreNetworksAvailableWithModule")."
\n"; print "
\n"; diff --git a/htdocs/admin/openinghours.php b/htdocs/admin/openinghours.php index 62957712aca..b3560280d6f 100644 --- a/htdocs/admin/openinghours.php +++ b/htdocs/admin/openinghours.php @@ -24,12 +24,6 @@ // Load Dolibarr environment require '../main.inc.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php'; - -$action = GETPOST('action', 'aZ09'); -$contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'adminaccoutant'; // To manage different context of search - /** * @var Conf $conf * @var DoliDB $db @@ -37,6 +31,11 @@ $contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'ad * @var Translate $langs * @var User $user */ +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php'; + +$action = GETPOST('action', 'aZ09'); +$contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'adminaccoutant'; // To manage different context of search // Load translation files required by the page $langs->loadLangs(array('admin', 'companies', 'other')); @@ -105,8 +104,8 @@ $head = company_admin_prepare_head(); print dol_get_fiche_head($head, 'openinghours', '', -1, ''); -print ''.$langs->trans("OpeningHoursDesc")."
\n"; -print "

\n"; +print '
'.$langs->trans("OpeningHoursDesc")."
\n"; +print "
\n"; if (empty($action) || $action == 'edit' || $action == 'updateedit') { /** diff --git a/htdocs/admin/subcontractors.php b/htdocs/admin/subcontractors.php index b8a7aa18d83..b0d6f8c35af 100644 --- a/htdocs/admin/subcontractors.php +++ b/htdocs/admin/subcontractors.php @@ -148,13 +148,11 @@ $head = company_admin_prepare_head(); print dol_get_fiche_head($head, 'subcontractors', '', -1, ''); $form = new Form($db); -$formother = new FormOther($db); $formcompany = new FormCompany($db); -$countrynotdefined = ''.$langs->trans("ErrorSetACountryFirst").' ('.$langs->trans("SeeAbove").')'; -print ''.$langs->trans("SubcontractorsDesc")."
\n"; -print "

\n"; +print '
'.$langs->trans("SubcontractorsDesc")."
\n"; +print "
\n"; /** * Edit parameters diff --git a/htdocs/install/default.css b/htdocs/install/default.css index 34318319fd5..c86674463ea 100644 --- a/htdocs/install/default.css +++ b/htdocs/install/default.css @@ -350,6 +350,15 @@ span.okinversed { div.warning, span.warning { color: #777711; } +div.warningbackground { + padding-top: 16px; + padding-left: 20px; + padding-right: 10px; + padding-bottom: 16px; + margin: 1em 0em 1em 0em; + border-radius: 5px; + background: #fcf8e3; +} /* Error */ div.error { diff --git a/htdocs/install/inc.php b/htdocs/install/inc.php index 7724571563b..1b8cb503792 100644 --- a/htdocs/install/inc.php +++ b/htdocs/install/inc.php @@ -52,6 +52,8 @@ require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; @phan-var-force ?string $dolibarr_main_db_prefix '; +$realpathconf = realpath(DOL_DOCUMENT_ROOT.'/core/class/conf.class.php'); + $conf = new Conf(); // Force $_REQUEST["logtohtml"] @@ -245,7 +247,7 @@ if (!defined('DONOTLOADCONF') && file_exists($conffile) && filesize($conffile) > // Create conf object if (!empty($dolibarr_main_document_root)) { - $result = conf($dolibarr_main_document_root); + $result = conf($dolibarr_main_document_root, $realpathconf); } // Load database driver if ($result > 0) { @@ -420,10 +422,11 @@ if (GETPOST('lang', 'aZ09')) { /** * Load conf file (file must exists) * - * @param string $dolibarr_main_document_root Root directory of Dolibarr bin files + * @param string $dolibarr_main_document_root Root directory of Dolibarr program files + * @param ?string $realpathconf Real path of conf class if class was already included * @return int Return integer <0 if KO, >0 if OK */ -function conf($dolibarr_main_document_root) +function conf($dolibarr_main_document_root, $realpathconf = null) { global $conf; global $dolibarr_main_db_type; @@ -438,6 +441,13 @@ function conf($dolibarr_main_document_root) global $dolibarr_main_instance_unique_id; global $dolibarr_main_cookie_cryptkey; + if ($realpathconf) { + if (realpath($dolibarr_main_document_root.'/core/class/conf.class.php') != $realpathconf) { + print 'Warning: You are running files from directory ('.preg_replace('/core\/class\/conf\.class\.php$/', '', $realpathconf).') that differs from the directory already defined into the existing conf.php file ($dolibarr_main_document_root = '.$dolibarr_main_document_root.').'; + die(-1); + } + } + $return = @include_once $dolibarr_main_document_root.'/core/class/conf.class.php'; if (!$return) { return -1; diff --git a/htdocs/install/step5.php b/htdocs/install/step5.php index 86796510c27..1521ce78c86 100644 --- a/htdocs/install/step5.php +++ b/htdocs/install/step5.php @@ -320,7 +320,7 @@ if ($action == "set" || empty($action) || preg_match('/upgrade/i', $action)) { } else { if ($result == -6) { //login or email already exists dolibarr_install_syslog('step5: AdminLoginAlreadyExists', LOG_WARNING); - print '
'.$newuser->error."

"; + print '
'.$newuser->error."
"; $success = 1; } else { dolibarr_install_syslog('step5: FailedToCreateAdminLogin '.$newuser->error, LOG_ERR); @@ -522,7 +522,7 @@ if ($action == "set") { if ($success) { if (!getDolGlobalString('MAIN_VERSION_LAST_UPGRADE') || (getDolGlobalString('MAIN_VERSION_LAST_UPGRADE') == DOL_VERSION)) { // Install is finished (database is on same version than files) - print '
'.$langs->trans("SystemIsInstalled")."
"; + print '
'.$langs->trans("SystemIsInstalled")."

"; // Create install.lock file // No need for the moment to create it automatically, creation by web assistant means permissions are given @@ -545,7 +545,7 @@ if ($action == "set") { } } if (empty($createlock)) { - print '
'.$langs->trans("WarningRemoveInstallDir")."
"; + print '
'.$langs->trans("WarningRemoveInstallDir")."
"; } print "
"; @@ -594,7 +594,7 @@ if ($action == "set") { } } if (empty($createlock)) { - print '
'.$langs->trans("WarningRemoveInstallDir")."
"; + print '
'.$langs->trans("WarningRemoveInstallDir")."
"; } // Delete the upgrade.unlock file it it exists From 7345c21d5123166a0fb558620a0eb442af9e0ef4 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 09:48:48 +0200 Subject: [PATCH 295/307] Prepare final v24 --- htdocs/version.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/version.inc.php b/htdocs/version.inc.php index 21c702acc50..537eceefc27 100644 --- a/htdocs/version.inc.php +++ b/htdocs/version.inc.php @@ -38,7 +38,7 @@ if (!defined('DOL_APPLICATION_TITLE')) { // The major version of Dolibarr define('DOL_MAJOR_VERSION', '24'); -define('DOL_MINOR_VERSION', '0.0-beta'); +define('DOL_MINOR_VERSION', '0.0'); define('DOL_VERSION', constant('DOL_MAJOR_VERSION').'.'.constant('DOL_MINOR_VERSION')); From cad37ed37e1ca60a3439c854eecaf4404d971268 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 09:59:48 +0200 Subject: [PATCH 296/307] Debug v24 --- htdocs/install/upgrade2.php | 2 +- htdocs/version.inc.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/install/upgrade2.php b/htdocs/install/upgrade2.php index 0d929b3d18e..0124bbaf379 100644 --- a/htdocs/install/upgrade2.php +++ b/htdocs/install/upgrade2.php @@ -720,7 +720,7 @@ if (!GETPOST('action', 'aZ09') || preg_match('/upgrade/i', GETPOST('action', 'aZ 'MAIN_MODULE_RESOURCE' => 'noboxes', 'MAIN_MODULE_SALARIES' => 'newboxdefonly', 'MAIN_MODULE_SERVICE' => 'newboxdefonly', - 'MAIN_MODULE_SYSLOG' => 'newboxdefonly', + //'MAIN_MODULE_SYSLOG' => 'newboxdefonly', This enabled the module syslog, but we don't want to do that. 'MAIN_MODULE_SOCIETE' => 'newboxdefonly', 'MAIN_MODULE_STRIPE' => 'menuonly', 'MAIN_MODULE_TICKET' => 'newboxdefonly', diff --git a/htdocs/version.inc.php b/htdocs/version.inc.php index 537eceefc27..21c702acc50 100644 --- a/htdocs/version.inc.php +++ b/htdocs/version.inc.php @@ -38,7 +38,7 @@ if (!defined('DOL_APPLICATION_TITLE')) { // The major version of Dolibarr define('DOL_MAJOR_VERSION', '24'); -define('DOL_MINOR_VERSION', '0.0'); +define('DOL_MINOR_VERSION', '0.0-beta'); define('DOL_VERSION', constant('DOL_MAJOR_VERSION').'.'.constant('DOL_MINOR_VERSION')); From 8df0c160c32f7fea56457d521d559f7eb1e01839 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 10:01:56 +0200 Subject: [PATCH 297/307] Doc --- dev/build/makepack-howto.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/build/makepack-howto.md b/dev/build/makepack-howto.md index 8eca0e0807d..1cee5b9190d 100644 --- a/dev/build/makepack-howto.md +++ b/dev/build/makepack-howto.md @@ -53,7 +53,7 @@ Prerequisites to build autoexe DoliWamp package from Windows: ``` -## Actions to do a BETA +## Actions to do a FREEZE / BETA This section describes steps made by Dolibarr packaging team to make a beta version of Dolibarr, step by step. @@ -84,7 +84,7 @@ git log x.y.z-1.. --no-merges --pretty=short --oneline | sed -e "s/^[0-9a-z]* // - Recopy the content of the output file into the file ChangeLog. - Note: To know number of lines changes: git diff --shortstat A B + Note: To know number of changed lines: git diff --shortstat A B - Update version number with x.y.z-w in file htdocs/filefunc.inc.php From 4a36047716c72f9195e8a720937047f755027ff9 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 16:21:18 +0200 Subject: [PATCH 298/307] Try to fix bad decoding of encrypted key with accents (utf8 keys) --- htdocs/blockedlog/lib/securitycore.lib.php | 5 ++-- htdocs/core/lib/security.lib.php | 2 +- test/phpunit/SecurityTest.php | 28 +++++++++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/htdocs/blockedlog/lib/securitycore.lib.php b/htdocs/blockedlog/lib/securitycore.lib.php index 4323c0360c8..56f21b80f73 100644 --- a/htdocs/blockedlog/lib/securitycore.lib.php +++ b/htdocs/blockedlog/lib/securitycore.lib.php @@ -193,9 +193,10 @@ function dolDecrypt($chain, $key = '', $patterntotest = '') } // Test validity of decryption - if (!ascii_check($newchain)) { + //if (!ascii_check($newchain)) { + if (!ascii_check($newchain) && !utf8_check($newchain)) { if (empty($savkey)) { - dol_syslog("Error dolDecrypt failed: The key dolibarr_main_dolcrypt or dolibarr_main_instance_unique_id, found in conf.php file, is the the one used to encrypt this encrypted string", LOG_ERR); + dol_syslog("Error dolDecrypt failed: The key dolibarr_main_dolcrypt or dolibarr_main_instance_unique_id, found in conf.php file, seems the one used to encrypt the encrypted string", LOG_ERR); } else { dol_syslog("Error dolDecrypt failed: The string decoded with the key return a non valid value (not ascii)", LOG_ERR); } diff --git a/htdocs/core/lib/security.lib.php b/htdocs/core/lib/security.lib.php index e4e1d5cb48d..0aad43d0721 100644 --- a/htdocs/core/lib/security.lib.php +++ b/htdocs/core/lib/security.lib.php @@ -174,7 +174,7 @@ function dolGetLdapPasswordHash($password, $type = 'md5') * @param string $dbt_select Field rowid name, for select into tableandshare if not "rowid". Not used if objectid is null (optional) * @param int<0,1> $isdraft 1=The object with id=$objectid is a draft * @param int<0,1> $mode Mode (0=default, 1=return without dying) - * @return int If mode = 0 (default): Always 1, die process if not allowed. If mode = 1: Return 0 if access not allowed. + * @return int If mode = 0 (default): die process if not allowed (else return 1). If mode = 1: Return 0 if access not allowed (else return 1). * @see dol_check_secure_access_document(), checkUserAccessToObject() */ function restrictedArea(User $user, $features, $object = 0, $tableandshare = '', $feature2 = '', $dbt_keyfield = 'fk_soc', $dbt_select = 'rowid', $isdraft = 0, $mode = 0) diff --git a/test/phpunit/SecurityTest.php b/test/phpunit/SecurityTest.php index fdf4dd7bf1f..40dd487d82f 100644 --- a/test/phpunit/SecurityTest.php +++ b/test/phpunit/SecurityTest.php @@ -56,6 +56,7 @@ if (! defined("NOSESSION")) { require_once dirname(__FILE__).'/../../htdocs/main.inc.php'; // We force include of main.inc.php instead of master.inc.php even if we are in CLI mode because it contains a lot of security components we want to test. require_once dirname(__FILE__).'/../../htdocs/core/lib/security.lib.php'; +require_once dirname(__FILE__).'/../../htdocs/blockedlog/lib/securitycore.lib.php'; require_once dirname(__FILE__).'/../../htdocs/core/lib/security2.lib.php'; require_once dirname(__FILE__).'/CommonClassTest.class.php'; @@ -101,6 +102,31 @@ class SecurityTest extends CommonClassTest } + + /** + * testDolEncryptDolDecrypt + * + * @return void + */ + public function testDolEncryptDolDecrypt() + { + $s = 'simple string with no special char a..z 1..0'; + $es = dolEncrypt($s); + $news = dolDecrypt($es); + + print __METHOD__.' testDolEncryptDolDecrypt '.$s.' ==> '.$es.' ==> '.$news."\n"; + $this->assertEquals($news, $s); + + + $s = 'string with à é ç'; + $es = dolEncrypt($s); + $news = dolDecrypt($es); + + print __METHOD__.' testDolEncryptDolDecrypt '.$s.' ==> '.$es.' ==> '.$news."\n"; + $this->assertEquals($news, $s); + } + + /** * testSqlAndScriptInjectWithPHPUnit * @@ -451,7 +477,7 @@ class SecurityTest extends CommonClassTest //$dummyuser=new User($db); //$result=restrictedArea($dummyuser,'societe'); - $result = restrictedArea($user, 'societe'); + $result = restrictedArea($user, 'societe', 0, '', '', 'fk_soc', 'rowid', 0, 1); $this->assertEquals(1, $result); } From 0f6f2fd6c55e45695decd4c202d4d40270b4409e Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Fri, 24 Jul 2026 17:44:31 +0200 Subject: [PATCH 299/307] Fix possible sql injection in api expensereport - reported by Davide Saladino --- .../expensereport/class/api_expensereports.class.php | 4 ++-- htdocs/expensereport/class/expensereport.class.php | 8 ++++---- htdocs/expensereport/class/expensereportline.class.php | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/htdocs/expensereport/class/api_expensereports.class.php b/htdocs/expensereport/class/api_expensereports.class.php index 2f0d0dfd5bd..0d53a8760df 100644 --- a/htdocs/expensereport/class/api_expensereports.class.php +++ b/htdocs/expensereport/class/api_expensereports.class.php @@ -344,7 +344,7 @@ class ExpenseReports extends DolibarrApi $request_data->date, $request_data->comments, $request_data->fk_project, - $request_data->fk_c_exp_tax_cat, + (int) $request_data->fk_c_exp_tax_cat, $request_data->type, $request_data->fk_ecm_files ); @@ -414,7 +414,7 @@ class ExpenseReports extends DolibarrApi $request_data->value_unit, $request_data->date, $id, - $request_data->fk_c_exp_tax_cat, + (int) $request_data->fk_c_exp_tax_cat, $request_data->fk_ecm_files ); diff --git a/htdocs/expensereport/class/expensereport.class.php b/htdocs/expensereport/class/expensereport.class.php index 71bf9db3192..1f6185de5cc 100644 --- a/htdocs/expensereport/class/expensereport.class.php +++ b/htdocs/expensereport/class/expensereport.class.php @@ -1373,7 +1373,7 @@ class ExpenseReport extends CommonObject // Validate $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element; $sql .= " SET ref = '".$this->db->escape($num)."',"; - $sql .= " fk_statut = ".self::STATUS_VALIDATED.","; + $sql .= " fk_statut = ".((int) self::STATUS_VALIDATED).","; $sql .= " date_valid = '".$this->db->idate($this->date_valid)."',"; $sql .= " fk_user_valid = ".((int) $user->id); $sql .= " WHERE rowid = ".((int) $this->id); @@ -1480,7 +1480,7 @@ class ExpenseReport extends CommonObject if ($this->status != self::STATUS_VALIDATED) { $sql = 'UPDATE '.MAIN_DB_PREFIX.$this->table_element; - $sql .= " SET fk_statut = ".self::STATUS_VALIDATED; + $sql .= " SET fk_statut = ".((int) self::STATUS_VALIDATED); $sql .= " WHERE rowid = ".((int) $this->id); dol_syslog(get_class($this)."::set_save_from_refuse", LOG_DEBUG); @@ -1513,7 +1513,7 @@ class ExpenseReport extends CommonObject if ($this->status != self::STATUS_APPROVED) { $this->db->begin(); $sql = 'UPDATE '.MAIN_DB_PREFIX.$this->table_element; - $sql .= " SET ref = '".$this->db->escape($this->ref)."', fk_statut = ".self::STATUS_APPROVED.", fk_user_approve = ".((int) $fuser->id).","; + $sql .= " SET ref = '".$this->db->escape($this->ref)."', fk_statut = ".((int) self::STATUS_APPROVED).", fk_user_approve = ".((int) $fuser->id).","; $sql .= " date_approve='".$this->db->idate($now)."'"; $sql .= " WHERE rowid = ".((int) $this->id); if ($this->db->query($sql)) { @@ -1959,7 +1959,7 @@ class ExpenseReport extends CommonObject */ public function addline($qty = 0, $up = 0, $fk_c_type_fees = 0, $vatrate = 0, $date = '', $comments = '', $fk_project = 0, $fk_c_exp_tax_cat = 0, $type = 0, $fk_ecm_files = 0) { - global $langs, $mysoc; + global $mysoc; dol_syslog(get_class($this)."::addline qty=$qty, up=$up, fk_c_type_fees=$fk_c_type_fees, vatrate=$vatrate, date=$date, fk_project=$fk_project, type=$type, comments=$comments", LOG_DEBUG); diff --git a/htdocs/expensereport/class/expensereportline.class.php b/htdocs/expensereport/class/expensereportline.class.php index 08b593dc9b0..5d8d052bfb3 100644 --- a/htdocs/expensereport/class/expensereportline.class.php +++ b/htdocs/expensereport/class/expensereportline.class.php @@ -275,8 +275,8 @@ class ExpenseReportLine extends CommonObjectLine $this->date = $objp->date; $this->dates = $this->db->jdate($objp->date); $this->value_unit = $objp->value_unit; - $this->fk_c_type_fees = $objp->fk_c_type_fees; - $this->fk_c_exp_tax_cat = $objp->fk_c_exp_tax_cat; + $this->fk_c_type_fees = (int) $objp->fk_c_type_fees; + $this->fk_c_exp_tax_cat = (int) $objp->fk_c_exp_tax_cat; $this->fk_projet = $objp->fk_project; // deprecated $this->fk_project = $objp->fk_project; $this->type_fees_code = $objp->type_fees_code; @@ -347,7 +347,7 @@ class ExpenseReportLine extends CommonObjectLine $sql .= ' total_ht, total_tva, total_ttc,'; $sql .= ' total_localtax1, total_localtax2,'; $sql .= ' date, rule_warning_message, fk_c_exp_tax_cat, fk_ecm_files)'; - $sql .= " VALUES (".$this->db->escape((string) $this->fk_expensereport).","; + $sql .= " VALUES (".((int) $this->fk_expensereport).","; $sql .= " ".((int) $this->fk_c_type_fees).","; $sql .= " ".((int) (!empty($this->fk_project) && $this->fk_project > 0) ? ((int) $this->fk_project) : ((!empty($this->fk_projet) && $this->fk_projet > 0) ? ((int) $this->fk_projet) : 'null')).","; $sql .= " ".((float) $this->vatrate).","; @@ -491,8 +491,8 @@ class ExpenseReportLine extends CommonObjectLine $sql .= ", localtax1_type='".$this->db->escape($this->localtax1_type)."'"; $sql .= ", localtax2_type='".$this->db->escape($this->localtax2_type)."'"; $sql .= ", rule_warning_message='".$this->db->escape($this->rule_warning_message)."'"; - $sql .= ", fk_c_exp_tax_cat=".$this->db->escape((string) $this->fk_c_exp_tax_cat); - $sql .= ", fk_ecm_files=".($this->fk_ecm_files > 0 ? ((int) $this->fk_ecm_files) : 'null'); + $sql .= ", fk_c_exp_tax_cat = ".((int) $this->fk_c_exp_tax_cat); + $sql .= ", fk_ecm_files = ".($this->fk_ecm_files > 0 ? ((int) $this->fk_ecm_files) : 'null'); if ($this->fk_c_type_fees) { $sql .= ", fk_c_type_fees = ".((int) $this->fk_c_type_fees); } else { From d89568ed52d25821c2c208d807240ea6149ce204 Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Sat, 25 Jul 2026 06:17:25 +0900 Subject: [PATCH 300/307] FIX phpstan: Societe::$client (int<0,3>) does not accept -1 (#39244) Societe::$client is annotated @var int<0,3> (domain 0=no customer, 1=customer, 2=prospect, 3=both) in societe.class.php, but the create form in societe/card.php pre-set it to -1 as a transient "nothing pre-selected" placeholder, tripping PHPStan level 10 on develop. The value is never persisted (save paths compute 0..3) and the only reader, the switch() building the form, treats -1 and 0 identically via its default case. Use 0 (the property's own default) so the assignment matches the declared type. No behavior change. Co-authored-by: Claude Opus 4.8 --- htdocs/societe/card.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/societe/card.php b/htdocs/societe/card.php index 41850caf50b..2635dc406ef 100644 --- a/htdocs/societe/card.php +++ b/htdocs/societe/card.php @@ -1049,7 +1049,7 @@ if (is_object($objcanvas) && $objcanvas->displayCanvasExists($canvasdisplayactio // Define if customer/prospect or supplier status is set or not if (GETPOST("type", 'aZ') != 'f') { - $object->client = -1; + $object->client = 0; if (getDolGlobalString('THIRDPARTY_CUSTOMERPROSPECT_BY_DEFAULT')) { $object->client = 3; } From 76a83ac9ec3ca54ed958437c08f7d0d634fecf66 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 27 Jul 2026 01:19:13 +0200 Subject: [PATCH 301/307] Better dol_eval protection - reported by CyberM --- htdocs/core/lib/functions.lib.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/htdocs/core/lib/functions.lib.php b/htdocs/core/lib/functions.lib.php index 799989c9a4c..944f5077f8b 100644 --- a/htdocs/core/lib/functions.lib.php +++ b/htdocs/core/lib/functions.lib.php @@ -12350,6 +12350,11 @@ function dol_eval_standard($s, $hideerrors = 1, $onlysimplestring = '1') return 'Bad string syntax to evaluate (mode ' . $onlysimplestring . ', found a < or <= without space after): ' . $s; } + // Check if there is an include or a require + if (preg_match('/(include|include_once|require|require_once)/', $s)) { + return 'Bad string syntax to evaluate (found not allowed key include|include_once|require|require_once): ' . $s; + } + // Check if there is dynamic call (first we use black list patterns) if (preg_match('/\$[\w]*\s*\(/', $s)) { return 'Bad string syntax to evaluate (mode ' . $onlysimplestring . ', found a call using "$abc(" or "$abc (" instead of using the direct name of the function): ' . $s; From e9cc5499ff6f48c1acdb77a899d98261f645fba5 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 27 Jul 2026 04:28:59 +0200 Subject: [PATCH 302/307] Debug v24 --- htdocs/admin/modules.php | 2 +- htdocs/admin/system/dolibarr.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/admin/modules.php b/htdocs/admin/modules.php index db479b392bf..1dc19ff1f1a 100644 --- a/htdocs/admin/modules.php +++ b/htdocs/admin/modules.php @@ -1254,7 +1254,7 @@ if ($mode == 'common' || $mode == 'commonkanban') { } $urltogo = $_SERVER["PHP_SELF"].'?id='.$objMod->numero.'&token='.newToken().'&module_position='.$module_position.'&action=set&token='.newToken().'&value='.$modName.'&mode='.$mode.$param; - $popupWidth = 500; + $popupWidth = 600; $popupHeight = 300; $codeenabledisable .= ''."\n"; $codeenabledisable .= 'loadLangs(array("install", "other", "admin")); +$langs->loadLangs(array("blockedlog", "install", "other", "admin")); $action = GETPOST('action', 'aZ09'); From 438a439d2a3badff5f173d06058e3236417d32f8 Mon Sep 17 00:00:00 2001 From: Alexandre SPANGARO Date: Mon, 27 Jul 2026 07:36:46 +0200 Subject: [PATCH 303/307] NEW Accountancy - FEC export - Add constant ACCOUNTING_EXPORT_FEC_ADD_TIMESTAMP to add a timestamp in export file name --- htdocs/accountancy/tpl/export_journal.tpl.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/htdocs/accountancy/tpl/export_journal.tpl.php b/htdocs/accountancy/tpl/export_journal.tpl.php index 48e75dd4a48..7978a609c8c 100644 --- a/htdocs/accountancy/tpl/export_journal.tpl.php +++ b/htdocs/accountancy/tpl/export_journal.tpl.php @@ -80,7 +80,17 @@ if ((substr($accountancyexport->getFormatCode($formatexportset), 0, 3) == 'fec') $endaccountingperiod = dol_print_date(dol_get_last_day($tmparray['year'], $tmparray['mon']), 'dayxcard'); $siren = str_replace(" ", "", $siren); - $completefilename = $siren."FEC".$endaccountingperiod.".txt"; + $completefilename = $siren."FEC".$endaccountingperiod; + + // Option: append a timestamp suffix to avoid overwriting a previous export of the same fiscal period. + // The strict FEC naming convention (SIRENFECAAAAMMJJ.txt) is still respected by default; + // this suffix is only added if the admin explicitly enables it, since some import software + // requires strict compliance with the official filename. + if (getDolGlobalInt('ACCOUNTING_EXPORT_FEC_ADD_TIMESTAMP')) { + $completefilename .= "_".dol_print_date(dol_now(), '%Y%m%d%H%M%S'); + } + + $completefilename .= ".txt"; } elseif ($accountancyexport->getFormatCode($formatexportset) == 'ciel' && $type_export == "general_ledger" && getDolGlobalString('ACCOUNTING_EXPORT_XIMPORT_FORCE_FILENAME')) { $completefilename = "XIMPORT.TXT"; } else { From 0ae4a4b2c788ff0e32db237010a9cf56291dc283 Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Mon, 27 Jul 2026 11:38:37 +0200 Subject: [PATCH 304/307] The test on dolibarr_main_restrict_os_commands was moved into dumpDatabase() --- htdocs/admin/tools/export.php | 13 ++--------- htdocs/core/class/utils.class.php | 37 ++++++++++++++++++++++++++++++- htdocs/langs/en_US/errors.lang | 1 + 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/htdocs/admin/tools/export.php b/htdocs/admin/tools/export.php index 773126498d4..4450ac53ff3 100644 --- a/htdocs/admin/tools/export.php +++ b/htdocs/admin/tools/export.php @@ -137,21 +137,12 @@ if ($what == 'mysql') { $reg = array(); if (preg_match('/mysqldump(\.exe)?$/', $cmddump, $reg)) { // And if command ends with mysqldump $cmddump = 'mysqldump'.(empty($reg[1]) ? '' : $reg[1]); // Then we try the command with no forced path + $langs->load("errors"); + setEventMessage($langs->trans("WarningFullPathWasNotFoundSoWeUseAnAlternativeValue", $cmddump), 'warnings'); } } } - if (!empty($dolibarr_main_restrict_os_commands)) { - $arrayofallowedcommand = explode(',', $dolibarr_main_restrict_os_commands); - $arrayofallowedcommand = array_map('trim', $arrayofallowedcommand); - dol_syslog("Command are restricted to ".$dolibarr_main_restrict_os_commands.". We check that one of this command is inside ".$cmddump); - if (!in_array($basenamecmddump, $arrayofallowedcommand)) { // the provided command $cmddump must be an allowed command - $langs->load("errors"); - $errormsg = $langs->trans('CommandIsNotInsideAllowedCommands'); - $errormsg .= '
'.$langs->trans('ErrorCheckTheCommandInsideTheAdvancedOptions'); - } - } - if (!$errormsg && $cmddump) { dolibarr_set_const($db, 'SYSTEMTOOLS_MYSQLDUMP', $cmddump, 'chaine', 0, '', 0); } diff --git a/htdocs/core/class/utils.class.php b/htdocs/core/class/utils.class.php index d9fafe911ed..ca041a98ad9 100644 --- a/htdocs/core/class/utils.class.php +++ b/htdocs/core/class/utils.class.php @@ -246,9 +246,10 @@ class Utils */ public function dumpDatabase($compression = 'none', $type = 'auto', $usedefault = 1, $file = 'auto', $keeplastnfiles = 0, $execmethod = 0, $lowmemorydump = 0) { - global $db, $conf, $langs, $dolibarr_main_data_root; + global $db, $conf, $langs; global $dolibarr_main_db_name, $dolibarr_main_db_host, $dolibarr_main_db_user, $dolibarr_main_db_port, $dolibarr_main_db_pass; global $dolibarr_main_db_character_set; + global $dolibarr_main_restrict_os_commands; $langs->load("admin"); @@ -318,6 +319,23 @@ class Utils $outputerror = $outputfile.'.err'; dol_mkdir($conf->admin->dir_output.'/backup'); + $cmddump = dol_sanitizePathName($cmddump); // Sanitize path + $cmddump = dol_string_nospecial($cmddump, '', array("|", ";", "<", ">", "&", "+")); // Sanitize command + $basenamecmddump = basename(str_replace('\\', '/', $cmddump)); + + // Sanitize and validate $cmddump + if (!empty($dolibarr_main_restrict_os_commands)) { + $arrayofallowedcommand = explode(',', $dolibarr_main_restrict_os_commands); + $arrayofallowedcommand = array_map('trim', $arrayofallowedcommand); + dol_syslog("Command are restricted to ".$dolibarr_main_restrict_os_commands.". We check that one of this command is inside ".$cmddump); + if (!in_array($basenamecmddump, $arrayofallowedcommand)) { // the provided command $cmddump must be an allowed command + $langs->load("errors"); + $this->error = $langs->trans('CommandIsNotInsideAllowedCommands'); + $this->error .= '
'.$langs->trans('ErrorCheckTheCommandInsideTheAdvancedOptions'); + return -1; + } + } + // Parameters execution $command = $cmddump; $command = preg_replace('/(\$|%)/', '', $command); // We removed chars that can be used to inject vars that contains space inside path of command without seeing there is a space to bypass the escapeshellarg. @@ -631,6 +649,23 @@ class Utils $outputerror = $outputfile.'.err'; dol_mkdir($conf->admin->dir_output.'/backup'); + // Sanitize and validate $cmddump + $cmddump = dol_sanitizePathName($cmddump); // Sanitize path + $cmddump = dol_string_nospecial($cmddump, '', array("|", ";", "<", ">", "&", "+")); // Sanitize command + $basenamecmddump = basename(str_replace('\\', '/', $cmddump)); + + if (!empty($dolibarr_main_restrict_os_commands)) { + $arrayofallowedcommand = explode(',', $dolibarr_main_restrict_os_commands); + $arrayofallowedcommand = array_map('trim', $arrayofallowedcommand); + dol_syslog("Command are restricted to ".$dolibarr_main_restrict_os_commands.". We check that one of this command is inside ".$cmddump); + if (!in_array($basenamecmddump, $arrayofallowedcommand)) { // the provided command $cmddump must be an allowed command + $langs->load("errors"); + $this->error = $langs->trans('CommandIsNotInsideAllowedCommands'); + $this->error .= '
'.$langs->trans('ErrorCheckTheCommandInsideTheAdvancedOptions'); + return -1; + } + } + // Parameters execution $command = $cmddump; $command = preg_replace('/(\$|%)/', '', $command); // We removed chars that can be used to inject vars that contains space inside path of command without seeing there is a space to bypass the escapeshellarg. diff --git a/htdocs/langs/en_US/errors.lang b/htdocs/langs/en_US/errors.lang index bf383db72d9..fc3e537f1b4 100644 --- a/htdocs/langs/en_US/errors.lang +++ b/htdocs/langs/en_US/errors.lang @@ -443,6 +443,7 @@ WarningModuleAffiliatedToAReportedCompany=Warning, this module has been reported WarningModuleAffiliatedToAPiratPlatform=Be careful when getting a module (paid or free) from a suspicious non official platform like %s WarningDNSServerNotAvailable=Warning, DNS server not available WarningSecureKeyNotSet=The value fo the securekey must not be empty +WarningFullPathWasNotFoundSoWeUseAnAlternativeValue=Note: Full path was not found so we used the alternative value %s SwissQrOnlyVIR = SwissQR invoice can only be added on invoices set to be paid with credit transfer payments. SwissQrCreditorAddressInvalid = Creditor address is invalid (are ZIP and city set? (%s) From 143d7ca9c8e90de4434546d3a21ed9d1c4195dca Mon Sep 17 00:00:00 2001 From: Zakaria Boushaba <48571684+Boushabazakaria@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:02:01 +0100 Subject: [PATCH 305/307] Fix missing extrafields propagation and object links when creating supplier credit note (#39285) Co-authored-by: Zakaria Boushaba --- htdocs/fourn/facture/card.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/htdocs/fourn/facture/card.php b/htdocs/fourn/facture/card.php index a6ac333555d..13a8789b770 100644 --- a/htdocs/fourn/facture/card.php +++ b/htdocs/fourn/facture/card.php @@ -972,6 +972,11 @@ if (empty($reshook)) { $fk_parent_line = 0; foreach ($facture_source->lines as $line) { + // Extrafields + if (method_exists($line, 'fetch_optionals')) { + $line->fetch_optionals(); + } + // Reset fk_parent_line for no child products and special product if (($line->product_type != 9 && empty($line->fk_parent_line)) || $line->product_type == 9) { $fk_parent_line = 0; @@ -1020,6 +1025,19 @@ if (empty($reshook)) { } } } + + // Add link between credit note and origin objects + if (!empty($object->fk_facture_source) && $id > 0) { + $facture_source_link = new FactureFournisseur($db); + if ($facture_source_link->fetch($object->fk_facture_source) > 0) { + $facture_source_link->fetchObjectLinked(); + if (!empty($facture_source_link->linkedObjectsIds)) { + foreach ($facture_source_link->linkedObjectsIds as $sourcetype => $TIds) { + $object->add_object_linked($sourcetype, current($TIds)); + } + } + } + } } } elseif ($fac_recid > 0 && (GETPOSTINT('type') == FactureFournisseur::TYPE_STANDARD || GETPOSTINT('type') == FactureFournisseur::TYPE_DEPOSIT)) { // Standard invoice or Deposit invoice, created from a Predefined template invoice @@ -2687,6 +2705,9 @@ if ($action == 'create') { $optionsav .= '
'; + $out .= img_picto($langs->trans("ShowDiffWithOriginal"), 'split', 'class="paddingleft"'); + $out .= ''; + $out .= ''."\n"; $out .= ''.dol_escape_htmltag(dol_trunc($file['expectedhash'], 16)).''."\n"; $out .= ''.dol_escape_htmltag(dol_trunc($file['hash'], 16)).''."\n"; $out .= ''; @@ -516,6 +520,28 @@ if (empty($error) && !empty($xml)) { $out .= ''; $out .= '
'; + // Ajax loader for the per-file diff against the original version + $out .= ''."\n"; + $out .= '
'; // Files added diff --git a/htdocs/blockedlog/admin/filecheck_diff.php b/htdocs/blockedlog/admin/filecheck_diff.php new file mode 100644 index 00000000000..e2ec50419a2 --- /dev/null +++ b/htdocs/blockedlog/admin/filecheck_diff.php @@ -0,0 +1,397 @@ + + * + * 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 . + */ + +/** + * \file htdocs/blockedlog/admin/filecheck_diff.php + * \brief Ajax fragment to show the diff of a modified file against its original version + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +require '../../main.inc.php'; +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/geturl.lib.php'; + +$langs->loadLangs(array("admin", "errors")); + +if (!$user->admin && !$user->hasRight('bockedlog', 'read')) { + accessforbidden(); +} + +$file = GETPOST('file', 'alphanohtml'); +$algo = GETPOST('algo', 'aZ09'); +$expectedhash = GETPOST('expectedhash', 'aZ09'); + +if (!in_array($algo, array('md5', 'sha256'), true)) { + $algo = 'sha256'; +} + + +/** + * Compute a line based diff between two arrays of lines, restricted to the changed + * region (common prefix and suffix are trimmed first to keep it cheap and memory safe). + * + * @param string[] $a Lines of the original file + * @param string[] $b Lines of the local file + * @return array List of [type, line] where type is ' ' (context), '-' (removed) or '+' (added) + */ +function filecheckLineDiff($a, $b) +{ + $na = count($a); + $nb = count($b); + + // Trim common prefix + $start = 0; + while ($start < $na && $start < $nb && $a[$start] === $b[$start]) { + $start++; + } + // Trim common suffix + $enda = $na - 1; + $endb = $nb - 1; + while ($enda >= $start && $endb >= $start && $a[$enda] === $b[$endb]) { + $enda--; + $endb--; + } + + $result = array(); + for ($i = 0; $i < $start; $i++) { + $result[] = array(' ', $a[$i]); + } + + $midA = array_slice($a, $start, $enda - $start + 1); + $midB = array_slice($b, $start, $endb - $start + 1); + + foreach (filecheckMiddleDiff($midA, $midB) as $d) { + $result[] = $d; + } + + for ($i = $enda + 1; $i < $na; $i++) { + $result[] = array(' ', $a[$i]); + } + + return $result; +} + +/** + * Diff of the changed region. For large regions, the region is recursively split on + * unique common lines (anchors, patience-diff style) so the expensive LCS only runs + * on small segments. This keeps small changes detected instead of one big block. + * + * @param string[] $a Changed lines of the original file + * @param string[] $b Changed lines of the local file + * @return array List of [type, line] + */ +function filecheckMiddleDiff($a, $b) +{ + $a = array_values($a); + $b = array_values($b); + $na = count($a); + $nb = count($b); + + if ($na == 0 || $nb == 0) { + $result = array(); + foreach ($a as $line) { + $result[] = array('-', $line); + } + foreach ($b as $line) { + $result[] = array('+', $line); + } + return $result; + } + + // Small enough region: a direct LCS gives the optimal line-level diff. + if ($na * $nb <= 1000000) { + return filecheckLcs($a, $b); + } + + // Large region: split on a unique common line (anchor) and recurse on both sides. + $anchor = filecheckFindAnchor($a, $b); + if ($anchor !== null) { + return array_merge( + filecheckMiddleDiff(array_slice($a, 0, $anchor[0]), array_slice($b, 0, $anchor[1])), + array(array(' ', $a[$anchor[0]])), + filecheckMiddleDiff(array_slice($a, $anchor[0] + 1), array_slice($b, $anchor[1] + 1)) + ); + } + + // No anchor available in a very large region: fall back to a plain block replacement. + $result = array(); + foreach ($a as $line) { + $result[] = array('-', $line); + } + foreach ($b as $line) { + $result[] = array('+', $line); + } + return $result; +} + +/** + * Find a line that appears exactly once in both arrays (a unique common "anchor"), + * choosing the candidate closest to the middle of $a to balance the recursion. + * + * @param string[] $a Lines + * @param string[] $b Lines + * @return ?array{0:int,1:int} [index in $a, index in $b] of the anchor, or null if none found + */ +function filecheckFindAnchor($a, $b) +{ + $countA = array(); + foreach ($a as $line) { + $countA[$line] = (isset($countA[$line]) ? $countA[$line] : 0) + 1; + } + $countB = array(); + $posB = array(); + foreach ($b as $j => $line) { + $countB[$line] = (isset($countB[$line]) ? $countB[$line] : 0) + 1; + $posB[$line] = $j; + } + + $middle = (int) (count($a) / 2); + $best = null; + $bestdist = -1; + foreach ($a as $i => $line) { + if ($countA[$line] == 1 && isset($countB[$line]) && $countB[$line] == 1) { + $dist = abs($i - $middle); + if ($bestdist < 0 || $dist < $bestdist) { + $bestdist = $dist; + $best = array($i, $posB[$line]); + } + } + } + + return $best; +} + +/** + * Diff of two small arrays of lines using a classic LCS dynamic programming matrix. + * + * @param string[] $a Lines of the original file + * @param string[] $b Lines of the local file + * @return array List of [type, line] + */ +function filecheckLcs($a, $b) +{ + $na = count($a); + $nb = count($b); + + // LCS length matrix + $lcs = array(); + for ($i = 0; $i <= $na; $i++) { + $lcs[$i] = array_fill(0, $nb + 1, 0); + } + for ($i = $na - 1; $i >= 0; $i--) { + for ($j = $nb - 1; $j >= 0; $j--) { + if ($a[$i] === $b[$j]) { + $lcs[$i][$j] = $lcs[$i + 1][$j + 1] + 1; + } else { + $lcs[$i][$j] = max($lcs[$i + 1][$j], $lcs[$i][$j + 1]); + } + } + } + + // Backtrack to build the diff + $result = array(); + $i = 0; + $j = 0; + while ($i < $na && $j < $nb) { + if ($a[$i] === $b[$j]) { + $result[] = array(' ', $a[$i]); + $i++; + $j++; + } elseif ($lcs[$i + 1][$j] >= $lcs[$i][$j + 1]) { + $result[] = array('-', $a[$i]); + $i++; + } else { + $result[] = array('+', $b[$j]); + $j++; + } + } + while ($i < $na) { + $result[] = array('-', $a[$i]); + $i++; + } + while ($j < $nb) { + $result[] = array('+', $b[$j]); + $j++; + } + + return $result; +} + +/** + * Collapse long runs of unchanged context lines, keeping a few lines around each change. + * + * @param array $diff Full diff as returned by filecheckLineDiff() + * @param int $context Number of context lines to keep around changes + * @return array Diff with collapsed context (type '@' marks a collapsed gap) + */ +function filecheckCollapseContext($diff, $context = 3) +{ + $n = count($diff); + $keep = array_fill(0, $n, false); + for ($i = 0; $i < $n; $i++) { + if ($diff[$i][0] !== ' ') { + $from = max(0, $i - $context); + $to = min($n - 1, $i + $context); + for ($k = $from; $k <= $to; $k++) { + $keep[$k] = true; + } + } + } + + $result = array(); + $ingap = false; + for ($i = 0; $i < $n; $i++) { + if ($keep[$i]) { + $result[] = $diff[$i]; + $ingap = false; + } elseif (!$ingap) { + $result[] = array('@', ''); + $ingap = true; + } + } + + return $result; +} + + +top_httphead('text/html'); + +print ''."\n"; + +// Validate the requested file: must be a relative path inside DOL_DOCUMENT_ROOT, with no traversal. +$errormsg = ''; +$reallocal = ''; +if (empty($file) || $file[0] !== '/' || strpos($file, '..') !== false || !preg_match('/^[A-Za-z0-9_\/.\-]+$/', $file)) { + $errormsg = $langs->trans("ErrorBadValueForParameter", dol_escape_htmltag($file), "file"); +} + +if (empty($errormsg)) { + $reallocal = realpath(DOL_DOCUMENT_ROOT.$file); + if ($reallocal === false || strpos($reallocal, realpath(DOL_DOCUMENT_ROOT).'/') !== 0 || !is_file($reallocal)) { + $errormsg = $langs->trans("ErrorFileNotFound", $file); + } +} +// Only text files can be diffed +if (empty($errormsg) && preg_match('/\.(jpg|jpeg|png|gif|ico|svg|eot|woff|woff2|ttf|mp3|mp4|wav|mkv|z|gz|zip|rar|tar)$/i', $file)) { + $errormsg = $langs->trans("DiffNotAvailableForBinaryFiles"); +} + +if (!empty($errormsg)) { + print '
'.$errormsg.'
'; + llxFooterFragment(); +} + +// Build the URL of the original file for the running version. +// For a stable version (eg 24.0.0) the matching git tag exists, so we compare against that tag. +// For an alpha/beta/rc version no tag exists yet, so we compare against the develop branch. +$baseurl = getDolGlobalString('MAIN_FILECHECK_DIFF_BASEURL', 'https://raw.githubusercontent.com/Dolibarr/dolibarr'); +if (preg_match('/alpha|beta|rc/i', DOL_VERSION)) { + $ref = 'develop'; +} else { + $ref = DOL_VERSION; +} +$ref = getDolGlobalString('MAIN_FILECHECK_DIFF_REF', $ref); +$originurl = $baseurl.'/'.$ref.'/htdocs'.$file; + +$res = getURLContent($originurl, 'GET', '', 1, array(), array('http', 'https'), 0); // Accept http or https links on external remote server only. +if (!empty($res['curl_error_no']) || (isset($res['http_code']) && !in_array((int) $res['http_code'], array(0, 200), true))) { + print '
'.$langs->trans("CouldNotFetchOriginalFile").': '.dol_escape_htmltag($originurl); + print ' ('.dol_escape_htmltag((string) (empty($res['http_code']) ? $res['curl_error_msg'] : $res['http_code'])).')
'; + llxFooterFragment(); +} + +$origincontent = (string) $res['content']; +$localcontent = (string) file_get_contents($reallocal); + +// Confirm the fetched original is the genuine reference file expected by the signature. +$verified = true; +if (!empty($expectedhash)) { + $verified = (hash($algo, $origincontent) === $expectedhash); +} +if (!$verified) { + print '
'.$langs->trans("OriginalFileChecksumMismatch").'
'; +} + +if ($origincontent === $localcontent) { + print '
'.$langs->trans("NoDifferenceFound").'
'; + llxFooterFragment(); +} + +$diff = filecheckLineDiff(explode("\n", $origincontent), explode("\n", $localcontent)); +$diff = filecheckCollapseContext($diff, 3); + +print '
'; +print img_picto('', 'split', 'class="pictofixedwidth"').dol_escape_htmltag($file).' — '.dol_escape_htmltag($originurl); +print '
'; + +print ''; +foreach ($diff as $line) { + $type = $line[0]; + if ($type === '@') { + print ''."\n"; + continue; + } + $bg = ''; + $sign = ' '; + if ($type === '+') { + $bg = 'background:#e6ffed'; + $sign = '+'; + } elseif ($type === '-') { + $bg = 'background:#ffeef0'; + $sign = '-'; + } + print ''."\n"; +} +print '
…
'; + print dol_escape_htmltag($sign.' '.$line[1]); + print '
'; + +llxFooterFragment(); + + +/** + * Close the fragment output and stop the script. + * + * @return void + */ +function llxFooterFragment() +{ + global $db; + if (is_object($db)) { + $db->close(); + } + exit; +} diff --git a/htdocs/langs/en_US/admin.lang b/htdocs/langs/en_US/admin.lang index 9417ad2b253..ffd74da4ead 100644 --- a/htdocs/langs/en_US/admin.lang +++ b/htdocs/langs/en_US/admin.lang @@ -27,6 +27,12 @@ FilesMissing=Missing files FilesUpdated=Updated files FilesModified=Modified files FilesAdded=Added files +ShowDiffWithOriginal=Show changes compared to the original file +DiffNotAvailableForBinaryFiles=Comparison is not available for binary files +CouldNotFetchOriginalFile=Could not retrieve the original file +OriginalFileChecksumMismatch=Warning: the retrieved original file does not match the expected checksum, the comparison below may not be reliable +NoDifferenceFound=No difference found +Loading=Loading FileCheckDolibarr=Files integrity AvailableOnlyOnPackagedVersions=Local integrity file is only available for packaged installations. Use the remote signature if unavailable. XmlNotFound=Application XML integrity file not found From 2cb9ac68ed870ae9bee5096c88ca2423fd53591f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Mon, 27 Jul 2026 14:04:28 +0200 Subject: [PATCH 307/307] Update version.inc.php --- htdocs/version.inc.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/version.inc.php b/htdocs/version.inc.php index 21c702acc50..fa542bbdab9 100644 --- a/htdocs/version.inc.php +++ b/htdocs/version.inc.php @@ -37,8 +37,8 @@ if (!defined('DOL_APPLICATION_TITLE')) { } // The major version of Dolibarr -define('DOL_MAJOR_VERSION', '24'); -define('DOL_MINOR_VERSION', '0.0-beta'); +define('DOL_MAJOR_VERSION', '25'); +define('DOL_MINOR_VERSION', '0.0-alpha'); define('DOL_VERSION', constant('DOL_MAJOR_VERSION').'.'.constant('DOL_MINOR_VERSION'));