* Liefert Detail eines einzelnen Auftrags. * GET /api/orders.php?id=&action=photos * Liefert die Anhang-Bilder/PDFs eines Auftrags. * POST /api/orders.php?id=&action=upload_photo * multipart: file= — legt ein Foto oder Video im Auftragsordner ab * (Fotos jpg/png, AVIF/WebP werden umgewandelt; Videos mp4/m4v/mov/webm seit 1.8.0, * Inhalt geprüft, Name video_…). Optional taken_at= = Aufnahmezeit eines * Videos, wird zum Dateidatum. Zu groß für PHP → 413. * POST /api/orders.php?action=create * JSON-Body: { socid, ref_client, title?, note_private?, date?, date_livraison?, validate? } * - ref_client ("Ihr Zeichen") ist PFLICHT (Geschäftsregel; Dolibarr selbst verlangt es nicht). * - title (Auftragsbeschreibung) optional -> Extrafield options_auftragsbeschreibung. * - date_livraison (Unix-TS) optional -> geplanter Liefertermin (Spalte date_livraison). * - validate (bool) optional -> Auftrag direkt freigeben. Der Auftrag bleibt dabei LEER: * Commande::valid() verlangt keine Position (verifiziert gegen Dolibarr 22.0.2, * KB #355/#989 - nur der Button in commande/card.php ist bei 0 Zeilen gesperrt). * Die Leistungen kommen später aus dem Stundenzettel. * Legt einen Draft-Auftrag an und übernimmt Kunden-Defaults (Zahlungsbedingungen, * Zahlart, Bankkonto, Incoterms, Lieferadresse, Default-Ansprechpartner). * POST /api/orders.php?id=&action=set_beschreibung (1.9.0) * JSON-Body: { text, vorher? } — Auftragsbeschreibung (Extrafield auftragsbeschreibung) ändern. * Eddy: „Sachen, die ich mir merken will, lieber in der Auftragsbeschreibung festhalten." * `vorher` = Stand, den die App angezeigt hat: weicht der Server-Stand ab (im Büro geändert), * antwortet die API mit 409 und dem aktuellen Text statt ihn zu überschreiben. * ⚠ Die Beschreibung steht auch auf dem Stundenzettel-PDF für den Kunden (Eddy wollte es so). */ require_once __DIR__.'/_inc.php'; api_authenticate(); global $db, $user, $conf; require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; $id = (int) ($_GET['id'] ?? 0); $action = $_GET['action'] ?? ''; /* ----- NEUEN AUFTRAG ANLEGEN ----- */ if ($action === 'create' && $_SERVER['REQUEST_METHOD'] === 'POST') { if (!$user->hasRight('commande', 'creer')) api_fail('Keine Berechtigung zum Anlegen von Aufträgen', 403); $in = api_input(); $socid = (int) ($in['socid'] ?? 0); $title = trim((string) ($in['title'] ?? '')); // Auftragsbeschreibung (optional) $ref_client = trim((string) ($in['ref_client'] ?? '')); // "Ihr Zeichen" (Pflicht) $note_private = trim((string) ($in['note_private'] ?? '')); $date = (int) ($in['date'] ?? 0); if ($date <= 0) $date = dol_now(); $date_livraison = (int) ($in['date_livraison'] ?? 0); // geplanter Liefertermin (optional) $do_validate = !empty($in['validate']); // direkt freigeben? if ($socid <= 0) api_fail('socid fehlt'); // Geschäftsregel: "Ihr Zeichen" (ref_client) ist Pflicht. Dolibarr-Core verlangt nur den Kunden. if ($ref_client === '') api_fail('Ihr Zeichen (Kunden-Referenz) ist Pflicht'); // Kundenstamm holen, um Defaults zu ziehen (wie Dolibarr-Web beim Auftrag-Anlegen) $soc = new Societe($db); if ($soc->fetch($socid) <= 0) api_fail('Kunde nicht gefunden', 404); $cmd = new Commande($db); $cmd->socid = $socid; $cmd->date_commande = $date; $cmd->date = $date; $cmd->ref_client = $ref_client; $cmd->note_private = $note_private; if ($date_livraison > 0) $cmd->delivery_date = $date_livraison; // -> Spalte date_livraison // Kunden-Defaults übernehmen if (!empty($soc->cond_reglement_id)) $cmd->cond_reglement_id = (int) $soc->cond_reglement_id; if (!empty($soc->mode_reglement_id)) $cmd->mode_reglement_id = (int) $soc->mode_reglement_id; if (!empty($soc->fk_account)) $cmd->fk_account = (int) $soc->fk_account; if (!empty($soc->fk_incoterms)) $cmd->fk_incoterms = (int) $soc->fk_incoterms; if (!empty($soc->location_incoterms)) $cmd->location_incoterms = $soc->location_incoterms; if (!empty($soc->fk_delivery_address)) $cmd->fk_delivery_address = (int) $soc->fk_delivery_address; // Titel des Auftrags in Extrafield "auftragsbeschreibung" ablegen (das Bericht-Modul nutzt dieses) if ($title !== '') { if (!is_array($cmd->array_options)) $cmd->array_options = array(); $cmd->array_options['options_auftragsbeschreibung'] = $title; } $newid = $cmd->create($user); if ($newid <= 0) api_fail('Anlegen fehlgeschlagen: '.$cmd->error, 500); // Kontakte: keine eigenen. Früher hing hier der ERSTE Kontakt des Kunden als Kundenkontakt (CUSTOMER) an — // oft ein Objekt (Haus), das dann als Empfänger auf dem Auftrag stand (Eddy, 14.10.2026). Die Standard- // adressen des Kunden hängt Dolibarr beim Anlegen selbst an (Trigger ContactRoles), DeliveryInvoiceAddress // ab 1.8.0 lässt davon je Rolle nur einen eindeutigen Standard stehen. /* ----- Optional: Auftrag direkt freigeben ----- * Ohne Positionen. Früher wurde hier eine Std-Lohn-Zeile mit Menge 1 angehängt, weil * angenommen wurde, valid() scheitere am leeren Auftrag. Das stimmt nicht (KB #355/#989, * gegen Dolibarr 22.0.2 verifiziert) und die Zeile hatte nur Nachteile: sie verfälscht * total_ht, taucht als offene Restmenge in der Lieferauflistung auf, steht im Kunden-PDF * und ist nach der Freigabe nicht mehr löschbar. Die Leistungen liefert der Stundenzettel. */ $validated = false; $validate_warning = ''; if ($do_validate) { $cmd->fetch($newid); $vr = $cmd->valid($user); if ($vr > 0) $validated = true; else $validate_warning = 'Freigabe fehlgeschlagen: '.$cmd->error; } // Für identische Response-Shape wie GET ?id= neu laden $cmd->fetch($newid); $cmd->fetch_thirdparty(); if (method_exists($cmd, 'fetch_optionals')) $cmd->fetch_optionals(); api_ok(array( 'order' => array( 'id' => (int) $cmd->id, 'ref' => $cmd->ref, 'ref_client' => $cmd->ref_client, 'date' => $cmd->date_commande, 'date_livraison' => !empty($cmd->delivery_date) ? (int) $cmd->delivery_date : null, 'status' => (int) $cmd->statut, 'validated' => $validated, 'total' => (float) $cmd->total_ttc, 'note_private' => $cmd->note_private, 'auftragsbeschreibung' => $cmd->array_options['options_auftragsbeschreibung'] ?? '', ), 'customer' => array( 'id' => (int) ($cmd->thirdparty->id ?? 0), 'name' => $cmd->thirdparty->name ?? '', 'address' => $cmd->thirdparty->address ?? '', 'zip' => $cmd->thirdparty->zip ?? '', 'town' => $cmd->thirdparty->town ?? '', 'phone' => $cmd->thirdparty->phone ?? '', 'email' => $cmd->thirdparty->email ?? '', ), 'validate_warning' => $validate_warning, )); } /* ----- LISTE ----- */ if (!$id) { // Filter: nur Aufträge des aktuellen Users (oder Admin sieht alle) $where = "c.entity IN (".getEntity('commande').")"; if (empty($user->admin)) { $where .= " AND (c.fk_user_author = ".((int) $user->id) ." OR c.fk_user_valid = ".((int) $user->id) ." OR c.fk_user_modif = ".((int) $user->id).")"; } // Optional: nur offene if (!empty($_GET['open'])) { $where .= " AND c.fk_statut IN (1, 2)"; // validiert + in Bearbeitung } // Suchterm if (!empty($_GET['q'])) { $q = $db->escape($_GET['q']); $where .= " AND (c.ref LIKE '%$q%' OR s.nom LIKE '%$q%')"; } $sql = "SELECT c.rowid, c.ref, c.ref_client, c.date_commande, c.fk_statut, c.total_ttc," ." s.rowid AS soc_id, s.nom AS soc_name, s.zip, s.town, s.address," ." (SELECT COUNT(*) FROM ".$db->prefix()."bericht b WHERE b.element_type='order' AND b.fk_element=c.rowid) AS bericht_count" ." FROM ".$db->prefix()."commande c" ." LEFT JOIN ".$db->prefix()."societe s ON s.rowid = c.fk_soc" ." WHERE ".$where ." ORDER BY c.date_commande DESC, c.rowid DESC" ." LIMIT 200"; $r = $db->query($sql); if (!$r) api_fail('DB-Fehler: '.$db->lasterror(), 500); $orders = array(); while ($o = $db->fetch_object($r)) { $orders[] = array( 'id' => (int) $o->rowid, 'ref' => $o->ref, 'ref_client' => $o->ref_client, 'date' => $db->jdate($o->date_commande), 'status'=> (int) $o->fk_statut, 'total' => (float) $o->total_ttc, 'customer' => array( 'id' => (int) $o->soc_id, 'name' => $o->soc_name, 'zip' => $o->zip, 'town' => $o->town, 'address' => $o->address, ), 'bericht_count' => (int) $o->bericht_count, ); } api_ok(array('orders' => $orders, 'count' => count($orders))); } /* ----- DETAIL eines Auftrags ----- */ $cmd = new Commande($db); if ($cmd->fetch($id) <= 0) api_fail('Auftrag nicht gefunden', 404); $cmd->fetch_thirdparty(); if (method_exists($cmd, 'fetch_optionals')) $cmd->fetch_optionals(); if ($action === 'photos') { // Anhänge des Auftrags — verwende glob statt dol_dir_list für korrektes filesize $upload_dir = $conf->commande->multidir_output[$cmd->entity].'/'.dol_sanitizeFileName($cmd->ref); $out = array(); if (is_dir($upload_dir)) { $allFiles = glob($upload_dir.'/*', GLOB_NOSORT); foreach ($allFiles as $filepath) { if (!is_file($filepath)) continue; $name = basename($filepath); // Ignoriere Meta-Dateien, Previews und Hidden Files if (strpos($name, '.meta') !== false || strpos($name, '_preview') !== false || $name[0] === '.') continue; $mtime = (int) @filemtime($filepath); $out[] = array( 'filename' => $name, 'size' => (int) @filesize($filepath), 'mime' => dol_mimetype($name), 'date' => $mtime, // Aufnahmezeit aus EXIF, sonst das Dateidatum. Bei Uploads aus der Galerie // ist die Upload-Zeit die falsche Reihenfolge — auf der Baustelle zählt, // wann das Foto entstanden ist. Eigener Parser, weil der Prod-Container // kein exif-Modul geladen hat (siehe lib/bericht.lib.php). 'taken_at' => (int) bericht_file_taken_at($filepath, $mtime), 'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $filepath), ); } } // Sortiere nach Aufnahmezeit (neuste zuerst) usort($out, function($a, $b) { return $b['taken_at'] <=> $a['taken_at']; }); api_ok(array('photos' => $out, 'count' => count($out))); } if ($action === 'upload_photo' && $_SERVER['REQUEST_METHOD'] === 'POST') { if (!$user->hasRight('bericht', 'write')) api_fail('Schreibrechte fehlen', 403); if (empty($_FILES['file']['tmp_name'])) { // Zu groß für PHP: bei upload_max_filesize meldet $_FILES den Fehler, bei post_max_size // kommt gar nichts an. 413 ist für die App ein dauerhafter Fehler — sonst schickte sie // ein zu großes Video immer wieder über den Mobilfunk. $max = bericht_upload_max_bytes(); $php_fehler = (int) ($_FILES['file']['error'] ?? 0); if (in_array($php_fehler, array(UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE), true) || (empty($_FILES) && $max > 0 && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max)) { api_fail('Datei zu groß — höchstens '.round($max / 1048576).' MB', 413); } api_fail('file fehlt'); } $orig = dol_sanitizeFileName($_FILES['file']['name']); $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION)); $ist_video = in_array($ext, bericht_video_endungen(), true); if ($ist_video) { // Videos aus der Kamera der App, der Galerie oder per Teilen (seit 1.8.0) if (!bericht_ist_video($_FILES['file']['tmp_name'], $ext)) api_fail('Kein gültiges Video'); } else { // AVIF/WebP (Galerie/Teilen aus dem Netz, auch unter „.jpg") vor der Prüfung in JPG/PNG umwandeln require_once __DIR__.'/../lib/bericht_avif.lib.php'; $bild_fehler = bericht_upload_bild_umwandeln($_FILES['file']); if ($bild_fehler !== '') api_fail($bild_fehler); // Datei validieren $orig = dol_sanitizeFileName($_FILES['file']['name']); $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION)); if (!in_array($ext, array('jpg', 'jpeg', 'png'))) api_fail('Dateityp nicht unterstützt'); } // Foto direkt in den Dolibarr-Standard-Auftragsordner speichern (kein Bericht nötig) $upload_dir = $conf->commande->multidir_output[$cmd->entity].'/'.dol_sanitizeFileName($cmd->ref); if (!is_dir($upload_dir)) dol_mkdir($upload_dir); // Liegt dasselbe Foto schon im Ordner? Dann kein zweites Mal ablegen. // Wichtig: trotzdem relpath zurueckgeben — die PWA wertet eine Antwort OHNE relpath // als "nicht bestaetigt" (lib/api.js) und haette das Foto sonst ewig in der Queue. $dup = bericht_find_identical_file($upload_dir, $_FILES['file']['tmp_name']); if ($dup !== null) { $existing = $upload_dir.'/'.$dup; api_ok(array( 'filename' => $dup, 'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $existing), 'size' => filesize($existing), 'duplicate' => true, )); } $filename = ($ist_video ? 'video_' : 'foto_').dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'_'.uniqid().'.'.$ext; $target = $upload_dir.'/'.$filename; if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) api_fail('Upload fehlgeschlagen', 500); // Videos tragen keine EXIF-Zeit; die Liste sortiert sie nach dem Dateidatum. Im Funkloch // gefilmt und Stunden später gesendet, stünde dort die Sendezeit — die App schickt die // Aufnahmezeit mit (Galerie: Dateidatum des Videos), sie wird zum Dateidatum. Nur plausible Werte. if ($ist_video) { $aufgenommen = (int) GETPOST('taken_at', 'int'); if ($aufgenommen > dol_now() - 400 * 86400 && $aufgenommen < dol_now() + 300) { @touch($target, $aufgenommen); clearstatcache(true, $target); } } bericht_als_beilage_indexieren($target, $cmd); // Beilage → nur im Reiter (KB #1641) $relpath = str_replace(DOL_DATA_ROOT.'/', '', $target); api_ok(array( 'filename' => $filename, 'relpath' => $relpath, 'size' => filesize($target), )); } /* ----- AUFTRAGSBESCHREIBUNG ÄNDERN (1.9.0) ----- */ if ($action === 'set_beschreibung' && $_SERVER['REQUEST_METHOD'] === 'POST') { if (!$user->hasRight('commande', 'creer')) api_fail('Keine Berechtigung, Aufträge zu ändern', 403); $in = api_input(); // Zeilenenden vereinheitlichen, Leerzeichen am Ende weg — sonst gilt jeder Handy-Text als „geändert" $norm = function ($t) { return rtrim(str_replace(array("\r\n", "\r"), "\n", (string) $t)); }; $text = $norm($in['text'] ?? ''); if (mb_strlen($text, 'UTF-8') > 20000) api_fail('Die Beschreibung ist zu lang'); $aktuell = $norm($cmd->array_options['options_auftragsbeschreibung'] ?? ''); if (array_key_exists('vorher', $in) && $norm($in['vorher']) !== $aktuell) { api_send(array('error' => 'Die Beschreibung wurde inzwischen geändert — bitte den neuen Stand ansehen.', 'auftragsbeschreibung' => $aktuell), 409); } if ($text === $aktuell) api_ok(array('auftragsbeschreibung' => $aktuell, 'unveraendert' => true)); $cmd->array_options['options_auftragsbeschreibung'] = ($text === '') ? null : $text; // Wie „Zusatzfelder ändern" auf der Auftragskarte: Trigger ORDER_MODIFY (Ereignis, Benachrichtigungen) if ($cmd->updateExtraField('auftragsbeschreibung', 'ORDER_MODIFY', $user) < 0) { api_fail('Speichern fehlgeschlagen: '.($cmd->error ?: $db->lasterror()), 500); } api_ok(array('auftragsbeschreibung' => $text)); } // Default: Auftrags-Detail $berichte = Bericht::fetchAllForElement($db, 'order', $cmd->id); $berichte_out = array(); foreach ($berichte as $b) { $berichte_out[] = array( 'id' => (int) $b->id, 'ref' => $b->ref, 'titel' => $b->titel, 'status' => (int) $b->status, 'datec' => (int) $b->datec, ); } api_ok(array( 'order' => array( 'id' => (int) $cmd->id, 'ref' => $cmd->ref, 'date' => $cmd->date_commande, 'status'=> (int) $cmd->statut, 'total' => (float) $cmd->total_ttc, 'note_private' => $cmd->note_private, 'auftragsbeschreibung' => $cmd->array_options['options_auftragsbeschreibung'] ?? '', ), 'customer' => array( 'id' => (int) ($cmd->thirdparty->id ?? 0), 'name' => $cmd->thirdparty->name ?? '', 'address' => $cmd->thirdparty->address ?? '', 'zip' => $cmd->thirdparty->zip ?? '', 'town' => $cmd->thirdparty->town ?? '', 'phone' => $cmd->thirdparty->phone ?? '', 'email' => $cmd->thirdparty->email ?? '', ), 'berichte' => $berichte_out, ));