prepare("SELECT id FROM public.{$table} WHERE code = :code LIMIT 1"); $stmt->execute([':code' => $code]); $id = $stmt->fetchColumn(); return $id === false ? null : (int) $id; } function map_payment_code(string $input): ?string { $v = strtolower(trim($input)); if ($v === '') { return null; } if (str_contains($v, 'twint')) { return 'twint'; } if (str_contains($v, 'bank') || str_contains($v, 'vorauskasse') || str_contains($v, 'ueberweisung')) { return 'bank_transfer'; } if (str_contains($v, 'kredit') || str_contains($v, 'debit') || str_contains($v, 'card')) { return 'card'; } return null; } function map_shipping_code(string $input): ?string { $v = strtolower(trim($input)); if ($v === '') { return null; } if (str_contains($v, 'abholung') || str_contains($v, 'pickup')) { return 'pickup'; } if (str_contains($v, 'post') || str_contains($v, 'versand')) { return 'post_standard'; } return null; } function ensure_required_tables_exist(PDO $pdo): void { $required = [ 'party', 'address', 'sales_order', 'sales_order_line', 'payment_method', 'shipping_method', ]; $stmt = $pdo->query( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'" ); $rows = $stmt->fetchAll(PDO::FETCH_COLUMN); $existing = array_map('strval', $rows ?: []); $missing = array_values(array_diff($required, $existing)); if ($missing !== []) { throw new RuntimeException('DB schema not initialized. Missing tables: ' . implode(', ', $missing)); } } function find_existing_order_id(PDO $pdo, string $externalRef): ?int { $stmt = $pdo->prepare('SELECT id FROM sales_order WHERE external_ref = :external_ref LIMIT 1'); $stmt->execute([':external_ref' => $externalRef]); $id = $stmt->fetchColumn(); return $id === false ? null : (int) $id; } function upsert_sales_order(PDO $pdo, array $fields): int { $stmt = $pdo->prepare( 'INSERT INTO public.sales_order ( external_ref, party_id, order_source, order_status, payment_status, payment_method_id, shipping_method_id, amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, webhook_payload, imported_at, created_at, updated_at ) VALUES ( :external_ref, :party_id, :order_source, :order_status, :payment_status, :payment_method_id, :shipping_method_id, :amount_net, :amount_shipping, :amount_tax, :amount_discount, :total_amount, :currency, :webhook_payload::jsonb, NOW(), NOW(), NOW() ) ON CONFLICT (external_ref) DO UPDATE SET party_id = EXCLUDED.party_id, order_source = EXCLUDED.order_source, order_status = EXCLUDED.order_status, payment_status = EXCLUDED.payment_status, payment_method_id = EXCLUDED.payment_method_id, shipping_method_id = EXCLUDED.shipping_method_id, amount_net = EXCLUDED.amount_net, amount_shipping = EXCLUDED.amount_shipping, amount_tax = EXCLUDED.amount_tax, amount_discount = EXCLUDED.amount_discount, total_amount = EXCLUDED.total_amount, currency = EXCLUDED.currency, webhook_payload = EXCLUDED.webhook_payload, imported_at = NOW(), updated_at = NOW() RETURNING id' ); $stmt->execute($fields); $id = $stmt->fetchColumn(); if ($id === false) { throw new RuntimeException('Could not upsert order'); } return (int) $id; } function create_direct_sales_order(PDO $pdo, array $fields): array { $orderDate = trim((string) ($fields[':order_date'] ?? '')); if ($orderDate === '') { throw new RuntimeException('Missing order date'); } $sequenceStmt = $pdo->query("SELECT nextval(pg_get_serial_sequence('sales_order', 'id')) AS id"); $sequenceRow = $sequenceStmt !== false ? $sequenceStmt->fetch() : false; if (!is_array($sequenceRow) || !isset($sequenceRow['id'])) { throw new RuntimeException('Could not reserve order id'); } $orderId = (int) $sequenceRow['id']; $externalRef = sprintf( 'DIR-%s-%05d', (new DateTimeImmutable($orderDate))->format('Ymd'), $orderId ); $stmt = $pdo->prepare( 'INSERT INTO sales_order ( id, external_ref, party_id, order_source, order_date, order_status, payment_status, payment_method_id, amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, market_occurrence_id, market_occurrence_day_id, imported_at, created_at, updated_at ) VALUES ( :id, :external_ref, :party_id, \'direct\', :order_date, \'imported\', \'paid\', :payment_method_id, :amount_net, 0, 0, 0, :total_amount, \'CHF\', :market_occurrence_id, :market_occurrence_day_id, NOW(), NOW(), NOW() ) RETURNING id, external_ref' ); $stmt->execute([ ':id' => $orderId, ':external_ref' => $externalRef, ':party_id' => $fields[':party_id'] ?? null, ':order_date' => $orderDate, ':payment_method_id' => $fields[':payment_method_id'] ?? null, ':amount_net' => $fields[':amount_net'] ?? null, ':total_amount' => $fields[':total_amount'] ?? null, ':market_occurrence_id' => $fields[':market_occurrence_id'] ?? null, ':market_occurrence_day_id' => $fields[':market_occurrence_day_id'] ?? null, ]); $row = $stmt->fetch(); if ($row === false) { throw new RuntimeException('Could not create order'); } return [ 'id' => (int) $row['id'], 'external_ref' => (string) $row['external_ref'], ]; } function delete_sales_order_lines(PDO $pdo, int $orderId): void { $deleteLines = $pdo->prepare('DELETE FROM public.sales_order_line WHERE sales_order_id = :sales_order_id'); $deleteLines->execute([':sales_order_id' => $orderId]); } function insert_sales_order_line(PDO $pdo, array $fields): int { $stmt = $pdo->prepare( 'INSERT INTO public.sales_order_line ( sales_order_id, line_no, sellable_item_id, raw_external_article_number, raw_external_title, qty, unit_price, line_total, created_at, updated_at ) VALUES ( :sales_order_id, :line_no, :sellable_item_id, :article_number, :title, :qty, :unit_price, :line_total, NOW(), NOW() ) RETURNING id' ); $stmt->execute($fields); $id = $stmt->fetchColumn(); if ($id === false) { throw new RuntimeException('Could not insert sales_order_line'); } return (int) $id; } function normalize_sales_order_sort_column(string $sortColumn): string { $allowed = [ 'order_date', 'external_ref', 'name', 'total_amount', ]; return in_array($sortColumn, $allowed, true) ? $sortColumn : 'order_date'; } function normalize_sales_order_sort_direction(string $direction, string $sortColumn): string { $direction = strtoupper(trim($direction)); if ($direction !== 'ASC' && $direction !== 'DESC') { return $sortColumn === 'order_date' ? 'DESC' : 'ASC'; } return $direction; } function escape_sales_order_search_term(string $searchTerm): string { return strtr($searchTerm, [ '\\' => '\\\\', '%' => '\\%', '_' => '\\_', ]); } function sales_order_row_matches_search_term(array $row, string $searchTerm): bool { $needle = trim($searchTerm); if ($needle === '') { return true; } $haystacks = [ (string) ($row['external_ref'] ?? ''), (string) ($row['order_date'] ?? ''), (string) ($row['total_amount'] ?? ''), (string) ($row['first_name'] ?? ''), (string) ($row['last_name'] ?? ''), trim((string) ($row['first_name'] ?? '') . ' ' . (string) ($row['last_name'] ?? '')), (string) ($row['street'] ?? ''), (string) ($row['house_number'] ?? ''), (string) ($row['zip'] ?? ''), (string) ($row['city'] ?? ''), (string) ($row['country_name'] ?? ''), ]; foreach ($haystacks as $haystack) { if ($haystack !== '' && stripos($haystack, $needle) !== false) { return true; } } return false; } function fetch_sales_order_overview_rows(PDO $pdo, string $sortColumn, string $sortDirection, ?int $limit = 20): array { $sortExpressions = [ 'order_date' => 'so.order_date', 'external_ref' => 'so.external_ref', 'name' => 'COALESCE(ad.first_name, \'\') || \' \' || COALESCE(ad.last_name, \'\')', 'total_amount' => 'COALESCE(so.total_amount, 0)', ]; $sortExpression = $sortExpressions[$sortColumn] ?? 'so.order_date'; $baseFromSql = <<<'SQL' 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 = 'billing' ORDER BY a.id DESC LIMIT 1 ) ad ON TRUE SQL; $listSql = <<<'SQL' SELECT so.id, so.external_ref, so.order_date, so.total_amount, 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 SQL; $listSql .= "\n" . $baseFromSql; $listSql .= "\nORDER BY {$sortExpression} {$sortDirection}, so.id {$sortDirection}"; if ($limit !== null) { $listSql .= "\nLIMIT :limit"; } $listStmt = $pdo->prepare($listSql); if ($limit !== null) { $listStmt->bindValue(':limit', $limit, PDO::PARAM_INT); } $listStmt->execute(); $rows = []; foreach ($listStmt->fetchAll() as $row) { $rows[] = [ 'id' => (int) $row['id'], 'external_ref' => (string) $row['external_ref'], 'order_date' => (string) $row['order_date'], 'total_amount' => $row['total_amount'] !== null ? (float) $row['total_amount'] : null, 'first_name' => (string) $row['first_name'], 'last_name' => (string) $row['last_name'], 'street' => (string) $row['street'], 'house_number' => (string) $row['house_number'], 'zip' => (string) $row['zip'], 'city' => (string) $row['city'], 'country_name' => (string) $row['country_name'], ]; } return $rows; } function get_sales_order_overview(PDO $pdo, array $filters = []): array { $searchTerm = trim((string) ($filters['search'] ?? '')); $sortColumn = normalize_sales_order_sort_column((string) ($filters['sort_column'] ?? 'order_date')); $sortDirection = normalize_sales_order_sort_direction((string) ($filters['sort_direction'] ?? ''), $sortColumn); $limit = max(1, (int) ($filters['limit'] ?? 20)); $pageSize = max(1, (int) ($filters['page_size'] ?? 20)); if ($searchTerm !== '') { $allRows = fetch_sales_order_overview_rows($pdo, $sortColumn, $sortDirection, null); $matchedRows = array_values(array_filter( $allRows, static fn (array $row): bool => sales_order_row_matches_search_term($row, $searchTerm) )); $totalCount = count($matchedRows); $rows = array_slice($matchedRows, 0, $limit); } else { $countStmt = $pdo->prepare('SELECT COUNT(*) FROM sales_order'); $countStmt->execute(); $totalCount = (int) $countStmt->fetchColumn(); $rows = fetch_sales_order_overview_rows($pdo, $sortColumn, $sortDirection, $limit); } return [ 'rows' => $rows, 'search' => $searchTerm, 'sort_column' => $sortColumn, 'sort_direction' => $sortDirection, 'limit' => $limit, 'page_size' => $pageSize, 'total_count' => $totalCount, 'has_more' => $totalCount > $limit, 'next_limit' => min($totalCount, $limit + $pageSize), ]; } 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, md.name AS market_name, mday.date AS market_occurrence_day_date, 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 LEFT JOIN market_occurrence mo ON mo.id = so.market_occurrence_id LEFT JOIN market_definition md ON md.id = mo.market_definition_id LEFT JOIN market_occurrence_day mday ON mday.id = so.market_occurrence_day_id AND mday.occurrence_id = mo.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'], 'market' => [ 'name' => $order['market_name'] !== null ? (string) $order['market_name'] : null, 'occurrence_day_date' => $order['market_occurrence_day_date'] !== null ? (string) $order['market_occurrence_day_date'] : null, ], '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)); $stmt = $pdo->prepare( 'SELECT id, created_at, updated_at FROM public.sales_order ORDER BY updated_at DESC NULLS LAST, id DESC LIMIT :limit' ); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); $rows = []; foreach ($stmt->fetchAll() as $row) { $rows[] = [ 'id' => (int) ($row['id'] ?? 0), 'created_at' => (string) ($row['created_at'] ?? ''), 'updated_at' => (string) ($row['updated_at'] ?? ''), ]; } return $rows; }