diff --git a/modules/erp/bestellungen/service.php b/modules/erp/bestellungen/service.php index 3c5a632..51463a9 100644 --- a/modules/erp/bestellungen/service.php +++ b/modules/erp/bestellungen/service.php @@ -374,6 +374,150 @@ function get_sales_order_overview(PDO $pdo, array $filters = []): array ]; } +function get_sales_order_detail(PDO $pdo, int $orderId): ?array +{ + $orderStmt = $pdo->prepare( + "SELECT + so.id, + so.external_ref, + so.order_date, + so.order_source, + so.order_status, + so.payment_status, + so.amount_net, + so.amount_shipping, + so.amount_tax, + so.amount_discount, + so.total_amount, + so.currency, + COALESCE(ad.first_name, '') AS first_name, + COALESCE(ad.last_name, '') AS last_name, + COALESCE(ad.street, '') AS street, + COALESCE(ad.house_number, '') AS house_number, + COALESCE(ad.zip, '') AS zip, + COALESCE(ad.city, '') AS city, + COALESCE(ad.country_name, '') AS country_name, + COALESCE(pm.label, '') AS payment_method, + COALESCE(sm.label, '') AS shipping_method + FROM sales_order so + LEFT JOIN LATERAL ( + SELECT a.first_name, a.last_name, a.street, a.house_number, a.zip, a.city, a.country_name + FROM address a + WHERE a.party_id = so.party_id AND a.type IN ('shipping', 'billing') + ORDER BY CASE WHEN a.type = 'shipping' THEN 0 ELSE 1 END, a.id DESC + LIMIT 1 + ) ad ON TRUE + LEFT JOIN payment_method pm ON pm.id = so.payment_method_id + LEFT JOIN shipping_method sm ON sm.id = so.shipping_method_id + WHERE so.id = :order_id" + ); + $orderStmt->execute([':order_id' => $orderId]); + $order = $orderStmt->fetch(); + if (!is_array($order)) { + return null; + } + + $lineStmt = $pdo->prepare( + "SELECT + sol.id, + sol.line_no, + sol.sellable_item_id, + COALESCE(si.item_code, sol.raw_external_article_number, '') AS item_code, + COALESCE(si.display_name, sol.raw_external_title, '') AS item_name, + COALESCE(sol.raw_external_article_number, '') AS raw_article_number, + COALESCE(sol.raw_external_title, '') AS raw_title, + sol.sku, + sol.qty, + sol.qty_cancelled, + sol.line_status, + sol.unit_price, + sol.line_total, + sic.product_id, + p.name AS product_name, + sic.qty_per_item, + a.id AS allocation_id, + a.qty AS allocated_qty, + a.allocation_status, + sl.lot_number, + sl.status AS lot_status + FROM sales_order_line sol + LEFT JOIN sellable_item si ON si.id = sol.sellable_item_id + LEFT JOIN sellable_item_component sic ON sic.sellable_item_id = sol.sellable_item_id + LEFT JOIN product p ON p.id = sic.product_id + LEFT JOIN sales_order_line_lot_allocation a + ON a.sales_order_line_id = sol.id AND a.product_id = sic.product_id + LEFT JOIN stock_lot sl ON sl.id = a.lot_id + WHERE sol.sales_order_id = :order_id + ORDER BY sol.line_no, p.name NULLS LAST, a.id" + ); + $lineStmt->execute([':order_id' => $orderId]); + + $lines = []; + foreach ($lineStmt->fetchAll() as $row) { + $lineId = (int) $row['id']; + if (!isset($lines[$lineId])) { + $lines[$lineId] = [ + 'id' => $lineId, + 'line_no' => (int) $row['line_no'], + 'item_code' => (string) $row['item_code'], + 'item_name' => (string) $row['item_name'], + 'raw_article_number' => (string) $row['raw_article_number'], + 'raw_title' => (string) $row['raw_title'], + 'sku' => (string) ($row['sku'] ?? ''), + 'qty' => (float) $row['qty'], + 'qty_cancelled' => (float) $row['qty_cancelled'], + 'line_status' => (string) $row['line_status'], + 'unit_price' => $row['unit_price'] !== null ? (float) $row['unit_price'] : null, + 'line_total' => $row['line_total'] !== null ? (float) $row['line_total'] : null, + 'product_allocations' => [], + ]; + } + + if ($row['product_id'] !== null) { + $lines[$lineId]['product_allocations'][] = [ + 'product_id' => (int) $row['product_id'], + 'product_name' => (string) $row['product_name'], + 'requested_qty' => (float) $row['qty'] * (float) $row['qty_per_item'], + 'allocated_qty' => $row['allocated_qty'] !== null ? (float) $row['allocated_qty'] : null, + 'allocation_status' => $row['allocation_status'] !== null ? (string) $row['allocation_status'] : null, + 'lot_number' => $row['lot_number'] !== null ? (string) $row['lot_number'] : null, + 'lot_status' => $row['lot_status'] !== null ? (string) $row['lot_status'] : null, + ]; + } + } + + $detail = [ + 'id' => (int) $order['id'], + 'external_ref' => (string) $order['external_ref'], + 'order_date' => (string) $order['order_date'], + 'order_source' => (string) $order['order_source'], + 'order_status' => (string) $order['order_status'], + 'payment_status' => (string) $order['payment_status'], + 'payment_method' => (string) $order['payment_method'], + 'shipping_method' => (string) $order['shipping_method'], + 'customer' => [ + 'first_name' => (string) $order['first_name'], + 'last_name' => (string) $order['last_name'], + 'street' => (string) $order['street'], + 'house_number' => (string) $order['house_number'], + 'zip' => (string) $order['zip'], + 'city' => (string) $order['city'], + 'country_name' => (string) $order['country_name'], + ], + 'amounts' => [ + 'net' => $order['amount_net'] !== null ? (float) $order['amount_net'] : null, + 'shipping' => $order['amount_shipping'] !== null ? (float) $order['amount_shipping'] : null, + 'tax' => $order['amount_tax'] !== null ? (float) $order['amount_tax'] : null, + 'discount' => $order['amount_discount'] !== null ? (float) $order['amount_discount'] : null, + 'total' => $order['total_amount'] !== null ? (float) $order['total_amount'] : null, + 'currency' => (string) $order['currency'], + ], + 'lines' => array_values($lines), + ]; + + return $detail; +} + function get_sales_order_realtime_snapshot(PDO $pdo, int $limit = 1000): array { $limit = max(1, min(2000, $limit)); diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index 9264a20..6286ae1 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -172,21 +172,7 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string $html[] = ''; $html[] = ''; $html[] = '
'; - $html[] = '
'; - $html[] = '
'; - $html[] = '
Bestell-Details
'; - $html[] = '
'; - $html[] = '
'; - $html[] = '

Bestellnummer:

'; - $html[] = '

Bestelldatum:

'; - $html[] = '

Lieferadresse:

'; - $html[] = '

'; - $html[] = '

'; - $html[] = '

'; - $html[] = '

Gesamtsumme:

'; - $html[] = '

Detailinhalt wird noch definiert.

'; - $html[] = '
'; - $html[] = '
'; + $html[] = '

Bestelldetails werden geladen …

'; $html[] = '
'; $html[] = ''; @@ -777,6 +763,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " const zip = trigger.dataset.orderZip || '';"; echo " const city = trigger.dataset.orderCity || '';"; echo " const country = trigger.dataset.orderCountry || '';"; + echo " const formatDate = (value) => value ? new Date(value).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' }) : 'Nicht vorhanden';"; echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();"; echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();"; echo " return ["; @@ -792,7 +779,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " '',"; echo " '',"; echo " '',"; - echo " '
',"; + echo " '
',"; echo " '
',"; echo " '

Bestellnummer: ' + escapeHtml(orderNumber) + '

',"; echo " '

Bestelldatum: ' + escapeHtml(orderDate) + '

',"; @@ -803,7 +790,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " '

Gesamtsumme: ' + escapeHtml(total) + '

',"; echo " '

Detailinhalt wird noch definiert.

',"; echo " '
',"; - echo " '
',"; + echo " '
',"; echo " ''"; echo " ].join('');"; echo " };"; @@ -833,30 +820,21 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " return;"; echo " }"; echo " const drawerTitle = drawer.querySelector('[data-order-drawer-title]');"; - echo " const drawerOrderNumber = drawer.querySelector('[data-order-drawer-order-number]');"; - echo " const drawerOrderDate = drawer.querySelector('[data-order-drawer-order-date]');"; - echo " const drawerAddressLine1 = drawer.querySelector('[data-order-drawer-address-line-1]');"; - echo " const drawerAddressLine2 = drawer.querySelector('[data-order-drawer-address-line-2]');"; - echo " const drawerAddressLine3 = drawer.querySelector('[data-order-drawer-address-line-3]');"; - echo " const drawerTotal = drawer.querySelector('[data-order-drawer-total]');"; - echo " const orderNumber = trigger.dataset.orderNumber || '';"; - echo " const orderDate = trigger.dataset.orderDate || '';"; - echo " const total = trigger.dataset.orderTotal || '';"; - echo " const street = trigger.dataset.orderStreet || '';"; - echo " const houseNumber = trigger.dataset.orderHouseNumber || '';"; - echo " const zip = trigger.dataset.orderZip || '';"; - echo " const city = trigger.dataset.orderCity || '';"; - echo " const country = trigger.dataset.orderCountry || '';"; - echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();"; - echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();"; + echo " const cards = drawer.querySelector('[data-order-drawer-cards]');"; + echo " const orderId = trigger.dataset.orderId || '';"; + echo " const formatAmount = (value, currency) => value === null || value === undefined ? 'Nicht vorhanden' : currency + ' ' + Number(value).toFixed(2);"; + echo " const formatDate = (value) => value ? new Date(value).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' }) : 'Nicht vorhanden';"; + echo " const value = (label, content) => '

