* Copyright (C) 2026 Nick Fragoulis * * 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 . */ /** * \file htdocs/ai/tools/thirdparty.class.php * \ingroup ai * \brief MCP Server tool for Dolibarr categories. */ require_once DOL_DOCUMENT_ROOT . '/societe/class/societe.class.php'; require_once DOL_DOCUMENT_ROOT . '/contact/class/contact.class.php'; /** * Class ToolThirdParty * * Provides various tools related to Dolibarr categories. */ class ToolThirdParty extends McpTool { /** * Constructor * * @param DoliDB $db Database handler * @param User $user User object for permission checks */ public function __construct($db, $user) { $this->db = $db; $this->user = $user; } /** * Returns an array of tool definitions, including name, description, and input schema. * * @return list> Array of tool definitions. */ public function getDefinitions(): array { return [ [ "name" => "search_thirdparties", "description" => "Search for a thirdparty by ID, name, alias, code, or email. If a numerical ID is provided alone, it returns an exact match. If a name is provided, it returns a list of matches.", "inputSchema" => [ "type" => "object", "properties" => [ "query" => ["type" => ["string", "integer"], "description" => "The ID of the thirdparty, or a name/alias/code/email to search for."], "type" => ["type" => "string", "enum" => ["customer", "prospect", "supplier"], "description" => "Filter by type (optional)."], "country_code" => ["type" => "string", "description" => "ISO 2-letter country code (e.g. US, FR, GR) (optional)."], "limit" => ["type" => "integer", "default" => 5] ], "required" => ["query"] ] ], [ "name" => "count_thirdparties", "description" => "Count the number of thirdparties matching the search criteria.", "inputSchema" => [ "type" => "object", "properties" => [ "query" => ["type" => ["string", "integer"], "description" => "A name/alias/code/email to search for."], "type" => ["type" => "string", "enum" => ["customer", "prospect", "supplier"], "description" => "Filter by type (optional)."], "country_code" => ["type" => "string", "description" => "ISO 2-letter country code (e.g. US, FR, GR) (optional)."] ], "required" => ["query"] ] ], [ "name" => "get_thirdparty_details", "description" => "Get full details of a specific thirdparty by ID.", "inputSchema" => [ "type" => "object", "properties" => [ "id" => ["type" => "integer", "description" => "The unique numerical ID of the thirdparty."] ], "required" => ["id"] ] ], [ "name" => "create_thirdparty", "description" => "Create a new thirdparty (Using only terms: Customer, Prospect, or Supplier).", "inputSchema" => [ "type" => "object", "properties" => [ "name" => ["type" => "string", "description" => "Name of the thirdparty"], "type" => ["type" => "string", "enum" => ["customer", "prospect", "supplier", "both", "none"], "default" => "customer", "description" => "Type of thirdparty. 'none' means not a customer or prospect."], "email" => ["type" => "string", "description" => "Email address"], "phone" => ["type" => "string", "description" => "Phone number"], "address" => ["type" => "string", "description" => "Address"], "zip" => ["type" => "string", "description" => "Postal code"], "town" => ["type" => "string", "description" => "Town/City"], "country_code" => ["type" => "string", "description" => "ISO 2-letter country code (e.g. US, FR, GR)"], "code_client" => ["type" => "string", "description" => "Customer code (optional, -1 for auto-generation)"], "idprof1" => ["type" => "string", "description" => "Professional ID 1"], "idprof2" => ["type" => "string", "description" => "Professional ID 2"], "idprof3" => ["type" => "string", "description" => "Professional ID 3"], "idprof4" => ["type" => "string", "description" => "Professional ID 4"] ], "required" => ["name"] ] ], [ "name" => "update_thirdparty", "description" => "Updates an existing thirdparty's details.", "inputSchema" => [ "type" => "object", "properties" => [ "id" => ["type" => "integer", "description" => "The ID of the thirdparty to update."], "name" => ["type" => "string", "description" => "The new name for the thirdparty."], "email" => ["type" => "string", "description" => "The new email address."], "phone" => ["type" => "string", "description" => "The new phone number."], "address" => ["type" => "string", "description" => "The new address."], "zip" => ["type" => "string", "description" => "The new postal code."], "town" => ["type" => "string", "description" => "The new town."], "country_code" => ["type" => "string", "description" => "The new ISO 2-letter country code."] ], "required" => ["id"] ] ], [ "name" => "list_thirdparty_contacts", "description" => "Lists all contacts associated with a specific thirdparty.", "inputSchema" => [ "type" => "object", "properties" => [ "id" => ["type" => "integer", "description" => "The ID of the thirdparty."] ], "required" => ["id"] ] ], [ "name" => "add_thirdparty_contact", "description" => "Adds a new contact to an existing thirdparty.", "inputSchema" => [ "type" => "object", "properties" => [ "thirdparty_identifier" => [ "type" => ["string", "integer"], "description" => "The ID or name of the thirdparty to add the contact to." ], "firstname" => ["type" => "string", "description" => "Contact's first name."], "lastname" => ["type" => "string", "description" => "Contact's last name."], "email" => ["type" => "string", "description" => "Contact's email address."], "phone" => ["type" => "string", "description" => "Contact's phone number."], "role" => ["type" => "string", "description" => "Contact's role or position within the company."] ], "required" => ["thirdparty_identifier", "firstname", "lastname"] ] ] ]; } /** * Return categories this tool belongs to. * Used by the intent parser to filter available tools. * * @return array List of categories (e.g., ['billing', 'commercial']) */ public function getCategories(): array { return ['thirdparty', 'billing', 'commercial', 'project', 'stock']; } /** * Executes the requested tool function based on its name. * * @param string $name The name of the tool to execute. * @param array $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 'search_thirdparties': return $this->search($args, 0); case 'count_thirdparties': return $this->search($args, 1); case 'get_thirdparty_details': // Get info of agiven thidparty return $this->getDetails($args); case 'create_thirdparty': return $this->create($args); case 'update_thirdparty': return $this->update($args); case 'list_thirdparty_contacts': return $this->listContacts($args); case 'add_thirdparty_contact': return $this->addContact($args); default: return ["error" => "Tool function '$name' not found."]; } } /** * Search for third parties based on provided criteria. * * @param array{query:string|int, type?:string, country_code?:string, limit?:int|string} $args Array of arguments: * - query: Search string or ID * - country_code: ISO country code on 2 chars (FR, US, GR...) * - type: 'customer', 'prospect', 'supplier' * - limit: Limit results (default 5) * @param int $count If set to 1, returns only the count of results. * @return array{error:string}|array{count:int}|list */ private function search(array $args, int $count = 0) { if (!$this->user->hasRight('societe', 'lire')) { return ["error" => "Permission Denied"]; } $query = $args['query']; $type = isset($args['type']) ? (string) $args['type'] : ''; $country_code = isset($args['country_code']) ? (string) $args['country_code'] : ''; $limit = isset($args['limit']) ? (int) $args['limit'] : 5; // Safety fallback if ($limit <= 0) { $limit = 5; } if ($limit > 1000) { dol_syslog("Search DB Error: Too many record requested", LOG_ERR); return ["error" => "DB Error"]; } // Dolibarr SQL construction if ($count) { $sql = "SELECT COUNT(s.rowid) as nb"; } else { $sql = "SELECT s.rowid, s.nom, s.name_alias, s.code_client, s.code_fournisseur, s.email, s.client, s.fournisseur"; } $sql .= " FROM " . MAIN_DB_PREFIX . "societe as s"; if ($country_code) { $sql.= " INNER JOIN ".MAIN_DB_PREFIX."c_country as c ON s.fk_pays = c.rowid AND c.code = '".$this->db->escape($country_code)."'"; } $sql .= " WHERE s.entity IN (" . getEntity('societe') . ")"; if (is_numeric($query)) { $sql .= " AND s.rowid = " . (int) $query; $limit = 1; } else { $sql .= " AND (s.nom LIKE '%" . $this->db->escape($query) . "%'"; $sql .= " OR s.name_alias LIKE '%" . $this->db->escape($query) . "%'"; $sql .= " OR s.code_client LIKE '%" . $this->db->escape($query) . "%'"; $sql .= " OR s.code_fournisseur LIKE '%" . $this->db->escape($query) . "%'"; $sql .= " OR s.email LIKE '%" . $this->db->escape($query) . "%')"; } if ($type === 'customer') { $sql .= " AND s.client IN (1, 3)"; } elseif ($type === 'prospect') { $sql .= " AND s.client IN (2, 3)"; } elseif ($type === 'supplier') { $sql .= " AND s.fournisseur = 1"; } $sql .= " ORDER BY s.nom ASC"; $sql .= $this->db->plimit($limit); $resql = $this->db->query($sql); if (!$resql) { dol_syslog("Search DB Error: " . $this->db->lasterror(), LOG_ERR); return ["error" => "DB Error"]; } $data = []; while ($obj = $this->db->fetch_object($resql)) { if ($count) { $data = [ "count" => (int) $obj->nb ]; $this->db->free($resql); return $data; } $roles = []; // Cast strictly to ensure type safety in logic $is_client = (int) $obj->client; $is_supplier = (int) $obj->fournisseur; if ($is_client === 1 || $is_client === 3) { $roles[] = "Customer"; } if ($is_client === 2 || $is_client === 3) { $roles[] = "Prospect"; } if ($is_supplier === 1) { $roles[] = "Supplier"; } $data[] = [ "id" => (int) $obj->rowid, "name" => (string) $obj->nom, "alias" => (string) $obj->name_alias, "code_cust" => (string) $obj->code_client, "code_sup" => (string) $obj->code_fournisseur, "email" => (string) $obj->email, "type" => implode('/', $roles), "url" => DOL_URL_ROOT . "/societe/card.php?socid=" . $obj->rowid ]; } $this->db->free($resql); return $data; } /** * Fetch details for a specific third party (Societe). * * @param array{id: int|string} $args Arguments array containing the thirdparty ID. * * @return array{error:string}|array * */ private function getDetails(array $args): array { if (!$this->user->hasRight('societe', 'lire')) { return ["error" => "Permission Denied"]; } if (empty($args['id'])) { return ["error" => "ID is required"]; } $soc = new Societe($this->db); // Fetch returns > 0 on success, 0 on not found, < 0 on error $result = $soc->fetch((int) $args['id']); if ($result > 0) { return [ "id" => (int) $soc->id, "name" => (string) $soc->nom, "address" => (string) $soc->address, "zip" => (string) $soc->zip, "city" => (string) $soc->town, "country" => (string) $soc->country, "email" => (string) $soc->email, "phone" => (string) $soc->phone, "vat" => (string) $soc->tva_intra, // getLibStatut(2) returns the status label (short). Cast to string just in case. "status" => (string) $soc->getLibStatut(2), "url" => DOL_URL_ROOT . "/societe/card.php?socid=" . $soc->id ]; } return ["error" => "Thirdparty not found"]; } /** * Create a new third party (Societe). * * @param array $args Arguments array. 'name' is mandatory. * * @return array{error:string}|array * */ private function create(array $args) { global $conf; // Check permissions if (!$this->user->hasRight('societe', 'creer')) { return ["error" => "Permission Denied"]; } // Validate mandatory fields if (empty($args['name'])) { return ["error" => "Name is required"]; } $soc = new Societe($this->db); // Assign properties with strict casting to prevent null issues in strict mode $soc->nom = (string) $args['name']; $soc->email = isset($args['email']) ? (string) $args['email'] : ''; $soc->phone = isset($args['phone']) ? (string) $args['phone'] : ''; $soc->address = isset($args['address']) ? (string) $args['address'] : ''; $soc->zip = isset($args['zip']) ? (string) $args['zip'] : ''; $soc->town = isset($args['town']) ? (string) $args['town'] : ''; $soc->code_client = isset($args['code_client']) ? (string) $args['code_client'] : ''; $soc->idprof1 = isset($args['idprof1']) ? (string) $args['idprof1'] : ''; $soc->idprof2 = isset($args['idprof2']) ? (string) $args['idprof2'] : ''; $soc->idprof3 = isset($args['idprof3']) ? (string) $args['idprof3'] : ''; $soc->idprof4 = isset($args['idprof4']) ? (string) $args['idprof4'] : ''; // Country Handling if (! empty($args['country_code'])) { require_once DOL_DOCUMENT_ROOT . '/core/lib/company.lib.php'; // getCountry returns an array or false/0. /** @var array{id:int}|false $info */ $info = getCountry($args['country_code'], '1'); if ($info && isset($info['id'])) { $soc->country_id = (int) $info['id']; } } // Fallback to default country if not set if (empty($soc->country_id) && !empty($conf->global->MAIN_INFO_SOCIETE_COUNTRY)) { $soc->country_id = (int) $conf->global->MAIN_INFO_SOCIETE_COUNTRY; } $type = $args['type'] ?? 'customer'; $soc->client = 0; // Default: not a customer $soc->fournisseur = 0; // Default: not a supplier if ($type === 'customer') { $soc->client = 1; if (empty($soc->code_client)) { $soc->code_client = '-1'; } } elseif ($type === 'prospect') { $soc->client = 2; if (empty($soc->code_client)) { $soc->code_client = '-1'; } } elseif ($type === 'both') { $soc->client = 3; // Prospect + Customer if (empty($soc->code_client)) { $soc->code_client = '-1'; } } if ($type === 'supplier' || $type === 'both') { $soc->fournisseur = 1; if (empty($soc->code_fournisseur)) { $soc->code_fournisseur = '-1'; } } $result = $soc->create($this->user); if ($result > 0) { return [ "status" => "success", "message" => "Thirdparty created", "id" => (int) $soc->id, "name" => (string) $soc->name, "url" => DOL_URL_ROOT . "/societe/card.php?socid=" . $soc->id ]; } return ["error" => "Create failed: " . (string) $soc->error]; } /** * Update a thirdparty record. * * @param array{id:int|string,name?:string,email?:string,phone?:string,address?:string,zip?:string,town?:string,country_code?:string} $args Thirdparty fields to update (ID required). * * @return array{status:string,message:string,id:int,url:string}|array{error:string} * */ private function update(array $args) { if (!$this->user->hasRight('societe', 'creer') && !$this->user->hasRight('societe', 'modifier')) { return ["error" => "Permission Denied to update."]; } if (empty($args['id'])) { return ["error" => "ID is required for update."]; } $soc = new Societe($this->db); $result = $soc->fetch((int) $args['id']); if ($result <= 0) { return ["error" => "Thirdparty not found with ID: " . $args['id']]; } // Update fields only if provided in arguments (Partial Update) // We cast to (string) to ensure strict type compliance if (isset($args['name'])) { $soc->nom = (string) $args['name']; } if (isset($args['email'])) { $soc->email = (string) $args['email']; } if (isset($args['phone'])) { $soc->phone = (string) $args['phone']; } if (isset($args['address'])) { $soc->address = (string) $args['address']; } if (isset($args['zip'])) { $soc->zip = (string) $args['zip']; } if (isset($args['town'])) { $soc->town = (string) $args['town']; } // Handle Country update if (!empty($args['country_code'])) { require_once DOL_DOCUMENT_ROOT . '/core/lib/company.lib.php'; /** @var array{id:int}|false $info */ $info = getCountry($args['country_code'], '1'); if ($info && isset($info['id'])) { $soc->country_id = (int) $info['id']; } } if ($soc->update($soc->id, $this->user) > 0) { return [ "status" => "success", "message" => "Thirdparty updated successfully.", "id" => (int) $soc->id, "url" => DOL_URL_ROOT . "/societe/card.php?socid=" . $soc->id ]; } return ["error" => "Update failed: " . (string) $soc->error]; } /** * List all contacts for a given thirdparty. * * @param array{id: int|string} $args Arguments array containing the thirdparty ID. * * @return array{error: string}|list> * */ private function listContacts(array $args) { global $langs; if (!$this->user->hasRight('societe', 'lire')) { return ["error" => "Permission Denied"]; } if (empty($args['id'])) { return ["error" => "Thirdparty ID is required"]; } $socid = (int) $args['id']; $langs->load("companies"); $langs->load("other"); // Translations $label_id = $langs->transnoentitiesnoconv("Id"); $label_firstname = $langs->transnoentitiesnoconv("Firstname"); $label_lastname = $langs->transnoentitiesnoconv("Lastname"); $label_email = $langs->transnoentitiesnoconv("Email"); $label_phone = $langs->transnoentitiesnoconv("Phone"); $label_role = $langs->transnoentitiesnoconv("PostOrFunction"); $label_link = $langs->transnoentitiesnoconv("Link"); $link_text = $langs->transnoentitiesnoconv("Show"); // Verify thirdparty exists first $soc = new Societe($this->db); if ($soc->fetch($socid) <= 0) { return ["error" => "Thirdparty not found with ID: " . $socid]; } // Build SQL $sql = "SELECT t.rowid, t.firstname, t.lastname, t.email, t.phone, t.poste"; $sql .= " FROM " . MAIN_DB_PREFIX . "socpeople as t"; $sql .= " WHERE t.fk_soc = " . (int) $socid; // Fix: Added explicit (int) cast to satisfy CodingPhpTest static analysis $sql .= " AND t.entity IN (" . getEntity('socpeople') . ")"; $sql .= " ORDER BY t.lastname ASC, t.firstname ASC"; $resql = $this->db->query($sql); if (! $resql) { dol_syslog("DB Error in listContacts: " . $this->db->lasterror(), LOG_ERR); return ["error" => "DB Error"]; } $data = []; while ($obj = $this->db->fetch_object($resql)) { // Absolute URL $absoluteUrl = DOL_URL_ROOT . "/contact/card.php?id=" . $obj->rowid; $htmlLink = "" . $link_text . ""; $data[] = [ $label_id => (int) $obj->rowid, $label_firstname => (string) $obj->firstname, $label_lastname => (string) $obj->lastname, $label_email => (string) $obj->email, $label_phone => (string) $obj->phone, $label_role => (string) $obj->poste, $label_link => $htmlLink ]; } $this->db->free($resql); return $data; } /** * Add a contact linked to a thirdparty. * * @param array{thirdparty_identifier:int|string,firstname:string,lastname:string,email?:string,phone?:string} $args Arguments array (identifier, firstname, lastname required). * * @return array{status:string,message:string,id:int,url?:string}|array{error:string} * * */ private function addContact(array $args) { if (!$this->user->hasRight('societe', 'creer')) { return ["error" => "Permission Denied to create contacts."]; } if (empty($args['thirdparty_identifier']) || empty($args['firstname']) || empty($args['lastname'])) { return ["error" => "Thirdparty identifier, firstname and lastname are required."]; } $identifier = $args['thirdparty_identifier']; $soc = new Societe($this->db); $res = 0; // Fetch Thirdparty if (is_numeric($identifier)) { // Fetch by RowID $res = $soc->fetch((int) $identifier); } else { // Fetch by Ref/Code (2nd argument of fetch is $ref) $res = $soc->fetch(0, (string) $identifier); } if ($res <= 0) { return ["error" => "Thirdparty not found with identifier: " . $identifier]; } // Prepare Contact $contact = new Contact($this->db); $contact->socid = $soc->id; // Link to the fetched thirdparty $contact->firstname = (string) ($args['firstname'] ?? ''); $contact->lastname = (string) $args['lastname']; $contact->email = isset($args['email']) ? (string) $args['email'] : ''; $contact->phone_pro = isset($args['phone']) ? (string) $args['phone'] : ''; $contact->poste = isset($args['role']) ? (string) $args['role'] : ''; // Attempt Creation if ($contact->create($this->user) > 0) { return [ "status" => "success", "message" => "Contact created successfully.", "id" => (int) $contact->id, "url" => DOL_URL_ROOT . "/contact/card.php?id=" . $contact->id ]; } return ["error" => "Failed to create contact: " . (string) $contact->error]; } }