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
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/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"))),
);
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) {
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)) {
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'];
}
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'])) {
diff --git a/htdocs/ai/tools/crud_objects.class.php b/htdocs/ai/tools/crud_objects.class.php
index 7c57d92a11f..61933736148 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" => [
@@ -539,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;
@@ -599,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);
|