' + escapeHtml(label) + ': ' + escapeHtml(content || 'Nicht vorhanden') + '

';"; + echo " const renderCards = (order) => {"; + echo " const c = order.customer || {}; const a = order.amounts || {}; const address = [[c.street, c.house_number].filter(Boolean).join(' '), [c.zip, c.city].filter(Boolean).join(' '), c.country_name].filter(Boolean).join('
');"; + echo " const lines = (order.lines || []).map((line) => '

' + escapeHtml(line.item_name) + ' · ' + escapeHtml(line.item_code) + ' · Menge: ' + escapeHtml(String(line.qty)) + '

' + value('Positionsstatus', line.line_status) + value('Einzelpreis', formatAmount(line.unit_price, a.currency || 'CHF')) + value('Positionssumme', formatAmount(line.line_total, a.currency || 'CHF')) + '
').join('');"; + echo " const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '
' + escapeHtml(item.product_name) + ': ' + escapeHtml(item.lot_number || 'Nicht zugeordnet') + ' · ' + escapeHtml(item.allocated_qty === null ? 'Nicht zugeordnet' : String(item.allocated_qty)) + ' · ' + escapeHtml(item.allocation_status || 'Nicht vorhanden') + (item.lot_status === 'virtual_shortage' ? ' · Virtuelle Fehlbestandscharge' : '') + '
')).join('');"; + echo " return '
Bestellübersicht
' + value('Bestellnummer', order.external_ref) + value('Bestelldatum', formatDate(order.order_date)) + value('Verkaufskanal', order.order_source) + value('Bestellstatus', order.order_status) + value('Zahlungsstatus', order.payment_status) + value('Zahlungsart', order.payment_method) + value('Versandart', order.shipping_method) + '

