Merge branch 'develop' of git@github.com:Dolibarr/dolibarr.git into develop

This commit is contained in:
Laurent Destailleur 2026-05-18 02:36:55 +02:00
commit ca3f1be55a
8 changed files with 158 additions and 29 deletions

View file

@ -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

View file

@ -440,12 +440,14 @@ class Subscription extends CommonObject
$result = '';
$langs->load("members");
$langs->load("main");
$label = img_picto('', $this->picto).' <u class="paddingrightonly">'.$langs->trans("Subscription").'</u>';
/*if (isset($this->statut)) {
$label .= ' '.$this->getLibStatut(5);
}*/
$label .= '<br><b>'.$langs->trans('Ref').':</b> '.$this->ref;
$label .= '<br><b>'.$langs->trans('Label').':</b> '.$this->note_public;
if (!empty($this->dateh)) {
$label .= '<br><b>'.$langs->trans('DateStart').':</b> '.dol_print_date($this->dateh, 'day');
}

View file

@ -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"))),
);

View file

@ -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 '<td class="center">'.dol_print_date($db->jdate($objp->dateh), 'day')."</td>\n";
print '<td class="center">'.dol_print_date($db->jdate($objp->datef), 'day')."</td>\n";
print '<td class="right amount">'.price($objp->subscription).'</td>';
print '<td class="left">'.$objp->clabel.'</td>';
if (isModEnabled('bank')) {
print '<td class="tdoverflowmax100 right">';
if ($objp->bid) {

View file

@ -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_<SERVICE>_MODEL_TEXT (matching the
// convention already used by Ai::generateContent() for the same data). The
// previous lookup used AI_API_<SERVICE>_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)) {

View file

@ -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/<model>: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/<model>: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'];
}

View file

@ -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 : '<empty>');
}
if (isset($json['error'])) {

View file

@ -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);