dolibarr/htdocs/ai/tools/categories.class.php
MDW 7cc8ae1efb
Qual: Fix 'SqlInjection' notices (#39221)
# Qual: Fix 'SqlInjection' notices

Batch of 25 files with changes to fix 'SqlInjection' Notices
2026-07-20 02:51:11 +02:00

800 lines
26 KiB
PHP

<?php
/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2026 Nick Fragoulis
* Copyright (C) 2026 MDW <mdeweerd@users.noreply.github.com>
*
* 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/categories.class.php
* \ingroup ai
* \brief MCP Server tool for Dolibarr categories.
*/
require_once DOL_DOCUMENT_ROOT . '/categories/class/categorie.class.php';
/**
* Class ToolCategories
*
* Provides various tools related to Dolibarr categories.
*/
class ToolCategories 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;
}
/**
* @var array<string,int>|null Cached category type map (scope string => integer type ID)
*/
private $categoryTypeMapCache = null;
/**
* @var array<int,string>|null Cached reverse type map (integer type ID => scope string)
*/
private $reverseTypeMapCache = null;
/**
* Get mapping of scope strings to integer type IDs (as stored in database).
* Uses the Categorie class MAP_ID to ensure consistency, including hook-extended types.
*
* @return array<string, int> Associative array mapping scope names to integer type IDs
*/
private function getCategoryTypeMap()
{
if ($this->categoryTypeMapCache !== null) {
return $this->categoryTypeMapCache;
}
$tmpCat = new Categorie($this->db);
$this->categoryTypeMapCache = $tmpCat->MAP_ID;
return $this->categoryTypeMapCache;
}
/**
* Get reverse mapping of integer type IDs to scope strings
*
* @return array<int, string> Associative array mapping integer type IDs to scope names
*/
private function getReverseTypeMap()
{
if ($this->reverseTypeMapCache !== null) {
return $this->reverseTypeMapCache;
}
$this->reverseTypeMapCache = array_flip($this->getCategoryTypeMap());
return $this->reverseTypeMapCache;
}
/**
* Get list of valid category scopes
*
* @return string[] Array of valid scope strings
*/
private function getValidScopes()
{
return array_keys($this->getCategoryTypeMap());
}
/**
* Get scope string from category type (integer ID as stored in database)
*
* @param int $type Category type ID (integer from database)
* @return string|null Scope string or null if not found
*/
private function getScopeFromType($type)
{
$reverseMap = $this->getReverseTypeMap();
return $reverseMap[$type] ?? null;
}
/**
* Check if user has create permission on categories
*
* @param int $category_type Category type ID (integer)
* @return bool
*/
private function hasCategoryCreatePermission($category_type)
{
// Get the scope string for permission checks
$scope = $this->getScopeFromType($category_type);
if ($scope === null) {
return false;
}
if ($this->user->hasRight('categorie', 'creer')) {
return true;
}
// Type-specific permissions based on scope
switch ($scope) {
case 'product':
case 'service':
return (bool) $this->user->hasRight('produit', 'creer');
case 'supplier':
case 'customer':
return (bool) $this->user->hasRight('societe', 'creer');
case 'member':
return (bool) $this->user->hasRight('adherent', 'creer');
case 'contact':
return (bool) $this->user->hasRight('societe', 'contact', 'creer');
case 'user':
return (bool) $this->user->hasRight('user', 'user', 'creer');
case 'project':
return (bool) $this->user->hasRight('projet', 'creer');
case 'ticket':
return (bool) $this->user->hasRight('ticket', 'write');
case 'website_page':
return (bool) $this->user->hasRight('website', 'write');
case 'knowledgemanagement':
return (bool) $this->user->hasRight('knowledgemanagement', 'knowledgerecord', 'write');
case 'fichinter':
return (bool) $this->user->hasRight('fichinter', 'creer');
case 'order':
return (bool) $this->user->hasRight('commande', 'creer');
case 'invoice':
return (bool) $this->user->hasRight('facture', 'creer');
case 'supplier_order':
return (bool) $this->user->hasRight('supplier_order', 'creer');
case 'supplier_invoice':
return (bool) $this->user->hasRight('supplier_invoice', 'creer');
case 'supplier_proposal':
return (bool) $this->user->hasRight('supplier_proposal', 'creer');
case 'propal':
return (bool) $this->user->hasRight('propal', 'creer');
case 'project_task':
return (bool) $this->user->hasRight('projet', 'creer');
case 'mo':
return (bool) $this->user->hasRight('mrp', 'creer');
default:
return false;
}
}
/**
* Returns an array of tool definitions, including name, description, and input schema.
*
* @return list<array<string, mixed>> Array of tool definitions.
*/
public function getDefinitions(): array
{
$validScopes = $this->getValidScopes();
return [
[
"name" => "search_categories",
"description" => "Search for categories by name or description. Returns a list of matching categories with their IDs, labels, types, and direct links. In Dolibarr, each category has a single type (scope) that determines what kind of objects it can be applied to (e.g., 'product', 'ticket', 'customer').",
"inputSchema" => [
"type" => "object",
"properties" => [
"query" => [
"type" => "string",
"description" => "Partial category name or description to search for (case-insensitive)."
],
"scope" => [
"type" => "string",
"enum" => $validScopes,
"description" => "Filter by a specific category scope/type (e.g., 'product', 'customer', 'project', 'ticket', 'user')."
],
"limit" => [
"type" => "integer",
"description" => "Maximum number of results to return (default: 20).",
"default" => 20
],
"offset" => [
"type" => "integer",
"description" => "Offset for pagination (default: 0).",
"default" => 0
]
],
"required" => []
]
],
[
"name" => "get_category_details",
"description" => "Retrieve comprehensive details for a specific category including its hierarchy (parent/children), linked objects count, and extrafields. Provide either the unique `category_id` (integer) or the `category_name` (exact label, case-insensitive). If using `category_name` and multiple categories share that name, use `scope` to disambiguate.",
"inputSchema" => [
"type" => "object",
"properties" => [
"category_id" => [
"type" => "integer",
"description" => "The unique numerical identifier of the category."
],
"category_name" => [
"type" => "string",
"description" => "The exact label (name) of the category (case-insensitive)."
],
"scope" => [
"type" => "string",
"enum" => $validScopes,
"description" => "Optional: Filter by scope if `category_name` matches multiple categories with different types."
]
],
"oneOf" => [
["required" => ["category_id"]],
["required" => ["category_name"]]
]
]
],
[
"name" => "create_category",
"description" => "Creates a new category in Dolibarr. Requires a 'label' (the category name) and a 'scope' (what type of object it applies to). Example: to create a category 'Urgent' for tickets, use label='Urgent' and scope='ticket'.",
"inputSchema" => [
"type" => "object",
"properties" => [
"label" => [
"type" => "string",
"description" => "The name of the category to create. (Required)."
],
"scope" => [
"type" => "string",
"enum" => $validScopes,
"description" => "The type of objects this category applies to. Examples: 'product' for items, 'invoice' for customer bills, 'supplier' for vendors, 'ticket' for support tickets, 'project' for projects. (Required)."
],
"description" => [
"type" => "string",
"description" => "An optional detailed description for the category."
],
"parent_category_id" => [
"type" => "integer",
"description" => "Optional: The ID of an existing parent category (must be same scope)."
],
"color" => [
"type" => "string",
"description" => "Optional: Hex color code (e.g., '#FF5733')."
]
],
"required" => ["label", "scope"]
]
]
];
}
/**
* 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 ['category', 'stock', 'thirdparty', 'project'];
}
/**
* 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). Only SQL safe arguments!
* @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_categories':
return $this->searchCategories($args);
case 'get_category_details':
return $this->getCategoryDetails($args);
case 'create_category':
return $this->createCategory($args);
default:
return ["error" => "Tool function '$name' not found."];
}
}
/**
* Internal helper to resolve a category ID from its name (label) and optional scope.
* Returns category ID on success, or an error array.
*
* @param string $category_name The label to search for.
* @param string|null $scope Optional category scope (e.g., 'product', 'ticket').
* @return int|array<string, mixed> Category ID or an error array.
*/
private function resolveCategoryIdFromName($category_name, $scope = null)
{
if (
!$this->user->hasRight('categorie', 'lire')
&& !$this->user->hasRight('produit', 'lire')
&& !$this->user->hasRight('societe', 'lire')
) {
return ["error" => "Permission Denied: User does not have read access to categories."];
}
$search_term = trim($category_name);
$cat_type_map = $this->getCategoryTypeMap();
$search_lower = strtolower($search_term);
$sql = "SELECT c.rowid, c.label, c.type";
$sql .= " FROM " . MAIN_DB_PREFIX . "categorie as c";
$sql .= " WHERE c.entity IN (" . getEntity('category') . ")";
$sql .= " AND LOWER(c.label) = '" . $this->db->escape($search_lower) . "'";
if (!empty($scope)) {
if (!isset($cat_type_map[$scope])) {
return ["error" => "Invalid category scope '{$scope}' provided for lookup. Valid scopes: " . implode(', ', array_keys($cat_type_map))];
}
$sql .= " AND c.type = " . ((int) $cat_type_map[$scope]);
}
$sql .= " LIMIT 2";
dol_syslog("ToolCategories::resolveCategoryIdFromName SQL: " . $sql, LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
return ["error" => "Database error during category ID lookup for '{$category_name}': " . $this->db->lasterror()];
}
$num_rows = $this->db->num_rows($resql);
if ($num_rows == 1) {
$obj = $this->db->fetch_object($resql);
$this->db->free($resql);
return (int) $obj->rowid;
} elseif ($num_rows > 1) {
$results = [];
while ($obj = $this->db->fetch_object($resql)) {
$results[] = [
"id" => (int) $obj->rowid,
"label" => $obj->label,
"scope" => $this->getScopeFromType((int) $obj->type) ?? "unknown",
"url" => dol_buildpath('/categories/viewcat.php', 1) . "?id=" . $obj->rowid . "&type=" . $obj->type
];
}
$this->db->free($resql);
return [
"error" => "Multiple categories found with the label '{$category_name}'. Please specify a 'scope' to narrow down the result.",
"matches" => $results
];
} else {
$this->db->free($resql);
// Try to find partial matches to help the user
$hint_sql = "SELECT c.rowid, c.label, c.type";
$hint_sql .= " FROM " . MAIN_DB_PREFIX . "categorie as c";
$hint_sql .= " WHERE c.entity IN (" . getEntity('category') . ")";
$hint_sql .= " AND LOWER(c.label) LIKE '%" . $this->db->escape($search_lower) . "%'";
$hint_sql .= " LIMIT 5";
$hint_resql = $this->db->query($hint_sql);
$hints = [];
if ($hint_resql) {
while ($hint_obj = $this->db->fetch_object($hint_resql)) {
$hints[] = [
"id" => (int) $hint_obj->rowid,
"label" => $hint_obj->label,
"scope" => $this->getScopeFromType((int) $hint_obj->type) ?? "unknown"
];
}
$this->db->free($hint_resql);
}
$error_msg = "Category with exact label '{$category_name}' not found.";
if (!empty($hints)) {
$error_msg .= " Did you mean one of these? " . json_encode($hints);
}
$error_msg .= " Use 'search_categories' with a partial name to find similar categories.";
return ["error" => $error_msg];
}
}
/**
* Searches for categories based on a query and type.
*
* Note: Only call with sql safe parameters
*
* @param array<string, mixed> $args Array containing 'query' (string), 'scope' (string), 'limit' (int), 'offset' (int).
* @return array{error:string}|array{count:int}|list<array<string, mixed>> A list of found categories or an error array.
*/
private function searchCategories($args)
{
if (
!$this->user->hasRight('categorie', 'lire')
&& !$this->user->hasRight('produit', 'lire')
&& !$this->user->hasRight('societe', 'lire')
) {
return [[
"error" => "Permission Denied: User does not have read access to categories."
]];
}
$query = !empty($args['query']) ? trim($args['query']) : '';
$scope_filter = !empty($args['scope']) ? $args['scope'] : '';
$limit = isset($args['limit']) ? max(1, min(100, (int) $args['limit'])) : 20;
$offset = isset($args['offset']) ? max(0, (int) $args['offset']) : 0;
// 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"];
}
$cat_type_map = $this->getCategoryTypeMap();
$sql = "SELECT c.rowid, c.label, c.description, c.type, c.color, c.fk_parent";
$sql .= " FROM " . MAIN_DB_PREFIX . "categorie as c";
$sql .= " WHERE c.entity IN (" . getEntity('category') . ")";
if (!empty($query)) {
$sqlSearchText = $this->db->escape(strtolower($query));
$sql .= " AND (LOWER(c.label) LIKE '%" . $sqlSearchText . "%' OR LOWER(c.description) LIKE '%" . $sqlSearchText . "%')";
}
if (!empty($scope_filter)) {
if (!isset($cat_type_map[$scope_filter])) {
return [[
"error" => "Invalid category scope '{$scope_filter}'. Valid scopes: " . implode(', ', array_keys($cat_type_map))
]];
}
$sql .= " AND c.type = " . ((int) $cat_type_map[$scope_filter]);
}
$sql .= " ORDER BY c.label ASC";
$sql .= $this->db->plimit($limit, $offset);
dol_syslog("ToolCategories::searchCategories SQL: " . $sql, LOG_DEBUG);
$resql = $this->db->query($sql);
$list = [];
if ($resql) {
$type_labels = [
'product' => 'Product',
'service' => 'Service',
'supplier' => 'Supplier',
'customer' => 'Customer/Prospect',
'member' => 'Member',
'contact' => 'Contact',
'user' => 'User',
'project' => 'Project',
'ticket' => 'Ticket',
'warehouse' => 'Warehouse',
'order' => 'Customer Order',
'invoice' => 'Customer Invoice',
'propal' => 'Proposal',
'supplier_order' => 'Supplier Order',
'supplier_invoice' => 'Supplier Invoice',
'supplier_proposal' => 'Supplier Proposal',
'fichinter' => 'Intervention',
'project_task' => 'Project Task',
'bank_account' => 'Bank Account',
'bank_line' => 'Bank Transaction',
'actioncomm' => 'Agenda Event',
'website_page' => 'Website Page',
'knowledgemanagement' => 'Knowledge Record',
'mo' => 'Manufacturing Order',
];
while ($r = $this->db->fetch_object($resql)) {
$type_id = (int) $r->type;
$scope_str = $this->getScopeFromType($type_id);
$list[] = [
"id" => (int) $r->rowid,
"label" => $r->label,
"description" => $r->description ?? '',
"type" => $type_labels[$scope_str] ?? $scope_str ?? "Unknown",
"color" => $r->color ?? null,
"parent_id" => $r->fk_parent ? (int) $r->fk_parent : null,
"url" => dol_buildpath('/categories/viewcat.php', 1) . "?id=" . (int) $r->rowid . "&type=" . $type_id
];
}
$this->db->free($resql);
}
if (empty($list)) {
$msg = "No categories found";
if (!empty($query)) {
$msg .= " matching '{$query}'";
}
if (!empty($scope_filter)) {
$msg .= " in scope '{$scope_filter}'";
}
return [[
"info" => $msg . "."
]];
}
return $list;
}
/**
* Retrieves comprehensive details for a specific category.
*
* @param array<string, mixed> $args Array containing 'category_id' (int) or 'category_name' (string) and optional 'scope'.
* @return array<int, array<string, mixed>>|array<string, mixed> Category details or an error array.
*/
private function getCategoryDetails($args)
{
if (
!$this->user->hasRight('categorie', 'lire')
&& !$this->user->hasRight('produit', 'lire')
&& !$this->user->hasRight('societe', 'lire')
) {
return ["error" => "Permission Denied: User does not have read access to categories."];
}
$category_id = 0;
if (!empty($args['category_id'])) {
$category_id = (int) $args['category_id'];
if ($category_id <= 0) {
return ["error" => "Invalid category_id provided. Must be a positive integer."];
}
} elseif (!empty($args['category_name'])) {
$resolved_id = $this->resolveCategoryIdFromName($args['category_name'], $args['scope'] ?? null);
if (is_array($resolved_id) && isset($resolved_id['error'])) {
return $resolved_id;
}
$category_id = $resolved_id;
} else {
return ["error" => "Missing 'category_id' or 'category_name' argument. One must be provided to get category details."];
}
$cat = new Categorie($this->db);
$result = $cat->fetch($category_id);
if ($result <= 0) {
return ["error" => "Category with ID {$category_id} not found."];
}
// Get scope from integer type
$type_id = (int) $cat->type;
$scope_str = $this->getScopeFromType($type_id);
// Type labels for human-readable output
$type_labels = [
'product' => 'Product',
'service' => 'Service',
'supplier' => 'Supplier',
'customer' => 'Customer/Prospect',
'member' => 'Member',
'contact' => 'Contact',
'user' => 'User',
'project' => 'Project',
'ticket' => 'Ticket',
'warehouse' => 'Warehouse',
'order' => 'Customer Order',
'invoice' => 'Customer Invoice',
'propal' => 'Proposal',
'supplier_order' => 'Supplier Order',
'supplier_invoice' => 'Supplier Invoice',
'supplier_proposal' => 'Supplier Proposal',
'fichinter' => 'Intervention',
'project_task' => 'Project Task',
'bank_account' => 'Bank Account',
'bank_line' => 'Bank Transaction',
'actioncomm' => 'Agenda Event',
'website_page' => 'Website Page',
'knowledgemanagement' => 'Knowledge Record',
'mo' => 'Manufacturing Order',
];
// Get children categories
$children = [];
$daughters = $cat->get_filles();
if (is_array($daughters)) {
foreach ($daughters as $daughter) {
$children[] = [
"id" => (int) $daughter->id,
"label" => $daughter->label,
"url" => dol_buildpath('/categories/viewcat.php', 1) . "?id=" . $daughter->id . "&type=" . $daughter->type
];
}
}
// Get parent categories (hierarchy)
$parents = [];
$mothers = $cat->get_meres();
if (is_array($mothers)) {
foreach ($mothers as $mother) {
$parents[] = [
"id" => (int) $mother->id,
"label" => $mother->label,
"url" => dol_buildpath('/categories/viewcat.php', 1) . "?id=" . $mother->id . "&type=" . $mother->type
];
}
}
// Count linked objects based on category type using the scope string
$linked_objects_count = 0;
$linked_objects_summary = "";
if (!empty($scope_str)) {
// Use the scope string (which matches Categorie::TYPE_* constants) for getObjectsInCateg
$objects = $cat->getObjectsInCateg($scope_str, 1); // onlyids=1 for efficiency
$linked_objects_count = is_array($objects) ? count($objects) : 0;
$linked_objects_summary = "{$linked_objects_count} item" . ($linked_objects_count != 1 ? "s" : "") . " tagged with this category";
}
// Load extrafields
$extrafields = [];
if (!empty($cat->array_options) && is_array($cat->array_options)) {
foreach ($cat->array_options as $key => $value) {
if ($value !== null && $value !== '') {
$clean_key = preg_replace('/^options_/', '', $key);
$extrafields[$clean_key] = $value;
}
}
}
// Build full path for hierarchy display
$full_path = $cat->label;
if (!empty($parents)) {
$path_parts = array_reverse($parents);
$path_parts[] = ["label" => $cat->label];
$full_path = implode(' > ', array_map(
/**
* @param array{label:string} $p
* @return string
*/
static function ($p) {
return $p['label'];
},
$path_parts
));
}
$result = [
"id" => (int) $cat->id,
"label" => $cat->label,
"description" => $cat->description,
"scope" => $scope_str ?? "unknown",
"scope_label" => (isset($scope_str) && isset($type_labels[$scope_str])) ? $type_labels[$scope_str] : ($scope_str ?? "Unknown"),
"type_id" => $type_id,
"color" => $cat->color,
"visible" => (int) $cat->visible,
"full_path" => $full_path,
"linked_objects" => [
"count" => $linked_objects_count,
"summary" => $linked_objects_summary
],
"hierarchy" => [
"parent" => !empty($parents) ? $parents[0] : null,
"children_count" => count($children),
"children" => $children
],
"extrafields" => !empty($extrafields) ? $extrafields : null,
"dates" => [
"created" => $cat->date_creation ? dol_print_date($cat->date_creation, 'dayrfc') : null,
"modified" => $cat->date_modification ? dol_print_date($cat->date_modification, 'dayrfc') : null
],
"url" => dol_buildpath('/categories/viewcat.php', 1) . "?id=" . $cat->id . "&type=" . $type_id
];
// Add multilangs if available
if (!empty($cat->multilangs) && is_array($cat->multilangs)) {
$result["translations"] = $cat->multilangs;
}
return $result;
}
/**
* Creates a new category in Dolibarr.
*
* @param array<string, mixed> $args Array containing 'label' (string), 'scope' (string), 'description' (string, optional), 'parent_category_id' (int, optional), 'color' (string, optional).
* @return array<string, mixed> Success message with new category ID or an error array.
*/
private function createCategory($args)
{
$label = trim($args['name'] ?? $args['label'] ?? '');
$scope = trim($args['scope'] ?? '');
$description = trim($args['description'] ?? '');
$parent_category_id = (int) ($args['parent_category_id'] ?? 0);
$color = trim($args['color'] ?? '');
// Required fields
if (empty($label)) {
return ["error" => "Category label is required."];
}
if (empty($scope)) {
return ["error" => "Category scope is required."];
}
$cat_type_map = $this->getCategoryTypeMap();
// Validate scope
if (!isset($cat_type_map[$scope])) {
return ["error" => "Invalid scope '{$scope}' provided. Valid scopes: " . implode(', ', array_keys($cat_type_map))];
}
$category_type = (int) $cat_type_map[$scope];
// Permission check
if (!$this->hasCategoryCreatePermission($category_type)) {
return ["error" => "Permission Denied: User does not have rights to create categories of type '{$scope}'."];
}
$cat = new Categorie($this->db);
$cat->label = $label;
$cat->description = $description;
$cat->type = (string) $category_type;
$cat->visible = 1;
// Set color if provided (remove # prefix if present for storage)
if (!empty($color)) {
$cat->color = ltrim($color, '#');
}
// Handle parent category
if ($parent_category_id > 0) {
$parent_cat = new Categorie($this->db);
$parent_fetch_result = $parent_cat->fetch($parent_category_id);
if ($parent_fetch_result <= 0) {
return ["error" => "Parent category with ID {$parent_category_id} not found."];
}
// Validate parent category type matches
if ((int) $parent_cat->type != $category_type) {
$parent_scope = $this->getScopeFromType((int) $parent_cat->type) ?? "unknown";
return ["error" => "Cannot create a category with scope '{$scope}' under a parent with scope '{$parent_scope}'. Category types must match."];
}
$cat->fk_parent = $parent_category_id;
}
// Check for duplicate label within same type
$label_lower = strtolower($label);
$existing_sql = "SELECT COUNT(*) as cnt FROM " . MAIN_DB_PREFIX . "categorie";
$existing_sql .= " WHERE LOWER(label) = '" . $this->db->escape($label_lower) . "'";
$existing_sql .= " AND type = " . ((int) $category_type);
$existing_sql .= " AND entity IN (" . getEntity('category') . ")";
$existing_res = $this->db->query($existing_sql);
if ($existing_res) {
$existing_obj = $this->db->fetch_object($existing_res);
if ($existing_obj && $existing_obj->cnt > 0) {
return ["error" => "A category with the label '{$label}' already exists for scope '{$scope}'."];
}
$this->db->free($existing_res);
}
// Create the category
$new_category_id = $cat->create($this->user);
if ($new_category_id > 0) {
return [
"success" => true,
"message" => "Category '{$label}' created successfully.",
"category_id" => (int) $new_category_id,
"label" => $label,
"scope" => $scope,
"type_id" => $category_type,
"description" => $description,
"parent_id" => $parent_category_id > 0 ? $parent_category_id : null,
"color" => $cat->color,
"url" => dol_buildpath('/categories/viewcat.php', 1) . "?id=" . $new_category_id . "&type=" . $category_type
];
} else {
$error_msg = $cat->error ?: ($cat->errors ? implode(', ', $cat->errors) : $this->db->lasterror());
return ["error" => "Failed to create category '{$label}': " . $error_msg];
}
}
}