Kunde: ' + escapeHtml([c.first_name, c.last_name].filter(Boolean).join(' ') || 'Nicht vorhanden') + '

Lieferadresse:
' + address + '

' + value('Netto', formatAmount(a.net, a.currency || 'CHF')) + value('Versand', formatAmount(a.shipping, a.currency || 'CHF')) + value('Rabatt', formatAmount(a.discount, a.currency || 'CHF')) + value('Steuer', formatAmount(a.tax, a.currency || 'CHF')) + value('Gesamtsumme', formatAmount(a.total, a.currency || 'CHF')) + '
Bestellte Artikel
' + (lines || '

Keine Artikel vorhanden.

') + '
Chargenzuordnung
' + (allocations || '

Keine Chargenzuordnung vorhanden.

') + '
';"; + echo " };"; echo " setDrawerState(true);"; - echo " if (drawerTitle) { drawerTitle.textContent = orderNumber !== '' ? 'Bestellung ' + orderNumber : 'Bestellung'; }"; - echo " if (drawerOrderNumber) { drawerOrderNumber.textContent = orderNumber; }"; - echo " if (drawerOrderDate) { drawerOrderDate.textContent = orderDate; }"; - echo " if (drawerAddressLine1) { drawerAddressLine1.textContent = streetLine; }"; - echo " if (drawerAddressLine2) { drawerAddressLine2.textContent = cityLine; }"; - echo " if (drawerAddressLine3) { drawerAddressLine3.textContent = country; }"; - echo " if (drawerTotal) { drawerTotal.textContent = total; }"; + echo " if (drawerTitle) { drawerTitle.textContent = trigger.dataset.orderNumber ? 'Bestellung ' + trigger.dataset.orderNumber : 'Bestellung'; }"; + echo " if (cards) { cards.innerHTML = '

