dolibarr 25.0.0-alpha
crud_objects.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
3 * Copyright (C) 2026 Nick Fragoulis
4 * Copyright (C) 2026 MDW <mdeweerd@users.noreply.github.com>
5 * Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
6 * Copyright (C) 2026 Frédéric France <frederic.france@free.fr>
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License
19 * along with this program. If not, see <https://www.gnu.org/licenses/>.
20 */
21
28require_once DOL_DOCUMENT_ROOT . '/core/lib/company.lib.php';
29require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
30require_once DOL_DOCUMENT_ROOT . '/societe/class/societe.class.php';
31
38{
51 public function __construct(DoliDB $db, $user = null, $conf = null)
52 {
53 $this->db = $db;
54 $this->user = $user;
55 if ($conf !== null) {
56 $this->conf = $conf;
57 }
58 }
59
60
69 private $map = [
70 // --- CUSTOMER OBJECTS ---
71 'proposal' => [
72 'class' => 'Propal',
73 'path' => '/comm/propal/class/propal.class.php',
74 'card' => '/comm/propal/card.php',
75 'date_field' => 'datep', // Propal uses 'datep'
76 'soc_field' => 'socid'
77 ],
78 'order' => [
79 'class' => 'Commande',
80 'path' => '/commande/class/commande.class.php',
81 'card' => '/commande/card.php',
82 'date_field' => 'date_commande', // Commande uses 'date_commande'
83 'soc_field' => 'socid'
84 ],
85 'invoice' => [
86 'class' => 'Facture',
87 'path' => '/compta/facture/class/facture.class.php',
88 'card' => '/compta/facture/card.php',
89 'date_field' => 'date',
90 'soc_field' => 'socid'
91 ],
92 // --- SUPPLIER OBJECTS ---
93 'supplier_proposal' => [
94 'class' => 'SupplierProposal',
95 'path' => '/supplier_proposal/class/supplier_proposal.class.php',
96 'card' => '/supplier_proposal/card.php',
97 'date_field' => 'date', // Uses standard 'date' property for doc date
98 'soc_field' => 'socid'
99 ],
100 'supplier_order' => [
101 'class' => 'CommandeFournisseur',
102 'path' => '/fourn/class/fournisseur.commande.class.php',
103 'card' => '/fourn/commande/card.php',
104 'date_field' => 'date_commande',
105 'soc_field' => 'socid'
106 ],
107 'supplier_invoice' => [
108 'class' => 'FactureFournisseur',
109 'path' => '/fourn/class/fournisseur.facture.class.php',
110 'card' => '/fourn/facture/card.php',
111 'date_field' => 'date',
112 'soc_field' => 'socid'
113 ],
114 // --- LOGISTICS ---
115 'shipment' => [
116 'class' => 'Expedition',
117 'path' => '/expedition/class/expedition.class.php',
118 'card' => '/expedition/card.php',
119 'date_field' => 'date_expedition',
120 'soc_field' => 'socid'
121 ],
122 'reception' => [
123 'class' => 'Reception',
124 'path' => '/reception/class/reception.class.php',
125 'card' => '/reception/card.php',
126 'date_field' => 'date_reception',
127 'soc_field' => 'socid'
128 ],
129 ];
130
143 private const PERM_MAP = [
144 'proposal' => [['propal', 'creer']],
145 'order' => [['commande', 'creer']],
146 'invoice' => [['facture', 'creer']],
147 'supplier_proposal' => [['supplier_proposal', 'creer']],
148 'supplier_order' => [['fournisseur', 'commande', 'creer'], ['supplier_order', 'creer']],
149 'supplier_invoice' => [['fournisseur', 'facture', 'creer'], ['supplier_invoice', 'creer']],
150 'shipment' => [['expedition', 'creer']],
151 'reception' => [['reception', 'creer']],
152 ];
153
161 private const DELETE_PERM_MAP = [
162 'proposal' => [['propal', 'supprimer']],
163 'order' => [['commande', 'supprimer']],
164 'invoice' => [['facture', 'supprimer']],
165 'supplier_proposal' => [['supplier_proposal', 'supprimer']],
166 'supplier_order' => [['fournisseur', 'commande', 'supprimer'], ['supplier_order', 'supprimer']],
167 'supplier_invoice' => [['fournisseur', 'facture', 'supprimer'], ['supplier_invoice', 'supprimer']],
168 'shipment' => [['expedition', 'supprimer']],
169 'reception' => [['reception', 'supprimer']],
170 ];
171
183 private const ACCESS_MAP = [
184 'proposal' => ['feature' => 'propal', 'tableandshare' => 'propal'],
185 'order' => ['feature' => 'commande', 'tableandshare' => 'commande'],
186 'invoice' => ['feature' => 'facture', 'tableandshare' => 'facture'],
187 'supplier_proposal' => ['feature' => 'supplier_proposal', 'tableandshare' => 'supplier_proposal'],
188 'supplier_order' => ['feature' => 'fournisseur', 'tableandshare' => 'commande_fournisseur'],
189 'supplier_invoice' => ['feature' => 'fournisseur', 'tableandshare' => 'facture_fourn'],
190 'shipment' => ['feature' => 'expedition', 'tableandshare' => 'expedition'],
191 'reception' => ['feature' => 'reception', 'tableandshare' => 'reception'],
192 ];
193
209 private const ALLOWED_HEADER_FIELDS = [
210 'socid',
211 'note',
212 'note_public',
213 'note_private',
214 'duree_validite',
215 'ref_supplier',
216 'create_missing_products', // behavior flag, filtered out before property assignment
217 'products_category', // behavior flag (category label for created products), filtered out too
218 // Date fields, generic name and per object type name (see $this->map)
219 'date',
220 'datep',
221 'date_commande',
222 ];
223
229 public function getDefinitions(): array
230 {
231 return [
232 // Order tool
233 [
234 "name" => "create_sales_order",
235 "description" => "Create a CUSTOMER SALES ORDER. This is specifically for creating ORDERS that customers place with you. USE THIS TOOL whenever user mentions: 'create', 'new' or 'add' with 'order', 'customer order' or 'sales order'. This is NOT for invoices or supplier orders. Examples of when to use this tool:
236- 'create order for customer X'
237- 'new order for Y'
238- 'add order from customer Z'
239- 'add order for X with 5 items'
240If user says 'order' without any qualifier, they mean a SALES ORDER - use this tool.",
241 "inputSchema" => [
242 "type" => "object",
243 "properties" => [
244 "socid" => [
245 "type" => "integer",
246 "description" => "Customer ID (Thirdparty ID) - REQUIRED"
247 ],
248 "date_commande" => [
249 "type" => "string",
250 "description" => "Order date (YYYY-MM-DD format, optional, defaults to today)"
251 ],
252 "note" => [
253 "type" => "string",
254 "description" => "Order notes (optional)"
255 ],
256 "lines" => [
257 "type" => "array",
258 "description" => "Products being ordered by the customer",
259 "items" => [
260 "type" => "object",
261 "properties" => [
262 "product_id" => ["type" => "integer", "default" => 0, "description" => "Product ID (0 if not found)"],
263 "product_ref" => ["type" => "string", "description" => "Product reference/SKU as written on the source document (preferred when the numeric product_id is unknown; matched against catalog refs, barcodes and labels)"],
264 "barcode" => ["type" => "string", "description" => "Product barcode (EAN13/UPC) as written on the source document; used to enrich a product created on the fly"],
265 "description" => ["type" => "string", "description" => "Product name or description"],
266 "quantity" => ["type" => "number", "default" => 1, "description" => "Quantity ordered"],
267 "unit_price" => ["type" => "number", "description" => "Unit selling price. OMIT this field to take the price from the product catalog; send 0 only for a deliberately free line."],
268 "vat_rate" => ["type" => "number", "description" => "VAT rate (optional, auto-calculated if not provided)"]
269 ],
270 "required" => ["quantity"]
271 ]
272 ]
273 ],
274 "required" => ["socid"]
275 ]
276 ],
277 // Invoice tool
278 [
279 "name" => "create_customer_invoice",
280 "description" => "Create a customer invoice (bill). Do NOT use this for orders - use create_sales_order instead. Do NOT use this for payments - use pay_invoice instead. Examples: 'create invoice for customer X', 'new bill customer Y'",
281 "inputSchema" => [
282 "type" => "object",
283 "properties" => [
284 "object_type" => [
285 "type" => "string",
286 "enum" => ["invoice"],
287 "default" => "invoice"
288 ],
289 "header" => [
290 "type" => "object",
291 "description" => "Invoice header data. Must include 'socid' (Customer ID).",
292 "properties" => [
293 "socid" => ["type" => "integer", "description" => "Customer ID (Thirdparty ID)"],
294 "date" => ["type" => "string", "description" => "Invoice date (YYYY-MM-DD)"],
295 "note_public" => ["type" => "string", "description" => "Public note"],
296 "note_private" => ["type" => "string", "description" => "Private note"]
297 ],
298 "required" => ["socid"]
299 ],
300 "lines" => [
301 "type" => "array",
302 "description" => "Invoice line items.",
303 "items" => [
304 "type" => "object",
305 "properties" => [
306 "product_id" => ["type" => "integer", "default" => 0],
307 "product_ref" => ["type" => "string", "description" => "Product reference/SKU as written on the source document (preferred when the numeric product_id is unknown; matched against catalog refs, barcodes and labels)"],
308 "barcode" => ["type" => "string", "description" => "Product barcode (EAN13/UPC) as written on the source document; used to enrich a product created on the fly"],
309 "description" => ["type" => "string"],
310 "quantity" => ["type" => "number", "default" => 1],
311 "unit_price" => ["type" => "number", "description" => "Unit selling price. OMIT this field to take the price from the product catalog; send 0 only for a deliberately free line."],
312 "vat_rate" => ["type" => "number"],
313 "fk_unit" => ["type" => "integer"]
314 ],
315 "required" => ["quantity"]
316 ]
317 ]
318 ],
319 "required" => ["header"]
320 ]
321 ],
322 // Generic tool for other documents (excluding order and invoice)
323 [
324 "name" => "create_other_document",
325 "description" => "Create documents other than orders and invoices. Use this for: 'proposal', 'supplier_order', 'supplier_invoice', 'supplier_proposal', 'reception', 'shipment'. DO NOT use for 'order' or 'invoice' - they have dedicated tools.",
326 "inputSchema" => [
327 "type" => "object",
328 "properties" => [
329 "object_type" => [
330 "type" => "string",
331 "enum" => ['proposal', 'supplier_order', 'supplier_invoice', 'supplier_proposal', 'reception', 'shipment'],
332 "description" => "Document type. Cannot be 'order' or 'invoice'. 'reception' and 'shipment' create a standalone document (with no source order) and require the RECEPTION_STANDALONE / SHIPMENT_STANDALONE option to be enabled."
333 ],
334 "header" => [
335 "type" => "object",
336 "description" => "Header data. Must include 'socid'.",
337 "properties" => [
338 "socid" => ["type" => "integer", "description" => "Thirdparty ID (Customer for proposal, Supplier for supplier_*)"],
339 "date" => ["type" => "string", "description" => "Document date (YYYY-MM-DD)"],
340 "duree_validite" => ["type" => "integer", "description" => "Validity in days (proposal only)"],
341 "ref_supplier" => ["type" => "string", "description" => "Supplier's own document reference (delivery note number, supplier order number...) as written on the source document"],
342 "create_missing_products" => ["type" => "boolean", "default" => false, "description" => "When a line's product_ref does not match any catalog product, create the product on the fly (ref=product_ref, label=description, buying price=unit_price) instead of adding a free-text line"],
343 "products_category" => ["type" => "string", "description" => "Optional product category/tag label assigned to every product created on the fly (the category is created when missing), e.g. 'A TRAITER' so the new products can be reviewed as a batch. Only used with create_missing_products."],
344 "note_public" => ["type" => "string", "description" => "Public note"],
345 "note_private" => ["type" => "string", "description" => "Private note"]
346 ],
347 "required" => ["socid"]
348 ],
349 "lines" => [
350 "type" => "array",
351 "description" => "Array of line items.",
352 "items" => [
353 "type" => "object",
354 "properties" => [
355 "product_id" => ["type" => "integer", "default" => 0],
356 "product_ref" => ["type" => "string", "description" => "Product reference/SKU as written on the source document (preferred when the numeric product_id is unknown; matched against catalog refs, barcodes and labels)"],
357 "barcode" => ["type" => "string", "description" => "Product barcode (EAN13/UPC) as written on the source document; used to enrich a product created on the fly"],
358 "description" => ["type" => "string"],
359 "quantity" => ["type" => "number", "default" => 1],
360 "unit_price" => ["type" => "number", "description" => "Unit selling price. OMIT this field to take the price from the product catalog; send 0 only for a deliberately free line."],
361 "vat_rate" => ["type" => "number"],
362 "fk_unit" => ["type" => "integer"]
363 ],
364 "required" => ["quantity"]
365 ]
366 ]
367 ],
368 "required" => ["object_type", "header"]
369 ]
370 ],
371 [
372 "name" => "add_line_item",
373 "description" => "Add a single line to an existing draft document.",
374 "inputSchema" => [
375 "type" => "object",
376 "properties" => [
377 "object_type" => ["type" => "string", "enum" => array_keys($this->map)],
378 "parent_id" => ["type" => "integer"],
379 "product_id" => ["type" => "integer", "default" => 0],
380 "product_ref" => ["type" => "string", "description" => "Product reference/SKU as written on the source document (preferred when the numeric product_id is unknown; matched against catalog refs, barcodes and labels)"],
381 "barcode" => ["type" => "string", "description" => "Product barcode (EAN13/UPC) as written on the source document; used to enrich a product created on the fly"],
382 "description" => ["type" => "string"],
383 "quantity" => ["type" => "number", "default" => 1],
384 "unit_price" => ["type" => "number", "description" => "Unit selling price. OMIT this field to take the price from the product catalog; send 0 only for a deliberately free line."],
385 "vat_rate" => ["type" => "number"]
386 ],
387 "required" => ["object_type", "parent_id", "quantity"]
388 ]
389 ],
390 [
391 "name" => "delete_object",
392 "description" => "Delete a Draft document.",
393 "inputSchema" => [
394 "type" => "object",
395 "properties" => [
396 "object_type" => ["type" => "string", "enum" => array_keys($this->map)],
397 "id" => ["type" => "integer"]
398 ],
399 "required" => ["object_type", "id"]
400 ]
401 ]
402 ];
403 }
404
412 public function getRequiredRights(string $toolName)
413 {
414 return self::RIGHTS_ENFORCED_DOWNSTREAM;
415 }
416
424 public function writeConfirmationPreview(string $toolName, array $args)
425 {
426 global $langs;
427
428 $langs->load("other");
429
430 $header = isset($args['header']) && is_array($args['header']) ? $args['header'] : array();
431 $lines = isset($args['lines']) && is_array($args['lines']) ? $args['lines'] : array();
432 $socid = (int) ($header['socid'] ?? ($args['socid'] ?? 0));
433
434 $who = '';
435 if ($socid > 0) {
436 require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
437 $soc = new Societe($this->db);
438 if ($soc->fetch($socid) > 0) {
439 $who = ' '.$langs->trans("AIPreviewForThirdparty", $soc->name);
440 }
441 }
442
443 // A line may carry no price, in which case the tool takes it from the
444 // catalogue at execution: say so rather than showing nothing, so the
445 // preview never hides the part of the amount the user cannot see.
446 // A line with no unit_price at all is priced from the catalogue at
447 // execution; a line with an explicit 0 is written as zero. Both must be
448 // visible: a hidden zero is the one a user would never have approved.
449 $total = 0.0;
450 $derived = false;
451 $priced = false;
452 foreach ($lines as $line) {
453 if (!isset($line['unit_price']) || $line['unit_price'] === '') {
454 $derived = true;
455 continue;
456 }
457 $priced = true;
458 $total += ((float) ($line['quantity'] ?? 1)) * ((float) $line['unit_price']);
459 }
460
461 $what = $langs->trans(count($lines) === 1 ? "AIPreviewLine" : "AIPreviewLines", (string) count($lines));
462 if ($priced) {
463 $what .= ', '.price($total);
464 if ($derived) {
465 $what = $langs->trans("AIPreviewPlusCatalogue", $what);
466 }
467 } elseif ($derived) {
468 $what .= ', '.$langs->trans("AIPreviewFromCatalogue");
469 }
470
471 switch ($toolName) {
472 case 'create_customer_invoice':
473 return $langs->trans("AIPreviewCreateInvoice", $who, $what);
474 case 'create_sales_order':
475 return $langs->trans("AIPreviewCreateOrder", $who, $what);
476 case 'create_other_document':
477 return $langs->trans("AIPreviewCreateDocument", (string) ($args['object_type'] ?? 'document'), $who, $what);
478 case 'add_line_item':
479 return $langs->trans("AIPreviewAddLine", (string) ($args['object_type'] ?? 'document'), (string) ((int) ($args['parent_id'] ?? 0)));
480 case 'delete_object':
481 return $langs->trans("AIPreviewDelete", (string) ($args['object_type'] ?? 'record'), (string) ((int) ($args['id'] ?? 0)));
482 default:
483 return McpTool::NO_WRITE;
484 }
485 }
486
493 public function getCategories(): array
494 {
495 return ['commercial', 'billing', 'thirdparty'];
496 }
497
505 public function execute(string $name, array $args)
506 {
507 global $langs, $conf, $mysoc;
508
509 // Use the user injected via constructor (McpHandler). Fall back to global $user
510 // when running in a web session context (e.g. AI Assistant) where DI is not used.
511 // is_object() is used instead of empty() because the parent McpTool declares $user
512 // with a non-nullable type hint, which makes PHPStan flag empty($this->user) as
513 // unreachable code.
514 if (!is_object($this->user) || empty($this->user->id)) {
515 global $user;
516 if (is_object($user) && !empty($user->id)) {
517 $this->user = $user;
518 } else {
519 return ["error" => "User not authenticated."];
520 }
521 }
522
523 if (!is_object($mysoc) || empty($mysoc->id)) {
524 $mysoc = new Societe($this->db);
525 $mysoc->setMysoc($conf);
526 }
527
528 $langs->loadLangs(["main", "bills", "companies", "orders", "propal", "products", "supplier_orders", "supplier_proposals", "sendings", "receptions"]);
529
530 try {
531 switch ($name) {
532 case 'create_sales_order':
533 // Direct mapping for order
534 $args['object_type'] = 'order';
535 // Reorganize args to match createDocument format
536 if (!isset($args['header']) && isset($args['socid'])) {
537 $args['header'] = [
538 'socid' => $args['socid'],
539 'date_commande' => $args['date_commande'] ?? null,
540 'note' => $args['note'] ?? null
541 ];
542 unset($args['socid'], $args['date_commande'], $args['note']);
543 }
544 return $this->createDocument($args);
545
546 case 'create_customer_invoice':
547 $args['object_type'] = 'invoice';
548 return $this->createDocument($args);
549
550 case 'create_other_document':
551 // object_type is already set in args
552 return $this->createDocument($args);
553
554 case 'add_line_item':
555 return $this->addLineItem($args);
556
557 case 'delete_object':
558 return $this->deleteObject($args);
559
560 default:
561 return ["error" => "Unknown tool: $name"];
562 }
563 } catch (Exception $e) {
564 return ["error" => "Exception: " . $e->getMessage()];
565 }
566 }
567
579 private function createDocument(array $args)
580 {
581 // Normalize the argument shapes LLMs actually produce. Models emit
582 // top-level socid instead of header.socid and line keys like
583 // ref/qty/price - accept the common aliases instead of failing the
584 // whole create over naming (field-observed with gemini-2.5-flash).
585 if (empty($args['header']) && !empty($args['socid'])) {
586 $args['header'] = ['socid' => (int) $args['socid']];
587 unset($args['socid']);
588 }
589 // Customer given by NAME (models do this when no id is on screen; the
590 // single-shot architecture cannot chain find_customer -> create, so
591 // the create resolves names itself): exact match, then unique LIKE.
592 // Ambiguity returns the candidates instead of guessing.
593 if (empty($args['header']['socid'])) {
594 $custName = '';
595 foreach (array('customer', 'customer_name', 'thirdparty', 'company', 'name') as $ck) {
596 if (!empty($args['header'][$ck]) && is_string($args['header'][$ck])) {
597 $custName = trim($args['header'][$ck]);
598 break;
599 }
600 if (!empty($args[$ck]) && is_string($args[$ck])) {
601 $custName = trim($args[$ck]);
602 break;
603 }
604 }
605 if ($custName !== '' && !is_numeric($custName)) {
606 if (empty($args['header']) || !is_array($args['header'])) {
607 $args['header'] = array();
608 }
609 $sqlc = "SELECT rowid, nom FROM ".MAIN_DB_PREFIX."societe WHERE entity IN (".getEntity('societe').") AND nom = '".$this->db->escape($custName)."'";
610 $resc = $this->db->query($sqlc);
611 if ($resc && $this->db->num_rows($resc) == 1) {
612 $args['header']['socid'] = (int) $this->db->fetch_object($resc)->rowid;
613 } else {
614 $sqlc = "SELECT rowid, nom FROM ".MAIN_DB_PREFIX."societe WHERE entity IN (".getEntity('societe').") AND nom LIKE '%".$this->db->escape($custName)."%' LIMIT 6";
615 $resc = $this->db->query($sqlc);
616 $found = array();
617 while ($resc && ($oc = $this->db->fetch_object($resc))) {
618 $found[$oc->rowid] = $oc->nom;
619 }
620 if (count($found) == 1) {
621 $args['header']['socid'] = (int) array_key_first($found);
622 } elseif (count($found) > 1) {
623 $candidatesTxt = array();
624 foreach ($found as $fid => $fname) {
625 $candidatesTxt[] = $fname." (id ".((int) $fid).")";
626 }
627
628 return ["error" => "Several thirdparties match '".$custName."': ".implode(', ', $candidatesTxt).". Ask the user which one, then retry with that socid."];
629 }
630 }
631 }
632 }
633 if (!empty($args['lines']) && is_array($args['lines'])) {
634 $aliases = ['ref' => 'product_ref', 'product' => 'product_ref', 'sku' => 'product_ref', 'qty' => 'quantity', 'price' => 'unit_price', 'unitprice' => 'unit_price', 'price_ht' => 'unit_price', 'vat' => 'vat_rate'];
635 foreach ($args['lines'] as $k => $line) {
636 if (!is_array($line)) {
637 continue;
638 }
639 foreach ($aliases as $from => $to) {
640 if (isset($line[$from]) && !isset($line[$to])) {
641 $args['lines'][$k][$to] = $line[$from];
642 unset($args['lines'][$k][$from]);
643 }
644 }
645 // 'label' means the PRODUCT NAME when no product is otherwise
646 // identified (resolution degrades to free text on a miss
647 // anyway); it means the line description when one is.
648 if (isset($line['label'])) {
649 $to = (empty($line['product_ref']) && empty($line['product_id'])) ? 'product_ref' : 'description';
650 if (!isset($args['lines'][$k][$to])) {
651 $args['lines'][$k][$to] = $line['label'];
652 }
653 unset($args['lines'][$k]['label']);
654 }
655 }
656 }
657
658 global $conf;
659
660 $type = (string) $args['object_type'];
661
662 // Validate type against map
663 if (! isset($this->map[$type])) {
664 return ["error" => "Configuration not found for object type: " . $type];
665 }
666
667 // Check permissions
668 $permError = $this->checkPermission($type);
669 if ($permError !== null) {
670 return $permError;
671 }
672
673 // Standalone-mode guard: 'reception' and 'shipment' created here have no source order,
674 // so they require the matching standalone option (mirrors the checks in processAddLine()).
675 if ($type === 'reception' && ! getDolGlobalString('RECEPTION_STANDALONE')) {
676 return ["error" => "Reception standalone mode (RECEPTION_STANDALONE) must be enabled to create a reception without a supplier order."];
677 }
678 if ($type === 'shipment' && ! getDolGlobalString('SHIPMENT_STANDALONE')) {
679 return ["error" => "Shipment standalone mode (SHIPMENT_STANDALONE) must be enabled to create a shipment without an order."];
680 }
681
683 $confMap = $this->map[$type];
684
685 if (empty($args['header']) || ! is_array($args['header'])) {
686 return ["error" => "Missing or invalid header for object type: " . $type];
687 }
688
689 // Drop every header property that is not explicitly allowed. The header is produced by
690 // an LLM or by an external MCP client, so an unfiltered assignment would let the caller
691 // overwrite 'id', 'entity', 'ref', 'fk_user_author', 'total_ttc', ... on the object.
692 $header = array_intersect_key($args['header'], array_flip(self::ALLOWED_HEADER_FIELDS));
693
694 $rejectedfields = array_diff(array_keys($args['header']), array_keys($header));
695 if (! empty($rejectedfields)) {
697 '[ToolCrudObjects] Ignored non allowed header fields for ' . $type . ': '
698 . implode(', ', array_map('strval', $rejectedfields)),
699 LOG_WARNING
700 );
701 }
702
703 // A thirdparty is always required to build a document
704 if (empty($header['socid'])) {
705 return ["error" => "Missing socid in header for object type: " . $type];
706 }
707
708 // An external user must not be able to create a document for another thirdparty
709 if ($this->user->socid > 0 && $this->user->socid != (int) $header['socid']) {
711 '[ToolCrudObjects] User id=' . $this->user->id . ' (socid=' . $this->user->socid
712 . ') tried to create a ' . $type . ' for socid=' . ((int) $header['socid']) . '.',
713 LOG_WARNING
714 );
715 return ["error" => "Access denied to this thirdparty."];
716 }
717
718 // The thirdparty must exist: without this check a nonexistent socid
719 // reaches the INSERT and surfaces as a raw SQL foreign-key error in
720 // the chat (observed in the field on #39433).
721 require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
722 $ctrlSoc = new Societe($this->db);
723 if ($ctrlSoc->fetch((int) $header['socid']) <= 0) {
724 return ["error" => "Thirdparty with id ".((int) $header['socid'])." does not exist. Use find/search to resolve the thirdparty first."];
725 }
726
727 // Instantiate the specific Dolibarr class (Propal, Commande, etc.)
728 // We treat it as 'mixed' or generic object here to allow dynamic property assignment
729 $obj = $this->instantiate($type);
730
731 // Process Header with Field Mapping
732 $createMissingProducts = ! empty($header['create_missing_products']);
733 $productsCategory = trim((string) ($header['products_category'] ?? ''));
734 foreach ($header as $k => $v) {
735 $key = (string) $k;
736
737 // Behavior flags, not object properties
738 if ($key === 'create_missing_products' || $key === 'products_category') {
739 continue;
740 }
741
742 // Map 'date' to specific date field (e.g., date_commande)
743 if ($key === 'date' && isset($confMap['date_field'])) {
744 $key = $confMap['date_field'];
745 }
746 // Map 'socid' to specific soc field
747 if ($key === 'socid' && isset($confMap['soc_field'])) {
748 $key = $confMap['soc_field'];
749 $obj->fk_soc = (int) $v; // Standard Dolibarr field for thirdparty linkage
750 }
751
752 // Convert date strings to timestamp if needed
753 if (strpos($key, 'date') !== false && ! is_numeric($v) && is_string($v)) {
754 $timestamp = strtotime($v);
755 if ($timestamp !== false) {
756 $v = $timestamp;
757 }
758 }
759
760 // Assign value dynamically
761 // PHPStan normally dislikes dynamic property access on objects, so we suppress it for this mapper logic
763 $obj->{$key} = $v;
764 }
765
766 // Set Defaults
767 $dateField = $confMap['date_field'] ?? 'date';
768 // Check if date field is empty (property might not exist or be null/0)
769 if (empty($obj->{$dateField})) {
771 $obj->{$dateField} = dol_now();
772 }
773
774 // Specific default for Proposals
775 if ($type === 'proposal' && empty($obj->duree_validite)) {
776 $obj->duree_validite = 15;
777 }
778
779 // Belt and braces: the whitelist above already filters them out, but a new document must
780 // never carry an id nor an entity coming from the caller. setEntity(), called by every
781 // create() method, returns $currentobject->entity as soon as the object has both an id
782 // and an entity, which would create the document inside the submitted entity.
783 $obj->id = 0;
784 $obj->entity = $conf->entity;
785
786 // Attempt Creation
787 $id = $obj->create($this->user);
788
789 if ($id <= 0) {
790 $err = (string) $obj->error;
791 if (! empty($obj->errors)) {
792 $err .= " " . json_encode($obj->errors);
793 }
794 return ["error" => "Creation failed ($type): " . $err];
795 }
796
797 // Process Lines (if provided)
798 $linesAdded = 0;
799 $lineErrors = [];
800
801 if (! empty($args['lines']) && is_array($args['lines'])) {
802 foreach ($args['lines'] as $line) {
803 $line['object_type'] = $type;
804 $line['parent_id'] = $id;
805 if ($createMissingProducts) {
806 $line['create_missing_products'] = true;
807 if ($productsCategory !== '') {
808 $line['products_category'] = $productsCategory;
809 }
810 }
811
812 // Process line addition
813 // Assumes processAddLine returns array{success: bool, error?: string}
814 $res = $this->processAddLine($obj, $line);
815
816 if (! empty($res['success'])) {
817 $linesAdded++;
818 } else {
819 $lineErrors[] = isset($res['error']) ? (string) $res['error'] : 'Unknown line error';
820 }
821 }
822 }
823
824 // Some ::create() implementations (e.g. Reception) write the provisional
825 // reference (PROVxx) to the database but leave $obj->ref empty in memory,
826 // so the caller would show an empty ref. Reload the object to return the
827 // real reference.
828 if (empty($obj->ref) && method_exists($obj, 'fetch')) {
829 $obj->fetch($id);
830 }
831
832 return [
833 "success" => true,
834 "id" => (int) $id,
835 "ref" => (string) $obj->ref,
836 "lines_added" => $linesAdded,
837 "line_errors" => $lineErrors,
838 "url" => DOL_URL_ROOT . $confMap['card'] . "?id=" . $id
839 ];
840 }
841
860 private function processAddLine(CommonObject $object, array $args)
861 {
862 global $mysoc;
863 // Check status (Dolibarr objects usually use 'statut' property, 0 = Draft)
864 if (isset($object->statut) && $object->statut != 0) {
865 return ["success" => false, "error" => "Document is not in draft status"];
866 }
867
868 // Ensure Thirdparty is loaded
869 if (empty($object->thirdparty)) {
870 $object->fetch_thirdparty();
871 }
872
873 // Get company default VAT
874 $companyDefaultVAT = 0.0;
875 if (getDolGlobalString('MAIN_VAT_DEFAULT')) {
876 $companyDefaultVAT = getDolGlobalFloat('MAIN_VAT_DEFAULT');
877 }
878
879 // Normalize Inputs
880 // product_id is advertised by the schema and is what a model sends when it
881 // already resolved the product, so it must be honoured first: taking only
882 // the ref left $prod null and the line was written at price 0.
883 if (!empty($args['product_id'])) {
884 $productIdentifier = (string) ((int) $args['product_id']);
885 } elseif (isset($args['product'])) {
886 $productIdentifier = (string) $args['product'];
887 } elseif (isset($args['product_ref'])) {
888 $productIdentifier = (string) $args['product_ref'];
889 } elseif (isset($args['description'])) {
890 $productIdentifier = (string) $args['description'];
891 } else {
892 $productIdentifier = '';
893 }
894
895 $qtyInput = $args['qty'] ?? $args['quantity'] ?? 1;
896 $qty = (float) $qtyInput;
897
898 $priceInput = isset($args['price']) ? $args['price'] : ($args['unit_price'] ?? null);
899 $price = ($priceInput !== null) ? (float) $priceInput : null;
900
901 $vat = isset($args['vat_rate']) ? (float) $args['vat_rate'] : null;
902 $discount = isset($args['discount']) ? (float) $args['discount'] : 0.0;
903
904 if ($qty <= 0) {
905 return ["success" => false, "error" => "Quantity must be positive."];
906 }
907
908 // Find product
910 $prod = null;
911
912 if ($productIdentifier !== '') {
913 $findResult = $this->findProduct($productIdentifier);
914 if (is_array($findResult) && isset($findResult['error'])) {
915 // Only abort if the caller EXPLICITLY asked for a product (via the 'product'
916 // argument). If they only provided a free-text 'description', we silently
917 // fall through with $prod = null so Dolibarr creates a free-text line item,
918 // which is a perfectly valid Dolibarr feature.
919 // Previous behaviour aborted ALL line creations whose description didn't
920 // match an existing product reference, which broke AI-driven creation of
921 // invoices/orders/proposals from one-off line descriptions.
922 if (isset($args['product'])) {
923 return array_merge(['success' => false], $findResult);
924 }
925 // fall through: $prod stays null
926 }
927 if (is_object($findResult)) {
928 $prod = $findResult;
929 }
930 }
931
932 // Create the product on the fly when explicitly asked to (document-driven
933 // creation, create_missing_products flag): the ref comes from the source
934 // document. Without this, unknown refs end up as free-text lines, which
935 // move no stock on receptions.
936 if ($prod === null && ! empty($args['create_missing_products']) && ! empty($args['product_ref'])
937 && $this->user && $this->user->hasRight('produit', 'creer')) {
938 $newprod = new Product($this->db);
939 $newprod->ref = trim((string) $args['product_ref']);
940 $newprod->label = ! empty($args['description']) ? (string) $args['description'] : $newprod->ref;
941 $newprod->type = Product::TYPE_PRODUCT;
942 $newprod->status = 1;
943 $newprod->status_buy = 1;
944 // Trace AI-created products (convention akin to the scanner module's
945 // SCANyymmdd): lets admins list or purge a whole AI import batch.
946 $newprod->import_key = 'AI'.dol_print_date(dol_now(), '%y%m%d');
947 if (!empty($args['barcode'])) {
948 $newprod->barcode = trim((string) $args['barcode']);
949 // Dolibarr barcode features need the type (e.g. 2=EAN13): use the
950 // instance default when configured.
951 $defbctype = getDolGlobalInt('PRODUIT_DEFAULT_BARCODE_TYPE');
952 if ($defbctype > 0) {
953 $newprod->barcode_type = $defbctype;
954 }
955 }
956 if ($price !== null) {
957 $newprod->cost_price = (float) $price;
958 }
959 if ($newprod->create($this->user) > 0) {
960 // Product::create() does not persist import_key: set it with a
961 // targeted UPDATE right after creation.
962 $this->db->query("UPDATE ".MAIN_DB_PREFIX."product SET import_key='".$this->db->escape($newprod->import_key)."' WHERE rowid=".(int) $newprod->id);
963 $prod = $newprod;
964
965 // Complete the card with what the source document knows beyond
966 // the product itself: supplier price line, review category.
967 $this->completeCreatedProduct($newprod, $object, $args, ($price !== null ? (float) $price : null), ($vat !== null ? (float) $vat : 0.0));
968 }
969 // On failure (duplicate ref, numbering rule...): fall through to a free-text line
970 }
971
972 // Set values based on product or user input
973 if ($price === null) {
974 $price = ($prod && isset($prod->price)) ? (float) $prod->price : 0.0;
975 }
976
977 if ($vat === null) {
978 $vat = ($prod && isset($prod->tva_tx) && $prod->tva_tx !== '') ? (float) $prod->tva_tx : $companyDefaultVAT;
979 }
980
981 // Description — collapse any line breaks / repeated whitespace the LLM may
982 // have carried over from the source document: a multi-line description
983 // renders as extra lines glued to the product name (getNomUrl) in the
984 // object line templates.
985 $userDesc = isset($args['description']) ? trim(preg_replace('/\s+/', ' ', (string) $args['description'])) : '';
986 $desc = '';
987
988 if ($userDesc !== '') {
989 if ($prod && ! empty($prod->label)) {
990 if (strtolower($userDesc) === strtolower($prod->label)) {
991 $desc = $prod->label;
992 } else {
993 $desc = $prod->label . ' - ' . $userDesc;
994 }
995 } else {
996 $desc = $userDesc;
997 }
998 } else {
999 if ($prod && ! empty($prod->label)) {
1000 $desc = $prod->label;
1001 }
1002 }
1003
1004 // Product Unit handling
1005 $fk_unit = 0;
1006 if (getDolGlobalInt('PRODUCT_USE_UNITS') && $prod && ! empty($prod->fk_unit)) {
1007 $fk_unit = (int) $prod->fk_unit;
1008 }
1009
1010 // Add the line
1011 $res = 0;
1012 $docType = (string) $args['object_type'];
1013 $fkProduct = ($prod && isset($prod->id)) ? (int) $prod->id : 0;
1014 $prodType = ($prod && isset($prod->type)) ? (int) $prod->type : 0;
1015
1016 if ($docType === 'invoice') {
1018 $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, '', '', 0, 0, '', 'HT', 0, $prodType, -1, 0, '', 0);
1019 } elseif ($docType === 'order') {
1021 $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 0, 0, 'HT', 0, '', '', $prodType);
1022 } elseif ($docType === 'proposal') {
1024 $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 'HT', 0, 0, $prodType);
1025 } elseif ($docType === 'supplier_invoice') {
1027 // IMPORTANT: FactureFournisseur::addline() does NOT share the same signature
1028 // as Facture::addline(). Its parameter order is:
1029 // ($desc, $pu, $txtva, $txlocaltax1, $txlocaltax2, $qty, $fk_product, $remise_percent, ...)
1030 // i.e. $qty is in position 6, not 3 (unlike customer Facture / Commande / Propal).
1031 // The previous call passed our $qty as $txtva (-> a 1% VAT rate) and our $vat
1032 // as $txlocaltax1, and position 6 ended up being a hardcoded 0 -> a line was
1033 // inserted with qty=0, which Dolibarr silently dropped from the visible totals.
1034 $res = $object->addline($desc, $price, $vat, 0, 0, $qty, $fkProduct, $discount, '', '', 0, 0, 'HT', $prodType);
1035 } elseif ($docType === 'supplier_order') {
1037 // IMPORTANT: CommandeFournisseur::addline() signature is:
1038 // ($desc, $pu_ht, $qty, $txtva, $txlocaltax1, $txlocaltax2, $fk_product,
1039 // $fk_prod_fourn_price, $ref_supplier, $remise_percent, $price_base_type, $pu_ttc, $type, ...)
1040 // The previous call passed $discount at position 8 ($fk_prod_fourn_price) and
1041 // $prodType at position 15 ($notrigger): the discount was ignored (treated as a
1042 // supplier-price rowid) and the product/service type was never set on the line.
1043 $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, 0, '', $discount, 'HT', 0, $prodType);
1044 } elseif ($docType === 'supplier_proposal') {
1046 // SupplierProposal::addline() signature is:
1047 // ($desc, $pu_ht, $qty, $txtva, $txlocaltax1, $txlocaltax2, $fk_product,
1048 // $remise_percent, $price_base_type, $pu_ttc, $info_bits, $type, ...)
1049 // The previous call passed $price_base_type as 0 (instead of 'HT'), 'HT' as
1050 // $info_bits, left $type at 0 and leaked $prodType into $fk_parent_line.
1051 $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 'HT', 0, 0, $prodType);
1052 } elseif ($docType === 'shipment') {
1053 // Shipment Logic
1054 if (! getDolGlobalString('SHIPMENT_STANDALONE')) {
1055 return ["success" => false, "error" => "Shipment standalone mode required to add lines manually."];
1056 }
1057 // addlinefree(qty, type, fk_product, fk_unit, weight, desc, weight_units)
1059 $res = $object->addlinefree($qty, 'shipping', $fkProduct, $fk_unit, 0, $desc, 0);
1060 } elseif ($docType === 'reception') {
1061 // Reception Logic
1062 if (! getDolGlobalString('RECEPTION_STANDALONE')) {
1063 return ["success" => false, "error" => "Reception standalone mode required to add lines manually."];
1064 }
1065 require_once DOL_DOCUMENT_ROOT . '/reception/class/receptionlinebatch.class.php';
1066 // Reception::addlinefree(qty, element_type, fk_product, fk_unit, rang, description, array_options, cost_price, ref_fourn, fk_entrepot, batch)
1067 // A reception line WITHOUT a destination warehouse generates no stock movement on
1068 // validation, so pass the reception's default warehouse (falling back to the company
1069 // default warehouse). array_options must be an array ([]) — passing 0 breaks under PHP 8.
1071 // Resolve a destination warehouse: explicit arg, then the reception's default,
1072 // then the global default, then the first active warehouse.
1073 $recWarehouse = (int) ($args['warehouse_id'] ?? 0);
1074 if ($recWarehouse <= 0) {
1075 // Reception::$fk_warehouse (default warehouse on the reception header) is
1076 // introduced by #39294; until that lands it is an undeclared/dynamic
1077 // property that simply resolves to null here, so we fall through to the
1078 // global default warehouse.
1079 // @phan-suppress-next-line PhanUndeclaredProperty
1080 $recWarehouse = (int) (!empty($object->fk_warehouse) ? $object->fk_warehouse : getDolGlobalInt('MAIN_DEFAULT_WAREHOUSE'));
1081 }
1082 if ($recWarehouse <= 0) {
1083 $resqlw = $this->db->query("SELECT rowid FROM " . MAIN_DB_PREFIX . "entrepot WHERE entity IN (" . getEntity('stock') . ") AND statut = 1 ORDER BY rowid ASC");
1084 if ($resqlw && ($objw = $this->db->fetch_object($resqlw))) {
1085 $recWarehouse = (int) $objw->rowid;
1086 }
1087 }
1088 // Also carry the buying price and the supplier's line reference (both
1089 // introduced on reception lines by #39294; silently ignored before).
1090 $res = $object->addlinefree($qty, 'reception', $fkProduct, $fk_unit, 0, $desc, [], (float) $price, (string) ($args['product_ref'] ?? ''), $recWarehouse);
1091 } else {
1092 return ["success" => false, "error" => "Type $docType not supported for lines"];
1093 }
1094
1095 // Update unit if needed (Logic for standard docs, Shipment/Reception handle units in addlinefree)
1096 // Only trigger updateLineUnit for the standard commercial documents
1097 $commercialDocs = ['invoice', 'order', 'proposal', 'supplier_invoice', 'supplier_order', 'supplier_proposal'];
1098 if (in_array($docType, $commercialDocs, true) && $res > 0 && $fk_unit > 0 && getDolGlobalInt('PRODUCT_USE_UNITS')) {
1099 $this->updateLineUnit($docType, $res, $fk_unit);
1100 }
1101
1102 if ($res > 0) {
1103 return [
1104 "success" => true,
1105 "line_id" => (int) $res,
1106 "debug" => [
1107 "product_identifier" => $productIdentifier,
1108 "final_description" => $desc
1109 ]
1110 ];
1111 }
1112
1113 $errorMsg = isset($object->error) ? (string) $object->error : 'Unknown error adding line';
1114 return ["success" => false, "error" => $errorMsg];
1115 }
1116
1125 private function addLineItem(array $args)
1126 {
1127 $type = (string) $args['object_type'];
1128
1129 // Check Permissions
1130 $permError = $this->checkPermission($type);
1131 if ($permError !== null) {
1132 return [
1133 'success' => false,
1134 'error' => $permError['error'] ?? 'Permission denied'
1135 ];
1136 }
1137
1138 $parentId = (int) $args['parent_id'];
1139
1140 // Instantiate and Fetch the Parent Document
1141 try {
1142 $obj = $this->instantiate($type);
1143 } catch (Exception $e) {
1144 return ["success" => false, "error" => $e->getMessage()];
1145 }
1146
1147 if (! method_exists($obj, 'fetch')) {
1148 return ["success" => false, "error" => "Object does not support fetching"];
1149 }
1150
1151 $result = $obj->fetch($parentId);
1152 if ($result <= 0) {
1153 return ["success" => false, "error" => "Parent document not found with ID: " . $parentId];
1154 }
1155
1156 // fetch() does not filter on the entity, so the parent document must be checked against
1157 // the entity and the thirdparty restrictions of the user before adding a line to it.
1158 $accessError = $this->checkAccessToObject($type, $obj);
1159 if ($accessError !== null) {
1160 return ["success" => false, "error" => $accessError['error']];
1161 }
1162
1163 // Map Schema arguments to Helper arguments
1164 // The helper expects 'product' (which can be an ID or Ref), but schema sends 'product_id'
1165 if (! empty($args['product_id'])) {
1166 $args['product'] = (string) $args['product_id'];
1167 }
1168
1169 // Call the helper logic
1170 // processAddLine(CommonObject $object, array $args)
1171 return $this->processAddLine($obj, $args);
1172 }
1173
1194 private function completeCreatedProduct(Product $newprod, CommonObject $object, array $args, $price, $vat)
1195 {
1196 global $conf;
1197
1198 // Supplier price line - only where the document's thirdparty IS a supplier.
1199 $supplierSideTypes = array('reception', 'supplier_order', 'supplier_invoice', 'supplier_proposal');
1200 // @phan-suppress-next-line PhanUndeclaredProperty -- every business class here carries socid/fk_soc, CommonObject just does not declare them
1201 $socid = ! empty($object->socid) ? (int) $object->socid : (int) (empty($object->fk_soc) ? 0 : $object->fk_soc);
1202 if ($price !== null && $price > 0 && $socid > 0 && in_array((string) $args['object_type'], $supplierSideTypes, true)) {
1203 require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
1204 $pf = new ProductFournisseur($this->db);
1205 $supplier = new Societe($this->db);
1206 if ($pf->fetch($newprod->id) > 0 && $supplier->fetch($socid) > 0) {
1207 $reffourn = trim((string) ($args['product_ref'] ?? ''));
1208 // The supplier price line carries its own barcode: the EAN read on
1209 // THIS supplier's document belongs here. product->barcode (set at
1210 // creation) stays the product's main EAN; a later second supplier
1211 // with a different EAN would get his own on his own price line.
1212 $supplierbarcode = trim((string) ($args['barcode'] ?? ''));
1213 $supplierbarcodetype = ($supplierbarcode !== '') ? getDolGlobalInt('PRODUIT_DEFAULT_BARCODE_TYPE') : 0;
1214 // The multicurrency price must carry the same value (tx=1, company
1215 // currency): with the multicurrency module enabled, update_buyprice()
1216 // recomputes $buyprice from it - left at 0, it would zero the price.
1217 if ($pf->update_buyprice(1, (float) $price, $this->user, 'HT', $supplier, 0, $reffourn, (float) $vat, 0, 0, 0, 0, 0, '', array(), '', (float) $price, 'HT', 1, (string) $conf->currency, '', $supplierbarcode, $supplierbarcodetype) < 0) {
1218 dol_syslog('[ToolCrudObjects] update_buyprice failed for new product '.$newprod->id.': '.$pf->error, LOG_WARNING);
1219 }
1220 }
1221 }
1222
1223 // Optional review category.
1224 $catlabel = trim((string) ($args['products_category'] ?? ''));
1225 if ($catlabel !== '' && isModEnabled('category')) {
1226 require_once DOL_DOCUMENT_ROOT.'/categories/class/categorie.class.php';
1227 $cat = new Categorie($this->db);
1228 if ($cat->fetch(0, $catlabel, Categorie::TYPE_PRODUCT) <= 0) {
1229 if (! $this->user->hasRight('categorie', 'creer')) {
1230 dol_syslog('[ToolCrudObjects] category "'.$catlabel.'" not found and user lacks categorie->creer', LOG_WARNING);
1231 return;
1232 }
1233 $cat = new Categorie($this->db);
1234 $cat->label = $catlabel;
1235 $cat->type = Categorie::TYPE_PRODUCT;
1236 if ($cat->create($this->user) <= 0) {
1237 dol_syslog('[ToolCrudObjects] category creation failed ('.$catlabel.'): '.$cat->error, LOG_WARNING);
1238 return;
1239 }
1240 }
1241 if ($cat->add_type($newprod, Categorie::TYPE_PRODUCT) < 0 && $cat->error != 'DB_ERROR_RECORD_ALREADY_EXISTS') {
1242 dol_syslog('[ToolCrudObjects] category assignment failed for product '.$newprod->id.': '.$cat->error, LOG_WARNING);
1243 }
1244 }
1245 }
1246
1254 private function findProduct($identifier)
1255 {
1256 $product = new Product($this->db);
1257 // Cast strictly to string for string manipulation
1258 $searchString = trim((string) $identifier);
1259
1260 // Regex to handle "id: 123" format
1261 $matches = [];
1262 if (preg_match('/^(?:id)[:\s]+(\d+)$/i', $searchString, $matches)) {
1263 $searchString = $matches[1];
1264 }
1265
1266 // Try to Fetch by ID
1267 if (is_numeric($searchString)) {
1268 if ($product->fetch((int) $searchString) > 0) {
1269 return $product;
1270 }
1271 }
1272
1273 // Try to Fetch by Ref
1274 if ($product->fetch(0, $searchString) > 0) {
1275 return $product;
1276 }
1277
1278 // Custom SQL Search (Barcode, Label, Ref - Exact Match)
1279
1280 $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product";
1281 $sql .= " WHERE (barcode = '" . $this->db->escape($searchString) . "' OR label = '" . $this->db->escape($searchString) . "' OR ref = '" . $this->db->escape($searchString) . "')";
1282 $sql .= " AND entity IN (" . getEntity('product') . ")";
1283 $sql .= " LIMIT 1";
1284
1285 $res = $this->db->query($sql);
1286 if ($res) {
1287 $numRows = $this->db->num_rows($res);
1288 if ($numRows > 0) {
1289 $row = $this->db->fetch_object($res);
1290 if ($row) {
1291 $product->fetch((int) $row->rowid);
1292 $this->db->free($res);
1293 return $product;
1294 }
1295 }
1296 $this->db->free($res);
1297 }
1298
1299 // Loose match (LIKE) if exact match fails
1300 $sql = "SELECT rowid, ref, label FROM " . MAIN_DB_PREFIX . "product";
1301 $sql .= " WHERE (ref LIKE '%" . $this->db->escape($searchString) . "%' OR label LIKE '%" . $this->db->escape($searchString) . "%')";
1302 $sql .= " AND entity IN (" . getEntity('product') . ")";
1303 $sql .= " AND tosell = 1"; // Only fetch products available for sale
1304 $sql .= " LIMIT 5";
1305
1306 $res = $this->db->query($sql);
1307
1308 if ($res) {
1309 $numRows = $this->db->num_rows($res);
1310 if ($numRows > 0) {
1311 // If exactly one match found via loose search, use it
1312 if ($numRows == 1) {
1313 $row = $this->db->fetch_object($res);
1314 if ($row) {
1315 $product->fetch((int) $row->rowid);
1316 $this->db->free($res);
1317 return $product;
1318 }
1319 }
1320
1321 // If multiple matches, return list for ambiguity error
1322 $matchList = [];
1323 while ($row = $this->db->fetch_object($res)) {
1324 $matchList[] = $row->ref . " - " . $row->label;
1325 }
1326 $this->db->free($res);
1327
1328 return [
1329 "error" => "Multiple products found for '" . $searchString . "'",
1330 "matches" => $matchList
1331 ];
1332 }
1333 $this->db->free($res);
1334 }
1335
1336 return ["error" => "Product '" . $searchString . "' not found."];
1337 }
1338
1346 private function deleteObject(array $args): array
1347 {
1348 $type = (string) $args['object_type'];
1349 $id = (int) $args['id'];
1350
1351 // Check permissions. Deletion requires the delete permission, not the write one.
1352 $permError = $this->checkPermission($type, 'delete');
1353 if ($permError !== null) {
1354 return $permError;
1355 }
1356
1357 // Instantiate generic object based on type
1358 $obj = $this->instantiate($type);
1359
1360 // Fetch object
1361 if ($obj->fetch($id) <= 0) {
1362 return ["error" => "Object not found with ID: " . $id];
1363 }
1364
1365 // fetch() does not filter on the entity, so the loaded object must be checked against
1366 // the entity and the thirdparty restrictions of the user before going any further.
1367 $accessError = $this->checkAccessToObject($type, $obj);
1368 if ($accessError !== null) {
1369 return $accessError;
1370 }
1371
1372 // Check Status: Can only delete drafts (statut == 0)
1373 // We use int cast because status might be string '0' in some DB configurations
1374 $status = isset($obj->statut) ? (int) $obj->statut : -1;
1375
1376 if ($status !== 0) {
1377 return ["error" => "Can only delete drafts (status 0). Current status: " . $status];
1378 }
1379
1380 // Perform Deletion
1381 if ($obj->delete($this->user) > 0) {
1382 return ["success" => true];
1383 }
1384
1385 // Capture error message
1386 $errorMsg = ! empty($obj->error) ? (string) $obj->error : 'Unknown error';
1387
1388 return ["error" => "Delete failed: " . $errorMsg];
1389 }
1390
1400 private function checkPermission(string $type, string $mode = 'write'): ?array
1401 {
1402 $map = ($mode === 'delete' ? self::DELETE_PERM_MAP : self::PERM_MAP);
1403
1404 if (! isset($map[$type])) {
1405 return ["error" => "Unknown type for permission check: " . $type];
1406 }
1407
1408 // Holding any of the listed alternatives is enough
1409 foreach ($map[$type] as $perm) {
1410 if ($this->user->hasRight($perm[0], $perm[1], $perm[2] ?? '')) {
1411 return null;
1412 }
1413 }
1414
1415 return ["error" => "Permission denied for action on " . $type];
1416 }
1417
1431 private function checkAccessToObject(string $type, CommonObject $object): ?array
1432 {
1433 if (! isset(self::ACCESS_MAP[$type])) {
1434 return ["error" => "Unknown type for access check: " . $type];
1435 }
1436
1437 $access = self::ACCESS_MAP[$type];
1438
1439 // Pass the object itself (not its id) so restrictedArea() can derive $feature2 from
1440 // $object->element for the objects of the 'fournisseur' module.
1441 $ok = restrictedArea($this->user, $access['feature'], $object, $access['tableandshare'], '', 'fk_soc', 'rowid', 0, 1);
1442
1443 if ($ok <= 0) {
1444 dol_syslog(
1445 '[ToolCrudObjects] Access denied to ' . $type . ' id=' . $object->id
1446 . ' for user id=' . $this->user->id . ' (entity or thirdparty restriction).',
1447 LOG_WARNING
1448 );
1449 return ["error" => "Access denied to this " . $type . "."];
1450 }
1451
1452 return null;
1453 }
1454
1463 private function instantiate(string $type): CommonObject
1464 {
1465 if (! isset($this->map[$type])) {
1466 throw new Exception("Unknown type: " . $type);
1467 }
1468
1469 $config = $this->map[$type];
1470 $path = (string) $config['path'];
1471 $className = (string) $config['class'];
1472
1473 // Include the base class and the specific class file
1474 require_once DOL_DOCUMENT_ROOT . '/core/class/commonobject.class.php';
1475 require_once DOL_DOCUMENT_ROOT . $path;
1476
1477 if (! class_exists($className)) {
1478 throw new Exception("Class '$className' not found for type '$type'");
1479 }
1480
1481 return new $className($this->db);
1482 }
1483
1493 private function updateLineUnit(string $type, int $lineId, int $unitId): void
1494 {
1495 // Map document types to their specific detail tables
1497 $tableMap = [
1498 'invoice' => 'facturedet',
1499 'order' => 'commandedet',
1500 'proposal' => 'propaldet',
1501 'supplier_invoice' => 'facture_fourn_det',
1502 'supplier_order' => 'commande_fournisseurdet',
1503 'supplier_proposal' => 'supplier_proposaldet'
1504 ];
1505
1506 if (! isset($tableMap[$type])) {
1507 return;
1508 }
1509
1510 $table = $tableMap[$type];
1511
1512 $sql = "UPDATE " . MAIN_DB_PREFIX . $this->db->sanitize($table);
1513 $sql .= " SET fk_unit = " . (int) $unitId;
1514 $sql .= " WHERE rowid = " . (int) $lineId;
1515
1516 $resql = $this->db->query($sql);
1517
1518 if (! $resql) {
1519 dol_syslog("Error updating unit for line $lineId: " . $this->db->lasterror(), LOG_ERR);
1520 }
1521 }
1522}
$id
Support class for third parties, contacts, members, users or resources.
Definition account.php:47
if(! $sortfield) if(! $sortorder) $object
Definition account.php:100
$object ref
Definition info.php:90
Class to manage categories.
Class to manage Dolibarr database access.
Abstract base class for all MCP (Model Context Protocol) tools.
const NO_WRITE
Returned by writeConfirmationPreview() when the tool writes nothing.
Class to manage predefined suppliers products.
Class to manage products or services.
const TYPE_PRODUCT
Regular product.
Class to manage third parties objects (customers, suppliers, prospects...)
Tool class for CRUD operations on Dolibarr objects TODO Remove all tools in this file.
checkAccessToObject(string $type, CommonObject $object)
Check that the current user is allowed to work on an already fetched object.
getCategories()
Return categories this tool belongs to.
addLineItem(array $args)
Entry point for the 'add_line_item' tool.
writeConfirmationPreview(string $toolName, array $args)
Preview of the record this call would write.
instantiate(string $type)
Factory Helper to instantiate Dolibarr objects.
checkPermission(string $type, string $mode='write')
Check if the current user has permission for the given object type.
__construct(DoliDB $db, $user=null, $conf=null)
Constructor.
findProduct($identifier)
Find a product by various identifiers (ID, Ref, Barcode, Label).
getDefinitions()
Returns an array of tool definitions, including name, description, and input schema.
execute(string $name, array $args)
Executes the requested tool function based on its name.
completeCreatedProduct(Product $newprod, CommonObject $object, array $args, $price, $vat)
Complete a product just created on the fly (create_missing_products) with the information the source ...
deleteObject(array $args)
Delete a document object.
getRequiredRights(string $toolName)
Per-object-type rights are checked inside this class (PERM_MAP / delete map), including the two permi...
global $mysoc
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
dol_now($mode='gmt')
Return date for now.
getDolGlobalFloat($key, $default=0)
Return a Dolibarr global constant float value.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
isModEnabled($module)
Is Dolibarr module enabled.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
getEntity($element, $shared=1, $currentobject=null)
Get list of entity id to use.
conf($dolibarr_main_document_root, $realpathconf=null)
Load conf file (file must exists)
Definition inc.php:431
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:141
restrictedArea(User $user, $features, $object=0, $tableandshare='', $feature2='', $dbt_keyfield='fk_soc', $dbt_select='rowid', $isdraft=0, $nodie=0, $mode='')
Check permissions of a user to show a page and an object.