From dbae20fb5e29d67aeecce972531889872d01fcdd Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Mon, 18 May 2026 01:14:03 +0200 Subject: [PATCH 01/10] Fix: ToolCrudObjects header inputSchema JSON Schema compliance (#38299) create_customer_invoice and create_other_document declare a 'header' object whose schema has 'required: [socid]' but no 'properties' entry. This is technically allowed by JSON Schema but stricter validators flag it as suspicious: the 'required' array references a property that is not declared in 'properties'. Beyond compliance, the omission means LLMs (and human reviewers) have no machine-readable description of which fields are actually accepted in the 'header' object. The Dolibarr backend already accepts socid, date, duree_validite, note_public, note_private and others -- this patch documents the most useful ones explicitly so consumer tools can produce richer payloads. Tested with: create_other_document object_type=proposal accepting a 'header' that contains all of socid + date + duree_validite + note_public -> all four fields are correctly mapped to the Propal object (Dolibarr already accepts unknown properties via dynamic property assignment, so this is purely a schema-documentation change). --- htdocs/ai/tools/crud_objects.class.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/htdocs/ai/tools/crud_objects.class.php b/htdocs/ai/tools/crud_objects.class.php index 7c57d92a11f..c8634bd3735 100644 --- a/htdocs/ai/tools/crud_objects.class.php +++ b/htdocs/ai/tools/crud_objects.class.php @@ -196,6 +196,12 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t "header" => [ "type" => "object", "description" => "Invoice header data. Must include 'socid' (Customer ID).", + "properties" => [ + "socid" => ["type" => "integer", "description" => "Customer ID (Thirdparty ID)"], + "date" => ["type" => "string", "description" => "Invoice date (YYYY-MM-DD)"], + "note_public" => ["type" => "string", "description" => "Public note"], + "note_private" => ["type" => "string", "description" => "Private note"] + ], "required" => ["socid"] ], "lines" => [ @@ -233,6 +239,13 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t "header" => [ "type" => "object", "description" => "Header data. Must include 'socid'.", + "properties" => [ + "socid" => ["type" => "integer", "description" => "Thirdparty ID (Customer for proposal, Supplier for supplier_*)"], + "date" => ["type" => "string", "description" => "Document date (YYYY-MM-DD)"], + "duree_validite" => ["type" => "integer", "description" => "Validity in days (proposal only)"], + "note_public" => ["type" => "string", "description" => "Public note"], + "note_private" => ["type" => "string", "description" => "Private note"] + ], "required" => ["socid"] ], "lines" => [ From 6ee5130f24954ae4e0db567f4cd922117abd717b Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Mon, 18 May 2026 01:15:23 +0200 Subject: [PATCH 02/10] Fix: parse_intent.php model resolution (array default and wrong constant) (#38300) Two bugs combined to make UniversalLLMAdapter::__construct() crash with: TypeError: Argument #4 ($model) must be of type string, array given at parse_intent.php line 301 when the user-configured model wasn't properly read. (1) Wrong shape assumption -- getListOfAIServices() declares model defaults as a NESTED array of the form: $servicesList[$key]['textgeneration'] = ['default' => 'model-name'] The previous code read $servicesList[$key]['textgeneration'] (without ['default']) and got the inner array back. That array was then passed as the $defModel fallback to a (string)-typed constructor argument. (2) Wrong constant -- the admin UI ("Prompt and custom AI models" tab in setup.php) stores the per-function model under AI_API__MODEL_TEXT, matching the convention already used by Ai::generateContent() for the same data. The previous code read AI_API__MODEL (no _TEXT suffix), which is never written by that form. So the user-configured model was silently ignored and we fell back to the (array) default from bug #1. Together these two bugs reliably reproduced the TypeError on any default install of the AI module where the user had set a custom text model via the admin UI. This patch: - Walks $servicesList[$key]['textgeneration'] correctly (extracting ['default'] when it's an array, accepting strings for backward compat). - Tries AI_API__MODEL_TEXT first (the constant the admin UI writes), and falls back to the legacy AI_API__MODEL for compatibility with anyone who might have set it manually. - Defensively coerces the final value to a string and falls back to the default if for any reason it's still not a string. Tested with google (gemini-2.5-flash) and chatgpt configured via the admin UI -> UniversalLLMAdapter receives a string and the AI request goes through. --- htdocs/ai/assistant/parse_intent.php | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/htdocs/ai/assistant/parse_intent.php b/htdocs/ai/assistant/parse_intent.php index 1cfc8c9b762..cfb04707ad7 100644 --- a/htdocs/ai/assistant/parse_intent.php +++ b/htdocs/ai/assistant/parse_intent.php @@ -302,8 +302,34 @@ try { $defUrl = $servicesList[$serviceKey]['url'] ?? ''; $url = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_URL') ?: $defUrl; - $defModel = $servicesList[$serviceKey]['textgeneration'] ?? 'gpt-4o-mini'; - $model = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_MODEL') ?: $defModel; + // The model defaults declared in getListOfAIServices() are nested: + // $servicesList[$key]['textgeneration'] = ['default' => 'model-name'] + // Reading 'textgeneration' without ['default'] returns the inner array, which + // then fails the (string) type-hint of UniversalLLMAdapter's 4th argument with: + // "Argument #4 ($model) must be of type string, array given" + // + // The admin UI (htdocs/ai/admin/setup.php "Prompt and custom AI models" tab) also + // stores the per-function model under AI_API__MODEL_TEXT (matching the + // convention already used by Ai::generateContent() for the same data). The + // previous lookup used AI_API__MODEL which is never written by that + // form, so the user-configured model was silently ignored. + $rawDefault = $servicesList[$serviceKey]['textgeneration'] ?? null; + if (is_array($rawDefault)) { + $defModel = $rawDefault['default'] ?? 'gpt-4o-mini'; + } else { + $defModel = $rawDefault ?: 'gpt-4o-mini'; + } + $prefix = 'AI_API_' . strtoupper($serviceKey); + $model = getDolGlobalString($prefix . '_MODEL_TEXT') + ?: getDolGlobalString($prefix . '_MODEL') + ?: $defModel; + // Defensive: coerce to string if anyone stored an array in this constant + if (is_array($model)) { + $model = $model['default'] ?? $defModel; + } + if (!is_string($model) || $model === '') { + $model = (string) $defModel; + } $adapterType = $servicesList[$serviceKey]['adapter_type'] ?? 'openai'; if (!empty($apiKey)) { From 0e591afe34ee27eac62ebdb2e45eea6917237222 Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Mon, 18 May 2026 01:16:01 +0200 Subject: [PATCH 03/10] Fix: allow free-text invoice/order/proposal lines when no product matches (#38298) ToolCrudObjects::processAddLine() uses the line's 'description' as a fallback product identifier when 'product' is not explicitly provided. It then calls findProduct() to look up an existing Dolibarr product by ref/barcode/label. When no product matches (the common case for AI-generated lines that describe a one-off service or a custom item), findProduct() returns an error array and the function aborts WITHOUT ever calling addline(). The resulting document gets its header created but ends up with zero lines and a 0.00 total. This is contrary to standard Dolibarr behavior: free-text line items (no product link, just a description and a price) are a first-class feature of invoices, orders, and proposals. This patch distinguishes between the two cases: - Caller explicitly passed 'product': they wanted a specific product -> still abort with the original error if not found (unchanged). - Caller only passed 'description': it's a free-text line -> silently fall through with $prod = null. Dolibarr then creates a free-text line with the user-provided description, qty, unit_price and vat_rate. Tested with: create_other_document object_type=proposal containing 11 line items, none of which match existing product references -> all 11 lines are now created with their correct totals (previously the document was created with 0 lines and a 0.00 total). --- htdocs/ai/tools/crud_objects.class.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/htdocs/ai/tools/crud_objects.class.php b/htdocs/ai/tools/crud_objects.class.php index c8634bd3735..9b5f25e1fda 100644 --- a/htdocs/ai/tools/crud_objects.class.php +++ b/htdocs/ai/tools/crud_objects.class.php @@ -552,8 +552,17 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t if ($productIdentifier !== '') { $findResult = $this->findProduct($productIdentifier); if (is_array($findResult) && isset($findResult['error'])) { - // Ensure the return array matches the expected shape by adding 'success' => false - return array_merge(['success' => false], $findResult); + // Only abort if the caller EXPLICITLY asked for a product (via the 'product' + // argument). If they only provided a free-text 'description', we silently + // fall through with $prod = null so Dolibarr creates a free-text line item, + // which is a perfectly valid Dolibarr feature. + // Previous behaviour aborted ALL line creations whose description didn't + // match an existing product reference, which broke AI-driven creation of + // invoices/orders/proposals from one-off line descriptions. + if (isset($args['product'])) { + return array_merge(['success' => false], $findResult); + } + // fall through: $prod stays null } if (is_object($findResult)) { $prod = $findResult; From cdaff511478bb5eb28fa9feb03e6b502b9fb3e19 Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Mon, 18 May 2026 01:16:32 +0200 Subject: [PATCH 04/10] Fix: FactureFournisseur::addline parameter order in ToolCrudObjects (#38295) Unlike Facture::addline() which takes ($desc, $pu, $qty, $txtva, ...), FactureFournisseur::addline() uses a different parameter order: ($desc, $pu, $txtva, $txlocaltax1, $txlocaltax2, $qty, $fk_product, $remise_percent, ...) i.e. $qty is in position 6, not 3. ToolCrudObjects::processAddLine() was calling FactureFournisseur::addline() with the Facture order, which produced: - our $qty (1) passed as $txtva -> effective VAT rate 1% - our $vat (20) passed as $txlocaltax1 -> wrong local tax - a hardcoded 0 passed as $qty -> the line was inserted with qty=0 and silently dropped from the visible totals The result: every supplier invoice created via create_other_document ended up with the header correctly created but a 0.00 EUR total, with no error returned (lines_added was reported as success). This patch reorders the parameters to match the actual FactureFournisseur::addline() signature and adds an inline comment to warn future maintainers about this Dolibarr API quirk. Other addline calls in the same switch (invoice/order/proposal, supplier_order/supplier_proposal) use their respective correct signatures already and are unaffected. Tested with: create_other_document object_type=supplier_invoice with one line (qty=1, unit_price=10, vat_rate=20) -> line now appears with Total HT 10.00 EUR. --- htdocs/ai/tools/crud_objects.class.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/htdocs/ai/tools/crud_objects.class.php b/htdocs/ai/tools/crud_objects.class.php index 9b5f25e1fda..61933736148 100644 --- a/htdocs/ai/tools/crud_objects.class.php +++ b/htdocs/ai/tools/crud_objects.class.php @@ -621,7 +621,14 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 'HT', 0, 0, $prodType); } elseif ($docType === 'supplier_invoice') { /** @var FactureFournisseur $object */ - $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 0, 0, '', 0, 'HT', 0, $prodType); + // IMPORTANT: FactureFournisseur::addline() does NOT share the same signature + // as Facture::addline(). Its parameter order is: + // ($desc, $pu, $txtva, $txlocaltax1, $txlocaltax2, $qty, $fk_product, $remise_percent, ...) + // i.e. $qty is in position 6, not 3 (unlike customer Facture / Commande / Propal). + // The previous call passed our $qty as $txtva (-> a 1% VAT rate) and our $vat + // as $txlocaltax1, and position 6 ended up being a hardcoded 0 -> a line was + // inserted with qty=0, which Dolibarr silently dropped from the visible totals. + $res = $object->addline($desc, $price, $vat, 0, 0, $qty, $fkProduct, $discount, '', '', 0, 0, 'HT', $prodType); } elseif ($docType === 'supplier_order') { /** @var CommandeFournisseur $object */ $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 0, 0, 'HT', 0, '', '', $prodType); From 5d851934d565afe9d9c65d1cff9b41577b935248 Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Mon, 18 May 2026 01:17:39 +0200 Subject: [PATCH 05/10] Fix: Ai::generateContent uses Google Gemini native API for google provider (#38296) The Ai class currently builds every outbound request in OpenAI's chat/completions shape, regardless of the configured provider. This works for OpenAI-compatible backends (chatgpt, mistral, groq, custom) but breaks the 'google' provider, whose native API differs on all four key dimensions: - URL path: /v1beta/models/:generateContent (not /v1beta/chat/completions) - Payload root: 'contents' / 'parts' / optional 'system_instruction' (not 'messages') - Auth header: x-goog-api-key (not Authorization: Bearer) - Response shape: candidates[0].content.parts[*].text (not choices[0].message.content) As a result, every call to Gemini via Ai::generateContent() ended with HTTP 404 (wrong path) or unparsed response data. This patch makes Ai::generateContent() emit the native Gemini shape when $this->apiService === 'google': 1. URL: after $model is resolved, suffix /models/:generateContent (and skip the /chat/completions suffix entirely for google) 2. Payload: { "contents": [ {"role": "user", "parts": [{"text": }]} ], "system_instruction": {"parts": [{"text": }]} } 3. Header: x-goog-api-key: (no Bearer) 4. Response parsing: concatenate candidates[0].content.parts[*].text All other providers go through the existing OpenAI-compat branches unchanged. No behavioral change for chatgpt, mistral, groq, anthropic-compat, or custom endpoints. Tested with: gemini-2.5-flash end-to-end via the AI Assistant web UI (textgeneration). HTTP 200 with valid JSON output. --- htdocs/ai/class/ai.class.php | 69 ++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/htdocs/ai/class/ai.class.php b/htdocs/ai/class/ai.class.php index 3bd792c5e5e..a5396347ec8 100644 --- a/htdocs/ai/class/ai.class.php +++ b/htdocs/ai/class/ai.class.php @@ -140,7 +140,14 @@ class Ai $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'threads'; } else { // if $function == 'docparsing', 'text...', ... $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']); - $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'chat/completions'; + if ($this->apiService == 'google') { + // Google Gemini native API: the /models/:generateContent suffix is + // appended later (once $model has been resolved). The OpenAI-style + // /chat/completions does not exist on the native Gemini endpoint. + $this->apiEndpoint = rtrim($this->apiEndpoint, '/'); + } else { + $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'chat/completions'; + } } } if ($moreendpoint) { @@ -170,6 +177,12 @@ class Ai } } + // Google Gemini: append /models/:generateContent now that $model is resolved. + if ($this->apiService == 'google' && !in_array($function, array('file', 'assistant', 'thread')) + && strpos($this->apiEndpoint, ':generateContent') === false) { + $this->apiEndpoint .= '/models/'.rawurlencode($model).':generateContent'; + } + dol_syslog("Call API for apiKey=".substr($this->apiKey, 0, 5).'***********, apiEndpoint='.$this->apiEndpoint.", model=".$model.", format=".$format); if (getDolGlobalString("AI_DEBUG")) { if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir @@ -269,18 +282,33 @@ class Ai "top_p": 0.95 }*/ - $arrayforpayload = array( - 'messages' => array(array('role' => 'user', 'content' => $fullInstructions)), - 'model' => $model, - ); - // Add a system message $addDateTimeContext = false; if ($addDateTimeContext) { // @phpstan-ignore-line $prePrompt = ($prePrompt ? $prePrompt.(preg_match('/[\.\!\?]$/', $prePrompt) ? '' : '.').' ' : '').'Today we are '.dol_print_date(dol_now(), 'dayhourtext'); } - if ($prePrompt) { - $arrayforpayload['messages'][] = array('role' => 'system', 'content' => $prePrompt); + + if ($this->apiService == 'google') { + // Google Gemini native payload format (different from OpenAI's "messages"). + $arrayforpayload = array( + 'contents' => array( + array('role' => 'user', 'parts' => array(array('text' => $fullInstructions))) + ) + ); + if ($prePrompt) { + $arrayforpayload['system_instruction'] = array( + 'parts' => array(array('text' => $prePrompt)) + ); + } + } else { + // OpenAI-compatible payload format (chatgpt, mistral, groq, anthropic-compat, custom, ...) + $arrayforpayload = array( + 'messages' => array(array('role' => 'user', 'content' => $fullInstructions)), + 'model' => $model, + ); + if ($prePrompt) { + $arrayforpayload['messages'][] = array('role' => 'system', 'content' => $prePrompt); + } } } @@ -296,9 +324,16 @@ class Ai $payload = json_encode($arrayforpayload); } - $headers = array( - 'Authorization: Bearer ' . $this->apiKey, - ); + if ($this->apiService == 'google') { + // Google Gemini uses the x-goog-api-key header (Bearer is not accepted by the native API). + $headers = array( + 'x-goog-api-key: ' . $this->apiKey, + ); + } else { + $headers = array( + 'Authorization: Bearer ' . $this->apiKey, + ); + } if ($function != 'file') { $headers[] = 'Content-Type: application/json'; } @@ -388,6 +423,18 @@ class Ai } else { $generatedContent = var_export($decodedResponse['error'], true); } + } elseif ($this->apiService == 'google') { + // Google Gemini response shape: candidates[0].content.parts[*].text + // (parts is an array because Gemini can return mixed-modality output; + // we concatenate the textual parts.) + $generatedContent = ''; + if (!empty($decodedResponse['candidates'][0]['content']['parts'])) { + foreach ($decodedResponse['candidates'][0]['content']['parts'] as $part) { + if (isset($part['text'])) { + $generatedContent .= $part['text']; + } + } + } } else { $generatedContent = $decodedResponse['choices'][0]['message']['content']; } From a9fbc88b84ad852a9d600749ee94f544f8aa8cc7 Mon Sep 17 00:00:00 2001 From: Pichinov-Jose Date: Mon, 18 May 2026 01:18:27 +0200 Subject: [PATCH 06/10] Qual: enrich UniversalLLMAdapter::curl error diagnostics (#38304) Rebased on top of upstream commit 95b57550d3 ("Fix curl direct use is forbidden. Must use getUrlContent()") which replaced the raw curl_* calls in this method with getURLContent(). The diagnostics this patch adds are re-implemented on top of the new array shape returned by getURLContent(). When the LLM API returns a non-JSON body, the previous code surfaced only the generic message: "Error: Invalid JSON response from API." with no clue about what really happened. Common real-world causes are: HTTP 4xx/5xx with empty body, HTML error page from a proxy, gateway timeout, model not found, etc. None of these are distinguishable from each other in the admin Log Viewer, so admins had to re-run with curl by hand to diagnose. This patch: - Reads $result['http_code'] and $result['url'] (both populated by getURLContent() via curl_getinfo()) and stores an enriched payload in $this->lastResponse so the admin Log Viewer shows HTTP code + effective URL + body length + body contents. - Returns a descriptive error message when the body is not valid JSON: includes the HTTP code, body length, and a 500-char body snippet (with the '' placeholder for zero-length bodies). - Adds the cURL error number ($result['curl_error_no']) alongside the cURL error message in the network-level error message, with the effective URL for context. - Restores the LLM-specific response timeout that was lost in the getURLContent() refactor: $this->timeout is now passed as the 10th argument ($timeoutresponse) so the configured value is honored instead of falling back to MAIN_USE_RESPONSE_TIMEOUT (default 30s). This also fixes the phpstan warning "Property UniversalLLMAdapter::$timeout is never read, only written." that the upstream refactor introduced. No behavioral change for successful calls. Strictly improves the error-path diagnostics surfaced to administrators, and restores the per-LLM configurable timeout. Tested with: a misconfigured Google Gemini URL that returns HTTP 404 with an empty body now produces: Error: Invalid JSON response from API (HTTP 404, 0 bytes). Body snippet: instead of the previous opaque message. --- htdocs/ai/class/llmadapter.class.php | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/htdocs/ai/class/llmadapter.class.php b/htdocs/ai/class/llmadapter.class.php index 4138e585d7d..b5bb2394f36 100644 --- a/htdocs/ai/class/llmadapter.class.php +++ b/htdocs/ai/class/llmadapter.class.php @@ -202,18 +202,35 @@ class UniversalLLMAdapter global $dolibarr_ai_allow_local_endpoints; $localurl = $dolibarr_ai_allow_local_endpoints ?? 0; - $result = getURLContent($url, 'POST', json_encode($data), 1, $headers, array('http', 'https'), $localurl); + // Pass $this->timeout as the response timeout so the LLM-specific value configured + // at construction time is honored (getURLContent's $timeoutresponse is the 10th arg; + // preceding args $ssl_verifypeer=-1 and $timeoutconnect=0 keep their defaults). + $result = getURLContent($url, 'POST', json_encode($data), 1, $headers, array('http', 'https'), $localurl, -1, 0, $this->timeout); - $this->lastResponse = (string) $result['content']; + $body = (string) ($result['content'] ?? ''); + $httpCode = (int) ($result['http_code'] ?? 0); + $effectiveUrl = (string) ($result['url'] ?? $url); + // Store an enriched payload so the admin Log Viewer ("VIEW LOGS" in the AI Server + // MCP setup page) shows something actionable when something goes wrong, not just + // a bare "Invalid JSON response from API." with an empty body. + $this->lastResponse = "HTTP " . $httpCode . " from " . $effectiveUrl + . "\n--- body (" . strlen($body) . " bytes) ---\n" + . $body; if (!empty($result['curl_error_no'])) { - return "Error: " . $result['curl_error_msg']; + return "Error: cURL #" . $result['curl_error_no'] . " " . $result['curl_error_msg'] . " (url=" . $effectiveUrl . ")"; } - $json = json_decode((string) (string) $result['content'], true); + $json = json_decode($body, true); if ($json === null && json_last_error() !== JSON_ERROR_NONE) { - return "Error: Invalid JSON response from API."; + // Common real-world causes: HTTP 4xx/5xx with empty body, HTML error page + // from a proxy, gateway timeout, etc. Surface the HTTP code and a short + // body snippet so the admin can diagnose without re-running with curl. + $snippet = substr($body, 0, 500); + return "Error: Invalid JSON response from API (HTTP " . $httpCode . ", " + . strlen($body) . " bytes). Body snippet: " + . ($snippet !== '' ? $snippet : ''); } if (isset($json['error'])) { From af300a94678285a1c0a3f243480c2675f56c5063 Mon Sep 17 00:00:00 2001 From: Jon Bendtsen Date: Mon, 18 May 2026 01:19:12 +0200 Subject: [PATCH 07/10] New add notrigger to member->validate() function (#38293) Co-authored-by: Jon Bendtsen --- htdocs/adherents/class/adherent.class.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/htdocs/adherents/class/adherent.class.php b/htdocs/adherents/class/adherent.class.php index 849516e3971..7dfe24e7e82 100644 --- a/htdocs/adherents/class/adherent.class.php +++ b/htdocs/adherents/class/adherent.class.php @@ -2181,9 +2181,10 @@ class Adherent extends CommonObject * Function that validate a member * * @param User $user user adherent qui valide + * @param int $notrigger 1=disable trigger UPDATE (when called by create) * @return int Return integer <0 if KO, 0 if nothing done, >0 if OK */ - public function validate($user) + public function validate($user, $notrigger = 0) { global $langs, $conf; @@ -2211,11 +2212,13 @@ class Adherent extends CommonObject $this->status = self::STATUS_VALIDATED; // Call trigger - $result = $this->call_trigger('MEMBER_VALIDATE', $user); - if ($result < 0) { - $error++; - $this->db->rollback(); - return -1; + if (!$notrigger) { + $result = $this->call_trigger('MEMBER_VALIDATE', $user); + if ($result < 0) { + $error++; + $this->db->rollback(); + return -1; + } } // End call triggers From 8dfeb69148c513a56372fd282859e444412b8ed6 Mon Sep 17 00:00:00 2001 From: Jon Bendtsen Date: Mon, 18 May 2026 01:19:29 +0200 Subject: [PATCH 08/10] NEW Member subscription tooltip includes label (#38291) * NEW Add label column to a members subscription list * NEW add label to member subscription mouseover tooltip * pre-commit fix by loading the correct language --------- Co-authored-by: Jon Bendtsen --- htdocs/adherents/class/subscription.class.php | 2 ++ htdocs/adherents/subscription.php | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/htdocs/adherents/class/subscription.class.php b/htdocs/adherents/class/subscription.class.php index 8dc2532b732..390cf90b213 100644 --- a/htdocs/adherents/class/subscription.class.php +++ b/htdocs/adherents/class/subscription.class.php @@ -440,12 +440,14 @@ class Subscription extends CommonObject $result = ''; $langs->load("members"); + $langs->load("main"); $label = img_picto('', $this->picto).' '.$langs->trans("Subscription").''; /*if (isset($this->statut)) { $label .= ' '.$this->getLibStatut(5); }*/ $label .= '
'.$langs->trans('Ref').': '.$this->ref; + $label .= '
'.$langs->trans('Label').': '.$this->note_public; if (!empty($this->dateh)) { $label .= '
'.$langs->trans('DateStart').': '.dol_print_date($this->dateh, 'day'); } diff --git a/htdocs/adherents/subscription.php b/htdocs/adherents/subscription.php index b56879b06b8..10ff978ec1f 100644 --- a/htdocs/adherents/subscription.php +++ b/htdocs/adherents/subscription.php @@ -717,7 +717,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { $sql = "SELECT d.rowid, d.firstname, d.lastname, d.societe, d.fk_adherent_type as type,"; $sql .= " c.rowid as crowid, c.subscription,"; $sql .= " c.datec, c.fk_type as cfk_type,"; - $sql .= " c.dateadh as dateh,"; + $sql .= " c.dateadh as dateh, c.note as clabel,"; $sql .= " c.datef,"; $sql .= " c.fk_bank,"; $sql .= " b.rowid as bid,"; @@ -744,6 +744,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { print_liste_field_titre('DateStart', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'center '); print_liste_field_titre('DateEnd', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'center '); print_liste_field_titre('Amount', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'right '); + print_liste_field_titre('Label', $_SERVER["PHP_SELF"], 'clabel', '', $param, '', $sortfield, $sortorder); if (isModEnabled('bank')) { print_liste_field_titre('Account', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'right '); } @@ -762,6 +763,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { $subscriptionstatic->ref = $objp->crowid; $subscriptionstatic->id = $objp->crowid; + $subscriptionstatic->note_public = $objp->clabel; $typeid = $objp->cfk_type; if ($typeid > 0) { @@ -779,6 +781,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { print ''.dol_print_date($db->jdate($objp->dateh), 'day')."\n"; print ''.dol_print_date($db->jdate($objp->datef), 'day')."\n"; print ''.price($objp->subscription).''; + print ''.$objp->clabel.''; if (isModEnabled('bank')) { print ''; if ($objp->bid) { From 0d5225d9c8ee35ce8508889737081e4036a866e3 Mon Sep 17 00:00:00 2001 From: Jon Bendtsen Date: Mon, 18 May 2026 01:20:02 +0200 Subject: [PATCH 09/10] NEW add label textline on massaction member subscription (#38288) Co-authored-by: Jon Bendtsen --- htdocs/adherents/list.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/htdocs/adherents/list.php b/htdocs/adherents/list.php index 800ac22cf92..c2d23969900 100644 --- a/htdocs/adherents/list.php +++ b/htdocs/adherents/list.php @@ -356,6 +356,7 @@ if (empty($reshook)) { if ($action == 'createsubscription_confirm' && $confirm == "yes" && $user->hasRight('adherent', 'creer')) { $tmpmember = new Adherent($db); $adht = new AdherentType($db); + $label = GETPOST("label"); $nbcreated = 0; $now = dol_now(); $amount = price2num(GETPOST('amount', 'alpha')); @@ -363,7 +364,7 @@ if (empty($reshook)) { foreach ($toselect as $id) { $res = $tmpmember->fetch($id); if ($res > 0) { - $result = $tmpmember->subscription($now, (float) $amount); + $result = $tmpmember->subscription($now, (float) $amount, 0, '', $label); if ($result < 0) { $error++; } else { @@ -830,6 +831,7 @@ if ($massaction == 'createsubscription') { $formquestion = array( array('label' => $langs->trans("DateSubscription"), 'type' => 'other', 'value' => $date), array('label' => $langs->trans("Amount"), 'type' => 'text', 'value' => price($amount, 0, '', 0), 'name' => 'amount'), + array('label' => $langs->trans("Label"), 'type' => 'text', 'value' => '', 'name' => 'label'), array('type' => 'separator'), array('label' => $langs->trans("MoreActions"), 'type' => 'other', 'value' => $langs->trans("None").' '.img_warning($langs->trans("WarningNoComplementaryActionDone"))), ); From f0b6c2cc33fd09f0b6a2b0c467b78c543d64e0e8 Mon Sep 17 00:00:00 2001 From: Jon Bendtsen Date: Mon, 18 May 2026 01:25:13 +0200 Subject: [PATCH 10/10] NEW Add label column to a members subscription list (#38289) Co-authored-by: Jon Bendtsen