Bestelldetails werden geladen …

'; }"; + echo " fetch('/api/bestellungen/detail.php?id=' + encodeURIComponent(orderId), { credentials: 'same-origin' }).then((response) => response.ok ? response.json() : Promise.reject(new Error('Bestelldetails konnten nicht geladen werden.'))).then((payload) => { if (!payload.ok) { throw new Error('Bestelldetails konnten nicht geladen werden.'); } if (cards) { cards.innerHTML = renderCards(payload.order); } }).catch((error) => { if (cards) { cards.innerHTML = '

' + escapeHtml(error.message) + '

'; } });"; echo " contentRoot.querySelectorAll('[data-order-drawer-open]').forEach((button) => {"; echo " button.setAttribute('aria-expanded', String(button === trigger));"; echo " });"; @@ -879,6 +857,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " document.body.appendChild(bestellungenMobileDetailHost);"; echo " document.body.classList.add('sg-bestellungen-mobile-detail-open');"; echo " }"; + echo " fetch('/api/bestellungen/detail.php?id=' + encodeURIComponent(trigger.dataset.orderId || ''), { credentials: 'same-origin' }).then((response) => response.ok ? response.json() : Promise.reject(new Error('Bestelldetails konnten nicht geladen werden.'))).then((payload) => { if (!payload.ok || !bestellungenMobileDetailHost) { throw new Error('Bestelldetails konnten nicht geladen werden.'); } const order = payload.order; const cards = bestellungenMobileDetailHost.querySelector('[data-bestellungen-mobile-cards]'); const lines = (order.lines || []).map((line) => '

' + escapeHtml(line.item_name) + ' · ' + escapeHtml(line.item_code) + ' · Menge: ' + escapeHtml(String(line.qty)) + ' · Status: ' + escapeHtml(line.line_status) + '

').join(''); const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '

' + escapeHtml(item.product_name) + ': ' + escapeHtml(item.lot_number || 'Nicht zugeordnet') + ' · ' + escapeHtml(item.allocation_status || 'Nicht vorhanden') + '

')).join(''); if (cards) { cards.innerHTML = '
Bestellübersicht

Bestellnummer: ' + escapeHtml(order.external_ref) + '

Bestelldatum: ' + escapeHtml(formatDate(order.order_date)) + '

Verkaufskanal: ' + escapeHtml(order.order_source) + '

Status: ' + escapeHtml(order.order_status) + '

Bestellte Artikel
' + (lines || '

Keine Artikel vorhanden.

') + '
Chargenzuordnung
' + (allocations || '

Keine Chargenzuordnung vorhanden.

') + '
'; } }).catch((error) => { const cards = bestellungenMobileDetailHost?.querySelector('[data-bestellungen-mobile-cards]'); if (cards) { cards.innerHTML = '

' + escapeHtml(error.message) + '

'; } });"; echo " bestellungenDetailOpen = true;"; echo " window.history.pushState({ bestellungenView: 'detail', orderId: trigger.dataset.orderId || '' }, '', window.location.href);"; echo " };"; diff --git a/public/api/bestellungen/detail.php b/public/api/bestellungen/detail.php new file mode 100644 index 0000000..08770f9 --- /dev/null +++ b/public/api/bestellungen/detail.php @@ -0,0 +1,34 @@ + false, 'error' => 'Unauthorized']); + exit; +} + +$orderId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT); +if (!is_int($orderId) || $orderId <= 0) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Invalid order id']); + exit; +} + +$detail = get_sales_order_detail($pdo, $orderId); +if ($detail === null) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Order not found']); + exit; +} + +echo json_encode(['ok' => true, 'order' => $detail], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);