Merge branch 'develop' of git@github.com:Dolibarr/dolibarr.git into develop
This commit is contained in:
commit
ca3f1be55a
8 changed files with 158 additions and 29 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'])) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue