* Exclude doctemplates as DB dependencies
* FIX: Add Ghostscript installation and caching for Windows CI
# FIX: Add Ghostscript installation and caching for Windows CI
Ghostscript is necessary for one of the tests that converts an image into a PDF.
This commit adds the necessary steps to install Ghostscript and cache it for the Windows CI workflow.
* Fix#37675 API Post eventattendee sets ref to same as id
* Move empty ref check after fetch #38274
---------
Co-authored-by: Jon Bendtsen <jonbendtsen@jonb.dk>
# Qual: Better error message for missing translation
Update error message to include 'error - ' after the line:offset: text
to correspond to an emacs like error message recognized by logToCs
which allows it to appear as a github annotation
# Qual: Fix Phan notices observed in #38164
Notices appear in #38164 in unchanged files, probably because the analysis is more selective in PRs.
This fixes notices in the observed files
# Qual: Fix PHPStan for ldap, pgsql, odtphp
- Adjust typing in ldap and pgsql class to fix PHPStan notices;
- Exclude all files for odtphp from analysis (external lib);
- Update PHPStan baseline.
When creating or updating an action linked to an event organization element
(e.g., conferenceorboothattendee), the permission check was failing because
the module 'eventorganization' does not have its own 'read' right in the
standard permission matrix; it relies on the 'projet' (Project) module.
This change maps 'eventorganization' to 'projet' during the permission check,
aligning with how restrictedArea() handles this module.
Also refactors the existing 'productbatch' mapping to use a switch statement
for better readability and consistency across the three action blocks.
Co-authored-by: Jon Bendtsen <jonbendtsen@jonb.dk>
- Add 'max_participants' column to llx_actioncomm table (nullable)
- Add index 'idx_actioncomm_max_participants' for performance optimization
- Update table definition and key files for fresh installs
- Update migration script for existing installations
This column defines the maximum number of participants allowed for a session.
NULL means unlimited. This is used for events, meetings, and volunteer shifts.
This commit only contains database structure changes (SQL files).
PHP logic and UI implementation will follow in subsequent PRs.
Fixes#38247
Co-authored-by: Jon Bendtsen <jonbendtsen@jonb.dk>
* Fix reception card supplierorder errors
* Include subprice_ttc & multicurrency_subprice_ttc in the SQL because we assign them later, and that would give a PHP warning Undefined property
* Refactor origin handling in card.php
* Update card.php
---------
Co-authored-by: Jon Bendtsen <jonbendtsen@jonb.dk>
Co-authored-by: Laurent Destailleur <eldy@destailleur.fr>
* Fix#38278 POST to API /members/{id}/subscriptions returns -1
* return the id of the new subscription
---------
Co-authored-by: Jon Bendtsen <jonbendtsen@jonb.dk>
The empty option in the supplier/customer general account dropdown gets
value='-1' from Form::selectarray. When the form is submitted, GETPOST
returns '-1' and Societe::create()/update() stores it as a string because
the SQL builder's !empty() check accepts '-1' as truthy.
The bookkeeping export in purchasesjournal.php and sellsjournal.php then
uses '-1' as the account code (only bankjournal.php has a != '-1' guard)
and the transfer fails.
Normalize '-1' to '' next to the existing trim() in create() and update()
for both accountancy_code_customer_general and accountancy_code_supplier_general.
The existing SQL !empty() checks then write NULL as intended, and rows
that already contain '-1' self-heal on the next update.
* 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 <jonbendtsen@jonb.dk>
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 '<empty>' 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: <empty>
instead of the previous opaque message.
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/<model>: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/<urlencoded model>:generateContent
(and skip the /chat/completions suffix entirely for google)
2. Payload:
{
"contents": [
{"role": "user", "parts": [{"text": <full prompt>}]}
],
"system_instruction": {"parts": [{"text": <pre-prompt>}]}
}
3. Header: x-goog-api-key: <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.
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.
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).
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_<SERVICE>_MODEL_TEXT, matching the convention already used by
Ai::generateContent() for the same data. The previous code read
AI_API_<SERVICE>_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_<SERVICE>_MODEL_TEXT first (the constant the admin
UI writes), and falls back to the legacy AI_API_<SERVICE>_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.
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).
cleanToolSchemaForLLM() truncates each tool description to 3 words
whenever the schema has more than 20 tools. This heuristic likely
made sense for GPT-3.5 (4K context window) but is harmful with the
current crop of LLMs:
- Gemini 2.5 Flash: 1M token context
- GPT-4o: 128K
- Claude Sonnet: 1M
Three words per description is far too aggressive and actively
breaks tool selection. For example:
- create_other_document -> "Create documents other"
- create_sales_order -> "Create a CUSTOMER"
- add_line_item -> "Add a single"
The LLM then has no way to know that create_other_document is what
should be used for supplier_invoice, or that add_line_item modifies
an EXISTING draft. We've observed Gemini concluding that
"supplier_invoice creation is not available" because the truncated
description didn't mention it.
Two complementary changes:
1. Raise the threshold from 20 to 100 -- this effectively disables
compression for normal installs (~30 tools) while leaving a
safety net for very large custom installs that register dozens
of additional tools via the addMcpTools hook.
2. Even when compression IS active, never touch the tool-level
description. Parameter-level compression (stripping defaults,
descriptions of optional fields, collapsing complex objects)
still applies and continues to save tokens. The right answer
for truly oversized schemas is to filter the toolset upstream
(which filterToolsProfessional() already does), not to mutilate
each tool's description.
Tested with: a 30-tool schema sent in full now allows the LLM to
correctly select create_other_document with object_type
'supplier_invoice', 'proposal' and 'supplier_proposal' --
previously it returned a "feature not available" message.
The AI Assistant web UI (parse_intent.php) persists every request to
llx_ai_request_log via ai_log_request() in ai.lib.php. Administrators
can browse the resulting log via the existing Log Viewer
(htdocs/ai/admin/log_viewer.php).
The MCP HTTP server (mcp_server.php) does NOT call this helper. As a
result, external MCP client activity (Claude Desktop, Claude Code CLI,
MCP Inspector, custom scripts, etc.) is invisible to administrators
-- only dol_syslog() entries land in documents/dolibarr.log, which
is much harder to filter and audit.
This patch adds an mcp_log_request() helper alongside the existing
auth/dispatch logic in mcp_server.php. The helper:
- Persists every tools/call invocation to llx_ai_request_log
- Filters out lifecycle methods (initialize, ping,
notifications/initialized, tools/list, ...) to avoid log spam
- Logs with provider='mcp' so administrators can filter MCP traffic
in the Log Viewer
- Captures the tool name + arguments, success/error status, raw
request payload and raw response payload
- Charges the elapsed time per-call (also per-call inside batches)
The helper is gated by the same AI_LOG_REQUESTS toggle that already
controls the web UI logging -- no new admin setting introduced.
Tested with: search_products, create_other_document, search_invoice,
get_sales_report from Claude Desktop and Claude Code CLI -- each
call appears as a new row in the Log Viewer with the right tool name,
status, payloads, and elapsed time.
The get_sales_report tool declares a group_by parameter with enum
values [thirdparty, product, month] in its inputSchema, but the
implementation ignored $args['group_by'] entirely and always
returned a flat list of invoices.
Meanwhile getPurchaseReport() in the same class already implemented
the correct grouped pattern (s.nom or DATE_FORMAT date) -- this
patch ports that pattern to getSalesReport(), with three modes:
- With a socid: detailed invoice list for that customer (unchanged
behavior, kept for backward compatibility).
- Without socid, group_by=thirdparty (default): aggregate by
customer with COUNT and SUM(total_ttc) -> Top-customers report.
- Without socid, group_by=month: aggregate by DATE_FORMAT(datef,
'%Y-%m') -> monthly revenue report.
- Without socid, group_by=product: aggregate by product reference
via INNER JOIN on facturedet, with COALESCE(p.ref, fd.description)
so free-text lines without a product link still appear under
their description. SUM(fd.total_ttc) gives per-product revenue,
COUNT(DISTINCT f.rowid) gives the number of invoices that
contained the product.
Tested:
- group_by=thirdparty: top customers correctly ranked by total.
- group_by=month: 2026-04 and 2026-05 buckets correctly populated.
- group_by=product: top product (SAM-BIK3-CAS-1430-24-N) correctly
ranked at 1 276 EUR over 5 invoices.
- With socid: unchanged detailed listing.
Note: the 'product' grouping is also missing from getPurchaseReport()
in the current code. That can be addressed in a follow-up patch on
the same pattern.