2026-05-10 23:59:55 +00:00
< ? php
/* Copyright ( C ) 2026 Laurent Destailleur < eldy @ users . sourceforge . net >
* Copyright ( C ) 2026 Nick Fragoulis
Fix: MCP server propagates authenticated service user to tools (#38294)
The MCP server (htdocs/ai/server/mcp_server.php) loads a service user
from AI_MCP_USER_ID but does not expose it as the PHP global $user.
Several tool classes call 'global $user' to identify the caller --
they therefore see no authenticated user in HTTP MCP context (no PHP
web session exists) and return 'User not authenticated' even though
authentication via X-API-Key/Bearer has already succeeded.
This breaks roughly 40% of the MCP tools shipped by the module when
called from external clients (Claude Desktop Custom Connectors,
Claude Code CLI, MCP Inspector, custom scripts).
Two complementary fixes applied:
1. mcp_server.php: after successfully loading the service user,
assign it to $GLOBALS['user'] and the local $user variable. This
acts as a safety net for any tool (current or third-party) that
relies on the legacy 'global $user' pattern.
2. Three tool classes harmonize their constructor signatures with
the McpHandler contract -- new $className($db, $user, $conf):
- ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php)
- ToolInvoices (htdocs/ai/tools/invoices.class.php)
- ToolReports (htdocs/ai/tools/reports.class.php)
These now accept $user via constructor (matching the pattern
already used in ToolThirdParty, ToolCategories, ToolProducts)
and store it as $this->user. Methods that previously called
'global $user' (execute, validateInvoice, payInvoice) now use
the injected $this->user, with a fallback to the global for
web-session contexts.
Tested with:
- create_other_document (proposal, supplier_invoice): works
- search_invoice, validate_invoice, pay_invoice: works
- get_sales_report, get_purchase_report: works
- AI Assistant web UI: unchanged, no regression
2026-07-13 04:57:42 +00:00
* Copyright ( C ) 2026 Jose Martinez < jose . martinez @ pichinov . com >
2026-07-20 00:51:42 +00:00
* Copyright ( C ) 2026 MDW < mdeweerd @ users . noreply . github . com >
2026-05-10 23:59:55 +00:00
*
* This program is free software ; you can redistribute it and / or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation ; either version 3 of the License , or
* ( at your option ) any later version .
*
* This program is distributed in the hope that it will be useful ,
* but WITHOUT ANY WARRANTY ; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
* GNU General Public License for more details .
*
* You should have received a copy of the GNU General Public License
* along with this program . If not , see < https :// www . gnu . org / licenses />.
*/
/**
* \file htdocs / ai / tools / reports . php
* \ingroup ai
* \brief MCP Server tool for reports .
*
* This tool provides functionalities to generate various reports related to
* thirdparty transactions , sales , purchases , inventory , and other essential
* ERP / CRM reports .
*/
require_once DOL_DOCUMENT_ROOT . '/societe/class/societe.class.php' ;
require_once DOL_DOCUMENT_ROOT . '/core/lib/date.lib.php' ;
/**
* Class ToolReports
*
* Provides various tools related to Dolibarr reports .
*/
class ToolReports extends McpTool
{
/**
* Constructor
*
Fix: MCP server propagates authenticated service user to tools (#38294)
The MCP server (htdocs/ai/server/mcp_server.php) loads a service user
from AI_MCP_USER_ID but does not expose it as the PHP global $user.
Several tool classes call 'global $user' to identify the caller --
they therefore see no authenticated user in HTTP MCP context (no PHP
web session exists) and return 'User not authenticated' even though
authentication via X-API-Key/Bearer has already succeeded.
This breaks roughly 40% of the MCP tools shipped by the module when
called from external clients (Claude Desktop Custom Connectors,
Claude Code CLI, MCP Inspector, custom scripts).
Two complementary fixes applied:
1. mcp_server.php: after successfully loading the service user,
assign it to $GLOBALS['user'] and the local $user variable. This
acts as a safety net for any tool (current or third-party) that
relies on the legacy 'global $user' pattern.
2. Three tool classes harmonize their constructor signatures with
the McpHandler contract -- new $className($db, $user, $conf):
- ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php)
- ToolInvoices (htdocs/ai/tools/invoices.class.php)
- ToolReports (htdocs/ai/tools/reports.class.php)
These now accept $user via constructor (matching the pattern
already used in ToolThirdParty, ToolCategories, ToolProducts)
and store it as $this->user. Methods that previously called
'global $user' (execute, validateInvoice, payInvoice) now use
the injected $this->user, with a fallback to the global for
web-session contexts.
Tested with:
- create_other_document (proposal, supplier_invoice): works
- search_invoice, validate_invoice, pay_invoice: works
- get_sales_report, get_purchase_report: works
- AI Assistant web UI: unchanged, no regression
2026-07-13 04:57:42 +00:00
* Aligned with McpHandler ' s instantiation contract : new $className ( $db , $user , $conf ) .
*
2026-05-10 23:59:55 +00:00
* @ param DoliDB $db Database handler
Fix: MCP server propagates authenticated service user to tools (#38294)
The MCP server (htdocs/ai/server/mcp_server.php) loads a service user
from AI_MCP_USER_ID but does not expose it as the PHP global $user.
Several tool classes call 'global $user' to identify the caller --
they therefore see no authenticated user in HTTP MCP context (no PHP
web session exists) and return 'User not authenticated' even though
authentication via X-API-Key/Bearer has already succeeded.
This breaks roughly 40% of the MCP tools shipped by the module when
called from external clients (Claude Desktop Custom Connectors,
Claude Code CLI, MCP Inspector, custom scripts).
Two complementary fixes applied:
1. mcp_server.php: after successfully loading the service user,
assign it to $GLOBALS['user'] and the local $user variable. This
acts as a safety net for any tool (current or third-party) that
relies on the legacy 'global $user' pattern.
2. Three tool classes harmonize their constructor signatures with
the McpHandler contract -- new $className($db, $user, $conf):
- ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php)
- ToolInvoices (htdocs/ai/tools/invoices.class.php)
- ToolReports (htdocs/ai/tools/reports.class.php)
These now accept $user via constructor (matching the pattern
already used in ToolThirdParty, ToolCategories, ToolProducts)
and store it as $this->user. Methods that previously called
'global $user' (execute, validateInvoice, payInvoice) now use
the injected $this->user, with a fallback to the global for
web-session contexts.
Tested with:
- create_other_document (proposal, supplier_invoice): works
- search_invoice, validate_invoice, pay_invoice: works
- get_sales_report, get_purchase_report: works
- AI Assistant web UI: unchanged, no regression
2026-07-13 04:57:42 +00:00
* @ param User | null $user Service user provided by McpHandler ( from AI_MCP_USER_ID )
* @ param Conf | null $conf Dolibarr config ( optional )
2026-05-10 23:59:55 +00:00
*/
Fix: MCP server propagates authenticated service user to tools (#38294)
The MCP server (htdocs/ai/server/mcp_server.php) loads a service user
from AI_MCP_USER_ID but does not expose it as the PHP global $user.
Several tool classes call 'global $user' to identify the caller --
they therefore see no authenticated user in HTTP MCP context (no PHP
web session exists) and return 'User not authenticated' even though
authentication via X-API-Key/Bearer has already succeeded.
This breaks roughly 40% of the MCP tools shipped by the module when
called from external clients (Claude Desktop Custom Connectors,
Claude Code CLI, MCP Inspector, custom scripts).
Two complementary fixes applied:
1. mcp_server.php: after successfully loading the service user,
assign it to $GLOBALS['user'] and the local $user variable. This
acts as a safety net for any tool (current or third-party) that
relies on the legacy 'global $user' pattern.
2. Three tool classes harmonize their constructor signatures with
the McpHandler contract -- new $className($db, $user, $conf):
- ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php)
- ToolInvoices (htdocs/ai/tools/invoices.class.php)
- ToolReports (htdocs/ai/tools/reports.class.php)
These now accept $user via constructor (matching the pattern
already used in ToolThirdParty, ToolCategories, ToolProducts)
and store it as $this->user. Methods that previously called
'global $user' (execute, validateInvoice, payInvoice) now use
the injected $this->user, with a fallback to the global for
web-session contexts.
Tested with:
- create_other_document (proposal, supplier_invoice): works
- search_invoice, validate_invoice, pay_invoice: works
- get_sales_report, get_purchase_report: works
- AI Assistant web UI: unchanged, no regression
2026-07-13 04:57:42 +00:00
public function __construct ( DoliDB $db , $user = null , $conf = null )
2026-05-10 23:59:55 +00:00
{
$this -> db = $db ;
Fix: MCP server propagates authenticated service user to tools (#38294)
The MCP server (htdocs/ai/server/mcp_server.php) loads a service user
from AI_MCP_USER_ID but does not expose it as the PHP global $user.
Several tool classes call 'global $user' to identify the caller --
they therefore see no authenticated user in HTTP MCP context (no PHP
web session exists) and return 'User not authenticated' even though
authentication via X-API-Key/Bearer has already succeeded.
This breaks roughly 40% of the MCP tools shipped by the module when
called from external clients (Claude Desktop Custom Connectors,
Claude Code CLI, MCP Inspector, custom scripts).
Two complementary fixes applied:
1. mcp_server.php: after successfully loading the service user,
assign it to $GLOBALS['user'] and the local $user variable. This
acts as a safety net for any tool (current or third-party) that
relies on the legacy 'global $user' pattern.
2. Three tool classes harmonize their constructor signatures with
the McpHandler contract -- new $className($db, $user, $conf):
- ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php)
- ToolInvoices (htdocs/ai/tools/invoices.class.php)
- ToolReports (htdocs/ai/tools/reports.class.php)
These now accept $user via constructor (matching the pattern
already used in ToolThirdParty, ToolCategories, ToolProducts)
and store it as $this->user. Methods that previously called
'global $user' (execute, validateInvoice, payInvoice) now use
the injected $this->user, with a fallback to the global for
web-session contexts.
Tested with:
- create_other_document (proposal, supplier_invoice): works
- search_invoice, validate_invoice, pay_invoice: works
- get_sales_report, get_purchase_report: works
- AI Assistant web UI: unchanged, no regression
2026-07-13 04:57:42 +00:00
$this -> user = $user ;
if ( $conf !== null ) {
$this -> conf = $conf ;
}
2026-05-10 23:59:55 +00:00
}
/**
* Returns an array of tool definitions .
*
* @ return list < array < string , mixed >> Array of tool definitions .
*/
public function getDefinitions () : array
{
return [
[
" name " => " get_thirdparty_transactions " ,
" description " => " Generate a list of raw transactions (Invoices, Orders) for a specific thirdparty. " ,
" inputSchema " => [
" type " => " object " ,
" properties " => [
" thirdparty_id " => [ " type " => " integer " , " description " => " The unique ID of the thirdparty. " ],
" thirdparty_name " => [ " type " => " string " , " description " => " The name of the thirdparty. " ],
" date_start " => [ " type " => " string " , " description " => " Start date (YYYY-MM-DD). " ],
" date_end " => [ " type " => " string " , " description " => " End date (YYYY-MM-DD). " ],
" transaction_type " => [
" type " => " string " ,
" enum " => [ " all " , " invoices " , " orders " , " proposals " ],
" description " => " Filter by type. " ,
" default " => " all "
]
],
" oneOf " => [
[ " required " => [ " thirdparty_id " ]],
[ " required " => [ " thirdparty_name " ]]
]
]
],
[
" name " => " get_sales_report " ,
" description " => " Generate a sales/revenue report. If a Thirdparty is provided, it returns a detailed breakdown for that customer. Otherwise, it returns a global summary. " ,
" inputSchema " => [
" type " => " object " ,
" properties " => [
" thirdparty_id " => [
" type " => " integer " ,
" description " => " Optional: The ID of the customer. "
],
" date_start " => [ " type " => " string " , " description " => " Start date (YYYY-MM-DD). " ],
" date_end " => [ " type " => " string " , " description " => " End date (YYYY-MM-DD). " ],
" group_by " => [
" type " => " string " ,
" enum " => [ " thirdparty " , " product " , " month " ],
" description " => " Only used if no Thirdparty is specified. Groups global results. " ,
" default " => " thirdparty "
]
],
" required " => [ " date_start " , " date_end " ]
]
],
[
" name " => " get_purchase_report " ,
" description " => " Generate a purchase/expense report. If a Supplier is provided, it returns a detailed breakdown. Otherwise, it returns a global summary. " ,
" inputSchema " => [
" type " => " object " ,
" properties " => [
" thirdparty_id " => [
" type " => " integer " ,
" description " => " Optional: The ID of the supplier. "
],
" date_start " => [ " type " => " string " , " description " => " Start date (YYYY-MM-DD). " ],
" date_end " => [ " type " => " string " , " description " => " End date (YYYY-MM-DD). " ],
" group_by " => [
" type " => " string " ,
" enum " => [ " supplier " , " product " , " month " ],
" description " => " Only used if no Supplier is specified. Groups global results. " ,
" default " => " supplier "
]
],
" required " => [ " date_start " , " date_end " ]
]
],
[
" name " => " get_inventory_report " ,
" description " => " Generate an inventory report showing current stock levels and valuation. " ,
" inputSchema " => [
" type " => " object " ,
" properties " => [
" category_id " => [ " type " => " integer " , " description " => " Filter by category ID. " ],
" warehouse_id " => [ " type " => " integer " , " description " => " Filter by warehouse ID. " ],
" include_zero_stock " => [ " type " => " boolean " , " default " => false ]
]
]
],
[
" name " => " get_financial_report " ,
" description " => " Generate a summary financial report (Income vs Expense) for a period. " ,
" inputSchema " => [
" type " => " object " ,
" properties " => [
" date_start " => [ " type " => " string " , " description " => " Start date (YYYY-MM-DD). " ],
" date_end " => [ " type " => " string " , " description " => " End date (YYYY-MM-DD). " ]
],
" required " => [ " date_start " , " date_end " ]
]
],
];
}
/**
* Return categories this tool belongs to .
* Used by the intent parser to filter available tools .
*
* @ return array < string > List of categories ( e . g . , [ 'billing' , 'commercial' ])
*/
public function getCategories () : array
{
return [ 'reporting' , 'commercial' , 'billing' , 'stock' ];
}
/**
* Executes the requested tool function based on its name .
*
* @ param string $name The name of the tool to execute .
* @ param array < string , mixed > $args The arguments for the tool ( key - value pairs ) .
* @ return mixed The result of the tool execution ( usually an array ) or an error array .
*/
public function execute ( string $name , array $args )
{
switch ( $name ) {
case 'get_thirdparty_transactions' :
return $this -> getThirdpartyTransactions ( $args );
case 'get_sales_report' :
return $this -> getSalesReport ( $args );
case 'get_purchase_report' :
return $this -> getPurchaseReport ( $args );
case 'get_inventory_report' :
return $this -> getInventoryReport ( $args );
case 'get_financial_report' :
return $this -> getFinancialReport ( $args );
default :
return [ " error " => " Tool function ' $name ' not found. " ];
}
}
/**
* Resolves a Thirdparty ID from either an ID or a name .
*
* If `thirdparty_id` is provided , it is returned directly .
* Otherwise , if `thirdparty_name` is provided , the function searches
* the Dolibarr societe table using a LIKE match and returns the first match .
*
* @ param array < string , mixed > $args Input parameters ( thirdparty_id , thirdparty_name )
*
* @ return int | null Thirdparty ID if found , otherwise null .
*/
private function resolveThirdparty ( $args )
{
if ( ! empty ( $args [ 'thirdparty_id' ])) {
return ( int ) $args [ 'thirdparty_id' ];
}
if ( ! empty ( $args [ 'thirdparty_name' ])) {
2026-07-20 00:51:42 +00:00
$sqlName = $this -> db -> escape ( $args [ 'thirdparty_name' ]);
2026-05-10 23:59:55 +00:00
$sql = " SELECT rowid FROM " . MAIN_DB_PREFIX . " societe
2026-07-20 00:51:42 +00:00
WHERE nom LIKE '%" . $sqlName . "%'
2026-05-10 23:59:55 +00:00
AND entity IN ( " . getEntity('societe') . " )
LIMIT 1 " ;
$resql = $this -> db -> query ( $sql );
if ( $resql && $obj = $this -> db -> fetch_object ( $resql )) {
return $obj -> rowid ;
}
}
return null ;
}
/**
* Generate a sales / revenue report .
*
* @ param array < string , mixed > $args Input arguments ( date_start , date_end , limit , etc . )
* @ return array < int , array < string , string >> List of sales with localized keys and values .
*/
private function getSalesReport ( array $args ) : array
{
global $langs ;
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$langs -> loadLangs ( array ( " main " , " bills " , " companies " , " products " ));
2026-05-10 23:59:55 +00:00
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$limit = isset ( $args [ 'limit' ]) ? ( int ) $args [ 'limit' ] : 50 ;
2026-05-10 23:59:55 +00:00
$dateStart = dol_stringtotime ( $args [ 'date_start' ]);
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$dateEnd = dol_stringtotime ( $args [ 'date_end' ]);
$socid = $this -> resolveThirdparty ( $args );
$groupBy = isset ( $args [ 'group_by' ]) ? ( string ) $args [ 'group_by' ] : 'thirdparty' ;
2026-05-10 23:59:55 +00:00
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$list = [];
$totalSum = 0.0 ;
2026-05-10 23:59:55 +00:00
// Status Filter: Valid (1) and Paid (2). Exclude Draft (0) and Abandoned (3).
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$dateRange = " AND f.datef >= ' " . $this -> db -> idate ( $dateStart )
. " ' AND f.datef <= ' " . $this -> db -> idate ( $dateEnd )
. " ' AND f.fk_statut IN (1, 2) " ;
2026-05-10 23:59:55 +00:00
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
// CASE 1 -- Detailed list for a specific thirdparty.
2026-05-10 23:59:55 +00:00
if ( $socid ) {
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$sql = " SELECT f.rowid, f.ref, f.total_ttc, f.fk_statut, f.paye, f.datef, s.nom FROM "
. MAIN_DB_PREFIX . " facture as f LEFT JOIN "
. MAIN_DB_PREFIX . " societe as s ON f.fk_soc = s.rowid WHERE f.entity IN ( "
. getEntity ( 'facture' ) . " ) "
. $dateRange
. " AND f.fk_soc = " . ( int ) $socid
. " ORDER BY f.datef DESC LIMIT " . (( int ) $limit );
2026-05-10 23:59:55 +00:00
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$resql = $this -> db -> query ( $sql );
if ( $resql ) {
while ( $r = $this -> db -> fetch_object ( $resql )) {
$totalSum += ( float ) $r -> total_ttc ;
$statusLabel = $langs -> transnoentitiesnoconv ( " Unknown " );
if ( $r -> fk_statut == 1 && $r -> paye == 0 ) {
$statusLabel = $langs -> transnoentitiesnoconv ( " BillStatusNotPaid " );
} elseif ( $r -> fk_statut == 1 && $r -> paye == 1 ) {
$statusLabel = $langs -> transnoentitiesnoconv ( " BillStatusStarted " );
} elseif ( $r -> fk_statut == 2 ) {
$statusLabel = $langs -> transnoentitiesnoconv ( " BillStatusPaid " );
}
$url = DOL_URL_ROOT . " /compta/facture/card.php?id= " . $r -> rowid ;
$refHtml = '<a href="' . $url . '">' . $r -> ref . '</a>' ;
$list [] = [
$langs -> transnoentitiesnoconv ( " Ref " ) => $refHtml ,
$langs -> transnoentitiesnoconv ( " Date " ) => dol_print_date ( $this -> db -> jdate ( $r -> datef ), 'day' ),
$langs -> transnoentitiesnoconv ( " Customer " ) => $r -> nom ,
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $r -> total_ttc ),
$langs -> transnoentitiesnoconv ( " Status " ) => $statusLabel
];
2026-05-10 23:59:55 +00:00
}
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$this -> db -> free ( $resql );
}
} else {
// CASE 2 -- Global grouped report.
// Mirrors the pattern already used by getPurchaseReport(); previous implementation
// of getSalesReport() ignored $groupBy entirely and always returned a flat list.
$sanitizedSqlGroup = '' ;
$colName = '' ;
$sqlJoin = " LEFT JOIN " . MAIN_DB_PREFIX . " societe as s ON f.fk_soc = s.rowid " ;
if ( $groupBy === 'month' ) {
$sanitizedSqlGroup = " DATE_FORMAT(f.datef, '%Y-%m') " ;
$colName = $langs -> transnoentitiesnoconv ( " Month " );
} elseif ( $groupBy === 'product' ) {
// Aggregate on product line items. Lines without product_id fall back to their description.
$sanitizedSqlGroup = " COALESCE(p.ref, fd.description, '?') " ;
$colName = $langs -> transnoentitiesnoconv ( " Product " );
$sqlJoin .= " INNER JOIN " . MAIN_DB_PREFIX . " facturedet as fd ON fd.fk_facture = f.rowid LEFT JOIN "
. MAIN_DB_PREFIX . " product as p ON fd.fk_product = p.rowid " ;
} else {
// Default: group by customer
$sanitizedSqlGroup = " s.nom " ;
$colName = $langs -> transnoentitiesnoconv ( " Customer " );
}
2026-05-10 23:59:55 +00:00
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
// For product grouping we sum line totals (more accurate per-product);
// otherwise we sum the invoice total_ttc.
$amountExpr = ( $groupBy === 'product' ) ? " SUM(fd.total_ttc) " : " SUM(f.total_ttc) " ;
$countExpr = ( $groupBy === 'product' ) ? " COUNT(DISTINCT f.rowid) " : " COUNT(f.rowid) " ;
$sql = " SELECT " . $sanitizedSqlGroup . " as group_key, "
. $amountExpr . " as total_amount, "
. $countExpr . " as count_inv FROM "
. MAIN_DB_PREFIX . " facture as f "
. $sqlJoin
. " WHERE f.entity IN ( " . getEntity ( 'facture' ) . " ) "
. $dateRange
. " GROUP BY group_key ORDER BY total_amount DESC LIMIT "
. (( int ) max ( 1 , $limit ));
2026-05-10 23:59:55 +00:00
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
$resql = $this -> db -> query ( $sql );
if ( $resql ) {
while ( $r = $this -> db -> fetch_object ( $resql )) {
$totalSum += ( float ) $r -> total_amount ;
$list [] = [
$colName => $r -> group_key ? $r -> group_key : $langs -> transnoentitiesnoconv ( 'Unknown' ),
$langs -> transnoentitiesnoconv ( " Number " ) => ( int ) $r -> count_inv ,
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $r -> total_amount )
];
}
$this -> db -> free ( $resql );
2026-05-10 23:59:55 +00:00
}
}
if ( empty ( $list )) {
return [[ $langs -> transnoentitiesnoconv ( " Info " ) => $langs -> transnoentitiesnoconv ( " NoRecordFound " )]];
}
Fix: get_sales_report respects the group_by parameter (#38303)
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.
2026-05-17 22:04:14 +00:00
// Append Total Row (shape depends on detailed-vs-grouped path)
if ( $socid ) {
$list [] = [
$langs -> transnoentitiesnoconv ( " Ref " ) => $langs -> transnoentitiesnoconv ( " Total " ),
$langs -> transnoentitiesnoconv ( " Date " ) => " " ,
$langs -> transnoentitiesnoconv ( " Customer " ) => " " ,
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $totalSum ),
$langs -> transnoentitiesnoconv ( " Status " ) => " "
];
} else {
$list [] = [
$langs -> transnoentitiesnoconv ( " Total " ) => $langs -> transnoentitiesnoconv ( " Total " ),
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $totalSum )
];
}
2026-05-10 23:59:55 +00:00
return $list ;
}
/**
* Generate a list of raw transactions ( Invoices , Orders , Proposals ) .
*
* @ param array < string , mixed > $args Input arguments .
* @ return array < int , array < string , string >> Combined list of transactions .
*/
private function getThirdpartyTransactions ( array $args ) : array
{
global $langs ;
$langs -> loadLangs ( array ( " main " , " bills " , " orders " , " propal " ));
$dateStart = dol_stringtotime ( $args [ 'date_start' ]);
$dateEnd = dol_stringtotime ( $args [ 'date_end' ]);
$type = isset ( $args [ 'transaction_type' ]) ? ( string ) $args [ 'transaction_type' ] : 'all' ;
$socid = $this -> resolveThirdparty ( $args );
if ( ! $socid ) {
2026-05-19 12:08:05 +00:00
$langs -> load ( " errors " );
2026-05-10 23:59:55 +00:00
return [[ $langs -> transnoentitiesnoconv ( " Error " ) => $langs -> transnoentitiesnoconv ( " ErrorThirdPartyNotFound " )]];
}
2026-07-20 00:51:42 +00:00
$sqlQueries = [];
2026-05-10 23:59:55 +00:00
// Invoices
if ( $type == 'all' || $type == 'invoices' ) {
2026-07-20 00:51:42 +00:00
$sqlQueries [] = " SELECT 'Invoice' as source_type, rowid, ref, total_ttc as amount, datef as date_entry, fk_statut
2026-05-10 23:59:55 +00:00
FROM " . MAIN_DB_PREFIX . " facture
WHERE fk_soc = " . (int) $socid . " AND entity IN ( " . getEntity('facture') . " )
AND fk_statut IN ( 1 , 2 ) " ;
}
// Orders
if ( $type == 'all' || $type == 'orders' ) {
2026-07-20 00:51:42 +00:00
$sqlQueries [] = " SELECT 'Order' as source_type, rowid, ref, total_ttc as amount, date_commande as date_entry, fk_statut
2026-05-10 23:59:55 +00:00
FROM " . MAIN_DB_PREFIX . " commande
WHERE fk_soc = " . (int) $socid . " AND entity IN ( " . getEntity('commande') . " )
AND fk_statut > 0 " ;
}
// Proposals
if ( $type == 'all' || $type == 'proposals' ) {
2026-07-20 00:51:42 +00:00
$sqlQueries [] = " SELECT 'Proposal' as source_type, rowid, ref, total_ttc as amount, datep as date_entry, fk_statut
2026-05-10 23:59:55 +00:00
FROM " . MAIN_DB_PREFIX . " propal
WHERE fk_soc = " . (int) $socid . " AND entity IN ( " . getEntity('propal') . " )
AND fk_statut > 0 " ;
}
2026-07-20 00:51:42 +00:00
if ( empty ( $sqlQueries )) {
2026-05-10 23:59:55 +00:00
return [[ $langs -> transnoentitiesnoconv ( " Error " ) => " Invalid transaction type " ]];
}
$sql = " SELECT * FROM ( " ;
2026-07-20 00:51:42 +00:00
$sql .= implode ( " UNION " , $sqlQueries );
2026-05-10 23:59:55 +00:00
$sql .= " ) as combined_transactions " ;
$whereParts = [];
if ( $dateStart > 0 ) {
$whereParts [] = " date_entry >= ' " . $this -> db -> idate ( $dateStart ) . " ' " ;
}
if ( $dateEnd > 0 ) {
$whereParts [] = " date_entry <= ' " . $this -> db -> idate ( $dateEnd ) . " ' " ;
}
if ( ! empty ( $whereParts )) {
$sql .= " WHERE " . implode ( " AND " , $whereParts );
}
$sql .= " ORDER BY date_entry DESC " ;
$resql = $this -> db -> query ( $sql );
$list = [];
$totalAmt = 0.0 ;
if ( $resql ) {
while ( $r = $this -> db -> fetch_object ( $resql )) {
$totalAmt += ( float ) $r -> amount ;
$statusTxt = " " ;
$urlPath = " " ;
if ( $r -> source_type === 'Invoice' ) {
$urlPath = " /compta/facture/card.php?id= " . $r -> rowid ;
if ( $r -> fk_statut == 2 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " BillStatusPaid " );
} elseif ( $r -> fk_statut == 1 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " BillStatusNotPaid " );
}
} elseif ( $r -> source_type === 'Order' ) {
$urlPath = " /commande/card.php?id= " . $r -> rowid ;
if ( $r -> fk_statut == 1 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " StatusOrderValidated " );
} elseif ( $r -> fk_statut == 2 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " StatusOrderOnProcess " );
} elseif ( $r -> fk_statut == 3 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " StatusOrderDelivered " );
}
} elseif ( $r -> source_type === 'Proposal' ) {
$urlPath = " /comm/propal/card.php?id= " . $r -> rowid ;
if ( $r -> fk_statut == 1 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " PropalStatusValidated " );
} elseif ( $r -> fk_statut == 2 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " PropalStatusSigned " );
} elseif ( $r -> fk_statut == 3 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " PropalStatusNotSigned " );
} elseif ( $r -> fk_statut == 4 ) {
$statusTxt = $langs -> transnoentitiesnoconv ( " PropalStatusBilled " );
}
}
$fullUrl = $urlPath ? DOL_URL_ROOT . $urlPath : " " ;
$refHtml = $fullUrl ? '<a href="' . $fullUrl . '">' . $r -> ref . '</a>' : $r -> ref ;
$list [] = [
$langs -> transnoentitiesnoconv ( " Type " ) => $langs -> transnoentitiesnoconv ( $r -> source_type ),
$langs -> transnoentitiesnoconv ( " Ref " ) => $refHtml ,
$langs -> transnoentitiesnoconv ( " Date " ) => dol_print_date ( $this -> db -> jdate ( $r -> date_entry ), 'day' ),
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $r -> amount ),
$langs -> transnoentitiesnoconv ( " Status " ) => $statusTxt
];
}
$this -> db -> free ( $resql );
}
if ( empty ( $list )) {
return [[ $langs -> transnoentitiesnoconv ( " Info " ) => $langs -> transnoentitiesnoconv ( " NoRecordFound " )]];
}
// Summary
$list [] = [
$langs -> transnoentitiesnoconv ( " Type " ) => $langs -> transnoentitiesnoconv ( " Total " ),
$langs -> transnoentitiesnoconv ( " Ref " ) => " " ,
$langs -> transnoentitiesnoconv ( " Date " ) => " " ,
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $totalAmt ),
$langs -> transnoentitiesnoconv ( " Status " ) => " "
];
return $list ;
}
/**
* Generate a purchase / expense report .
*
* @ param array < string , mixed > $args Input arguments .
* @ return array < int , array < string , string >> List of purchases or groups .
*/
private function getPurchaseReport ( array $args ) : array
{
global $langs ;
$langs -> loadLangs ( array ( " main " , " bills " , " companies " ));
$socid = $this -> resolveThirdparty ( $args );
$groupBy = isset ( $args [ 'group_by' ]) ? ( string ) $args [ 'group_by' ] : 'supplier' ;
$dateStart = dol_stringtotime ( $args [ 'date_start' ]);
$dateEnd = dol_stringtotime ( $args [ 'date_end' ]);
$list = [];
$totalSum = 0.0 ;
// Detailed report for a specific Supplier
if ( $socid ) {
$sql = " SELECT f.rowid, f.ref, f.total_ttc, f.datef, s.nom
FROM " . MAIN_DB_PREFIX . " facture_fourn as f
LEFT JOIN " . MAIN_DB_PREFIX . " societe as s ON f . fk_soc = s . rowid
WHERE f . entity IN ( " . getEntity('facture_fourn') . " )
AND f . fk_soc = " . (int) $socid . "
AND f . datef >= '" . $this->db->idate($dateStart) . "'
AND f . datef <= '" . $this->db->idate($dateEnd) . "'
AND f . fk_statut > 0
ORDER BY f . datef DESC " ;
$resql = $this -> db -> query ( $sql );
if ( $resql ) {
while ( $r = $this -> db -> fetch_object ( $resql )) {
$totalSum += ( float ) $r -> total_ttc ;
$url = DOL_URL_ROOT . " /fourn/facture/card.php?id= " . $r -> rowid ;
$refHtml = '<a href="' . $url . '">' . $r -> ref . '</a>' ;
$list [] = [
$langs -> transnoentitiesnoconv ( " Ref " ) => $refHtml ,
$langs -> transnoentitiesnoconv ( " Date " ) => dol_print_date ( $this -> db -> jdate ( $r -> datef ), 'day' ),
$langs -> transnoentitiesnoconv ( " Supplier " ) => $r -> nom ,
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $r -> total_ttc )
];
}
$this -> db -> free ( $resql );
}
} else { // Global Grouped Report
$sanitizedSqlGroup = " " ;
$colName = " " ;
if ( $groupBy === 'month' ) {
$sanitizedSqlGroup = " DATE_FORMAT(f.datef, '%Y-%m') " ;
$colName = $langs -> transnoentitiesnoconv ( " Month " );
} else {
$sanitizedSqlGroup = " s.nom " ;
$colName = $langs -> transnoentitiesnoconv ( " Supplier " );
}
$sql = " SELECT " . $sanitizedSqlGroup . " as group_key, SUM(f.total_ttc) as total_amount, COUNT(f.rowid) as count_inv
FROM " . MAIN_DB_PREFIX . " facture_fourn as f
LEFT JOIN " . MAIN_DB_PREFIX . " societe as s ON f . fk_soc = s . rowid
WHERE f . entity IN ( " . getEntity('facture_fourn') . " )
AND f . datef >= '" . $this->db->idate($dateStart) . "'
AND f . datef <= '" . $this->db->idate($dateEnd) . "'
AND f . fk_statut > 0
GROUP BY group_key
ORDER BY total_amount DESC " ;
$resql = $this -> db -> query ( $sql );
if ( $resql ) {
while ( $r = $this -> db -> fetch_object ( $resql )) {
$totalSum += ( float ) $r -> total_amount ;
$list [] = [
$colName => $r -> group_key ? $r -> group_key : $langs -> transnoentitiesnoconv ( 'Unknown' ),
$langs -> transnoentitiesnoconv ( " Number " ) => $r -> count_inv ,
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $r -> total_amount )
];
}
$this -> db -> free ( $resql );
}
}
if ( empty ( $list )) {
return [[ $langs -> transnoentitiesnoconv ( " Info " ) => $langs -> transnoentitiesnoconv ( " NoRecordFound " )]];
}
// Summary Row
$summary = [
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $totalSum )
];
if ( $socid ) {
$summary [ $langs -> transnoentitiesnoconv ( " Ref " )] = $langs -> transnoentitiesnoconv ( " Total " );
$summary [ $langs -> transnoentitiesnoconv ( " Date " )] = " " ;
$summary [ $langs -> transnoentitiesnoconv ( " Supplier " )] = " " ;
} else {
$summary [( $groupBy === 'month' ? $langs -> transnoentitiesnoconv ( " Month " ) : $langs -> transnoentitiesnoconv ( " Supplier " ))] = $langs -> transnoentitiesnoconv ( " Total " );
$summary [ $langs -> transnoentitiesnoconv ( " Number " )] = " " ;
}
$list [] = $summary ;
return $list ;
}
/**
* Generate an inventory report .
*
* @ param array < string , mixed > $args Input arguments .
* @ return array < int , array < string , string >> Stock list .
*/
private function getInventoryReport ( array $args ) : array
{
global $langs ;
$langs -> loadLangs ( array ( " products " , " stocks " ));
$catId = isset ( $args [ 'category_id' ]) ? ( int ) $args [ 'category_id' ] : 0 ;
$warehouseId = isset ( $args [ 'warehouse_id' ]) ? ( int ) $args [ 'warehouse_id' ] : 0 ;
$includeZero = isset ( $args [ 'include_zero_stock' ]) ? ( bool ) $args [ 'include_zero_stock' ] : false ;
$sql = " SELECT p.rowid, p.ref, p.label, p.pmp, " ;
if ( $warehouseId > 0 ) {
$sql .= " ps.reel as stock_level " ;
$sql .= " FROM " . MAIN_DB_PREFIX . " product as p " ;
$sql .= " LEFT JOIN " . MAIN_DB_PREFIX . " product_stock as ps ON p.rowid = ps.fk_product " ;
$sql .= " WHERE ps.fk_entrepot = " . ( int ) $warehouseId ;
} else {
$sql .= " p.stock as stock_level " ;
$sql .= " FROM " . MAIN_DB_PREFIX . " product as p " ;
$sql .= " WHERE 1=1 " ;
}
$sql .= " AND p.entity IN ( " . getEntity ( 'product' ) . " ) " ;
if ( $catId > 0 ) {
$sql .= " AND p.rowid IN (SELECT fk_product FROM " . MAIN_DB_PREFIX . " categorie_product WHERE fk_categorie = " . ( int ) $catId . " ) " ;
}
if ( ! $includeZero ) {
if ( $warehouseId > 0 ) {
$sql .= " AND ps.reel > 0 " ;
} else {
$sql .= " AND p.stock > 0 " ;
}
}
$sql .= " ORDER BY p.ref ASC LIMIT 200 " ;
$resql = $this -> db -> query ( $sql );
$list = [];
$totalValuation = 0.0 ;
$totalItems = 0 ;
if ( $resql ) {
while ( $r = $this -> db -> fetch_object ( $resql )) {
$stockVal = $r -> stock_level * $r -> pmp ;
$totalValuation += $stockVal ;
$totalItems += ( int ) $r -> stock_level ;
$url = DOL_URL_ROOT . " /product/card.php?id= " . $r -> rowid ;
$refHtml = '<a href="' . $url . '">' . $r -> ref . '</a>' ;
$list [] = [
$langs -> transnoentitiesnoconv ( " Ref " ) => $refHtml ,
$langs -> transnoentitiesnoconv ( " Label " ) => $r -> label ,
$langs -> transnoentitiesnoconv ( " Stock " ) => $r -> stock_level ,
$langs -> transnoentitiesnoconv ( " PMPValue " ) => price ( $r -> pmp ),
$langs -> transnoentitiesnoconv ( " TotalValue " ) => price ( $stockVal )
];
}
$this -> db -> free ( $resql );
}
if ( empty ( $list )) {
return [[ $langs -> transnoentitiesnoconv ( " Info " ) => $langs -> transnoentitiesnoconv ( " NoRecordFound " )]];
}
$list [] = [
$langs -> transnoentitiesnoconv ( " Ref " ) => $langs -> transnoentitiesnoconv ( " Total " ),
$langs -> transnoentitiesnoconv ( " Label " ) => " " ,
$langs -> transnoentitiesnoconv ( " Stock " ) => $totalItems ,
$langs -> transnoentitiesnoconv ( " PMPValue " ) => " " ,
$langs -> transnoentitiesnoconv ( " TotalValue " ) => price ( $totalValuation )
];
return $list ;
}
/**
* Generate a summary financial report ( Income vs Expense ) .
*
* @ param array < string , mixed > $args Input arguments .
* @ return array < int , array < string , string >> Financial summary .
*/
private function getFinancialReport ( array $args ) : array
{
global $langs ;
$langs -> loadLangs ( array ( " compta " , " bills " ));
$dateStart = dol_stringtotime ( $args [ 'date_start' ]);
$dateEnd = dol_stringtotime ( $args [ 'date_end' ]);
// Income (Customer Invoices - Validated/Paid, no Drafts)
$sqlIncome = " SELECT SUM(total_ttc) as total FROM " . MAIN_DB_PREFIX . " facture
WHERE entity IN ( " . getEntity('facture') . " )
AND datef >= '" . $this->db->idate($dateStart) . "'
AND datef <= '" . $this->db->idate($dateEnd) . "'
AND fk_statut IN ( 1 , 2 ) " ;
$resIncome = $this -> db -> query ( $sqlIncome );
$objIncome = $this -> db -> fetch_object ( $resIncome );
$income = $objIncome && $objIncome -> total ? ( float ) $objIncome -> total : 0.0 ;
// Expenses (Supplier Invoices - Validated, no Drafts)
$sqlExpense = " SELECT SUM(total_ttc) as total FROM " . MAIN_DB_PREFIX . " facture_fourn
WHERE entity IN ( " . getEntity('facture_fourn') . " )
AND datef >= '" . $this->db->idate($dateStart) . "'
AND datef <= '" . $this->db->idate($dateEnd) . "'
AND fk_statut > 0 " ;
$resExpense = $this -> db -> query ( $sqlExpense );
$objExpense = $this -> db -> fetch_object ( $resExpense );
$expense = $objExpense && $objExpense -> total ? ( float ) $objExpense -> total : 0.0 ;
$net = $income - $expense ;
$list = [
[
$langs -> transnoentitiesnoconv ( " Category " ) => $langs -> transnoentitiesnoconv ( " Income " ),
$langs -> transnoentitiesnoconv ( " Description " ) => $langs -> transnoentitiesnoconv ( " BillsCustomers " ),
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $income )
],
[
$langs -> transnoentitiesnoconv ( " Category " ) => $langs -> transnoentitiesnoconv ( " Expenses " ),
$langs -> transnoentitiesnoconv ( " Description " ) => $langs -> transnoentitiesnoconv ( " BillsSuppliers " ),
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $expense )
],
[
$langs -> transnoentitiesnoconv ( " Category " ) => $langs -> transnoentitiesnoconv ( " Total " ),
$langs -> transnoentitiesnoconv ( " Description " ) => $langs -> transnoentitiesnoconv ( " Profit " ),
$langs -> transnoentitiesnoconv ( " Amount " ) => price ( $net )
]
];
return $list ;
}
}