From 6e656e00c5ab55ac6b4bac9725aa49881a0339ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Mon, 15 Jun 2026 10:36:20 +0200 Subject: [PATCH] Split ERP flows into modules --- docs/konzepte/phase1-technisches-konzept.md | 2 +- docs/modules/README.md | 4 + docs/modules/erp/README.md | 5 + docs/modules/erp/artikel-mapping/README.md | 3 + docs/modules/erp/bestellungen/README.md | 3 + docs/modules/erp/kontakte/README.md | 3 + docs/modules/erp/lager/README.md | 3 + modules/erp/artikel-mapping/service.php | 271 +++ modules/erp/bestellungen/service.php | 177 ++ modules/erp/direktverkauf/api/otc-order.php | 537 +----- .../erp/import-integration/order-import.php | 1485 +---------------- modules/erp/import-integration/service.php | 163 ++ modules/erp/kontakte/service.php | 81 + modules/erp/lager/service.php | 334 ++++ 14 files changed, 1109 insertions(+), 1962 deletions(-) create mode 100644 modules/erp/artikel-mapping/service.php create mode 100644 modules/erp/bestellungen/service.php create mode 100644 modules/erp/import-integration/service.php create mode 100644 modules/erp/kontakte/service.php create mode 100644 modules/erp/lager/service.php diff --git a/docs/konzepte/phase1-technisches-konzept.md b/docs/konzepte/phase1-technisches-konzept.md index 0b0084c..e569244 100644 --- a/docs/konzepte/phase1-technisches-konzept.md +++ b/docs/konzepte/phase1-technisches-konzept.md @@ -292,7 +292,7 @@ Fachliche Aufgabe: - E-Mail auf `Neue Bestellung` erkennen - Payload in ERP-JSON umformen -- JSON per HTTP POST an `modules/erp/import-integration/order-import.php` senden +- JSON per HTTP POST an `public/order-import.php` senden Liest: diff --git a/docs/modules/README.md b/docs/modules/README.md index a442ab9..6926a8d 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -9,6 +9,10 @@ Regeln: - Submodule werden im README des owning Moduls kurz benannt, wenn sie nicht eigenes Ownership tragen. - Code, Doku und Datenmodell sollen dieser Struktur folgen. - Bereits technisch umgesetzt sind aktuell nur: + - `modules/erp/kontakte` + - `modules/erp/bestellungen` + - `modules/erp/lager` + - `modules/erp/artikel-mapping` - `modules/erp/import-integration` - `modules/erp/direktverkauf` - `modules/shared` diff --git a/docs/modules/erp/README.md b/docs/modules/erp/README.md index 1305bff..fde278b 100644 --- a/docs/modules/erp/README.md +++ b/docs/modules/erp/README.md @@ -36,7 +36,12 @@ ERP ist der operative Container fuer die Kerndomane des Tagesgeschaefts. ## Technisch umgesetzt +- `modules/erp/kontakte/service.php` +- `modules/erp/bestellungen/service.php` +- `modules/erp/lager/service.php` +- `modules/erp/artikel-mapping/service.php` - `modules/erp/import-integration/order-import.php` +- `modules/erp/import-integration/service.php` - `modules/erp/direktverkauf/api/otc-order.php` - `modules/erp/direktverkauf/ui/index.php` diff --git a/docs/modules/erp/artikel-mapping/README.md b/docs/modules/erp/artikel-mapping/README.md index 1a1cb28..b1d4f1c 100644 --- a/docs/modules/erp/artikel-mapping/README.md +++ b/docs/modules/erp/artikel-mapping/README.md @@ -29,3 +29,6 @@ Aufloesung externer Shopdaten auf interne verkaufbare Artikel und Produkte. - Keine Lagerbewegungen. - Keine Buchhaltung. +## Technisch umgesetzt + +- `modules/erp/artikel-mapping/service.php` diff --git a/docs/modules/erp/bestellungen/README.md b/docs/modules/erp/bestellungen/README.md index 6cc57c9..1ba7079 100644 --- a/docs/modules/erp/bestellungen/README.md +++ b/docs/modules/erp/bestellungen/README.md @@ -35,3 +35,6 @@ Operative Verarbeitung von Bestellungen, Positionen und Status. - Keine Lagerbestandswahrheit. - Kein Direktverkaufs-UI. +## Technisch umgesetzt + +- `modules/erp/bestellungen/service.php` diff --git a/docs/modules/erp/kontakte/README.md b/docs/modules/erp/kontakte/README.md index de82ad7..a5b8085 100644 --- a/docs/modules/erp/kontakte/README.md +++ b/docs/modules/erp/kontakte/README.md @@ -35,3 +35,6 @@ Zentraler Kontaktstamm fuer Kunden, Lieferanten und sonstige Parteien. - Kontaktabfrage - Lookup fuer andere Module +## Technisch umgesetzt + +- `modules/erp/kontakte/service.php` diff --git a/docs/modules/erp/lager/README.md b/docs/modules/erp/lager/README.md index 446e113..b927cf3 100644 --- a/docs/modules/erp/lager/README.md +++ b/docs/modules/erp/lager/README.md @@ -37,3 +37,6 @@ Bestand, Charge, MHD und Bewegungen. - Keine Beratungslogik. - Keine fachfremden Artikelzuordnungen. +## Technisch umgesetzt + +- `modules/erp/lager/service.php` diff --git a/modules/erp/artikel-mapping/service.php b/modules/erp/artikel-mapping/service.php new file mode 100644 index 0000000..7462a51 --- /dev/null +++ b/modules/erp/artikel-mapping/service.php @@ -0,0 +1,271 @@ +query("SELECT id, sku, name FROM product WHERE status = 'active' ORDER BY id"); + foreach ($stmt->fetchAll() as $product) { + $productName = (string) ($product['name'] ?? ''); + $productKey = detect_product_family_key(normalize_match_key($productName)); + if ($productKey === $familyKey) { + return [ + 'id' => (int) $product['id'], + 'sku' => (string) $product['sku'], + 'name' => $productName, + ]; + } + } + + throw new RuntimeException("Kein aktives ERP-Produkt fuer '{$title}' gefunden"); +} + +function find_alias_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int +{ + $stmt = $pdo->prepare( + "SELECT si.id + FROM external_item_alias eia + JOIN sellable_item si ON si.id = eia.sellable_item_id + WHERE eia.source_system = 'wix' + AND eia.is_active = TRUE + AND ( + eia.external_article_number = :article_number + OR eia.external_title = :title + OR eia.title_normalized = :normalized_title + ) + ORDER BY eia.id + LIMIT 1" + ); + $stmt->execute([ + ':article_number' => trim($articleNumber), + ':title' => trim($title), + ':normalized_title' => normalize_match_key($title), + ]); + + $id = $stmt->fetchColumn(); + return $id === false ? null : (int) $id; +} + +function sellable_item_exists(PDO $pdo, int $sellableItemId): bool +{ + $stmt = $pdo->prepare('SELECT 1 FROM sellable_item WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $sellableItemId]); + return $stmt->fetchColumn() !== false; +} + +function find_sellable_item_for_product(PDO $pdo, int $productId): ?int +{ + $stmt = $pdo->prepare( + 'SELECT sellable_item_id + FROM sellable_item_component + WHERE product_id = :product_id + ORDER BY id + LIMIT 1' + ); + $stmt->execute([':product_id' => $productId]); + $id = $stmt->fetchColumn(); + return $id === false ? null : (int) $id; +} + +function find_product_name(PDO $pdo, int $productId): ?string +{ + $stmt = $pdo->prepare('SELECT name FROM product WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $productId]); + $name = $stmt->fetchColumn(); + return $name === false ? null : (string) $name; +} + +function ensure_unique_sellable_item_code(PDO $pdo, string $preferred): string +{ + $candidate = trim($preferred); + if ($candidate === '') { + $candidate = 'si'; + } + + $base = $candidate; + $counter = 1; + while (true) { + $stmt = $pdo->prepare('SELECT 1 FROM sellable_item WHERE item_code = :item_code LIMIT 1'); + $stmt->execute([':item_code' => $candidate]); + if ($stmt->fetchColumn() === false) { + return $candidate; + } + + $candidate = $base . '-' . $counter; + $counter++; + } +} + +function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $title): ?int +{ + $normalizedTitle = normalize_match_key($title); + $familyKey = detect_product_family_key($normalizedTitle); + if ($familyKey === null) { + return null; + } + + $stmt = $pdo->query("SELECT id, name FROM product WHERE status = 'active' ORDER BY id"); + foreach ($stmt->fetchAll() as $product) { + $productName = (string) ($product['name'] ?? ''); + $productKey = detect_product_family_key(normalize_match_key($productName)); + if ($productKey === $familyKey) { + return (int) $product['id']; + } + } + + return null; +} + +function infer_components_from_title(PDO $pdo, string $title): array +{ + $productId = resolve_product_id_fallback($pdo, '', $title); + if ($productId === null) { + return []; + } + + return [['product_id' => $productId, 'qty_per_item' => 1.0]]; +} + +function ensure_alias_points_to_sellable_item( + PDO $pdo, + string $articleNumber, + string $title, + int $sellableItemId +): void { + $stmt = $pdo->prepare( + "INSERT INTO external_item_alias ( + source_system, external_article_number, external_title, title_normalized, sellable_item_id, is_active, created_at, updated_at + ) VALUES ( + 'wix', :article_number, :title, :normalized_title, :sellable_item_id, TRUE, NOW(), NOW() + ) + ON CONFLICT DO NOTHING" + ); + $stmt->execute([ + ':article_number' => trim($articleNumber), + ':title' => trim($title), + ':normalized_title' => normalize_match_key($title), + ':sellable_item_id' => $sellableItemId, + ]); +} + +function ensure_sellable_mapping_from_title_components(PDO $pdo, string $articleNumber, string $title): ?int +{ + $sellableItemId = find_alias_sellable_item_id($pdo, $articleNumber, $title); + if ($sellableItemId !== null) { + return $sellableItemId; + } + + $components = infer_components_from_title($pdo, $title); + if ($components === []) { + return null; + } + + $productId = (int) $components[0]['product_id']; + $sellableItemId = find_sellable_item_for_product($pdo, $productId); + if ($sellableItemId === null) { + $productName = find_product_name($pdo, $productId) ?? 'Mapped item'; + $code = ensure_unique_sellable_item_code($pdo, normalize_match_key($productName)); + $stmt = $pdo->prepare( + "INSERT INTO sellable_item (item_code, display_name, status, created_at, updated_at) + VALUES (:item_code, :display_name, 'active', NOW(), NOW()) + RETURNING id" + ); + $stmt->execute([ + ':item_code' => $code, + ':display_name' => $productName, + ]); + $sellableItemId = (int) $stmt->fetchColumn(); + + $componentStmt = $pdo->prepare( + "INSERT INTO sellable_item_component (sellable_item_id, product_id, qty_per_item, created_at, updated_at) + VALUES (:sellable_item_id, :product_id, 1, NOW(), NOW())" + ); + $componentStmt->execute([ + ':sellable_item_id' => $sellableItemId, + ':product_id' => $productId, + ]); + } + + ensure_alias_points_to_sellable_item($pdo, $articleNumber, $title, $sellableItemId); + + return $sellableItemId; +} + +function ensure_sellable_mapping_from_product_fallback(PDO $pdo, string $articleNumber, string $title): ?int +{ + return ensure_sellable_mapping_from_title_components($pdo, $articleNumber, $title); +} + +function resolve_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int +{ + $id = find_alias_sellable_item_id($pdo, $articleNumber, $title); + if ($id !== null) { + return $id; + } + + return ensure_sellable_mapping_from_product_fallback($pdo, $articleNumber, $title); +} + +function get_item_components(PDO $pdo, int $sellableItemId): array +{ + $stmt = $pdo->prepare( + 'SELECT product_id, qty_per_item + FROM sellable_item_component + WHERE sellable_item_id = :sellable_item_id + ORDER BY id' + ); + $stmt->execute([':sellable_item_id' => $sellableItemId]); + + return $stmt->fetchAll(); +} diff --git a/modules/erp/bestellungen/service.php b/modules/erp/bestellungen/service.php new file mode 100644 index 0000000..254cba6 --- /dev/null +++ b/modules/erp/bestellungen/service.php @@ -0,0 +1,177 @@ +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 +{ + $stmt = $pdo->prepare( + "INSERT INTO sales_order ( + external_ref, party_id, order_source, order_status, payment_status, payment_method_id, + amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, imported_at, created_at, updated_at + ) VALUES ( + '', :party_id, 'direct', 'imported', 'paid', :payment_method_id, + :amount_net, 0, 0, 0, :total_amount, 'CHF', NOW(), NOW(), NOW() + ) + RETURNING id, external_ref" + ); + $stmt->execute($fields); + + $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; +} diff --git a/modules/erp/direktverkauf/api/otc-order.php b/modules/erp/direktverkauf/api/otc-order.php index 815944b..ea17cdc 100644 --- a/modules/erp/direktverkauf/api/otc-order.php +++ b/modules/erp/direktverkauf/api/otc-order.php @@ -1,438 +1,17 @@ false, 'status' => 0, 'body' => '', 'error' => 'Could not encode payload']; - } - - $headerLines = ['Content-Type: application/json']; - foreach ($headers as $name => $value) { - if ($name === '' || $value === '') { - continue; - } - $headerLines[] = $name . ': ' . $value; - } - - $context = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => implode("\r\n", $headerLines), - 'content' => $body, - 'timeout' => $timeoutSeconds, - 'ignore_errors' => true, - ], - ]); - - $responseBody = @file_get_contents($url, false, $context); - $responseHeaders = $http_response_header ?? []; - - $status = 0; - if (isset($responseHeaders[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $responseHeaders[0], $m) === 1) { - $status = (int) $m[1]; - } - - if ($responseBody === false) { - $responseBody = ''; - } - - return [ - 'ok' => $status >= 200 && $status < 300, - 'status' => $status, - 'body' => substr($responseBody, 0, 500), - 'error' => ($status === 0) ? 'Request failed or timed out' : '', - ]; -} - -function trigger_excel_webhook(string $externalRef, array $localEnv): array -{ - $url = derive_excel_webhook_url($localEnv); - if ($url === '') { - return [ - 'enabled' => false, - 'ok' => false, - 'message' => 'Excel webhook URL not configured', - ]; - } - - $headers = []; - $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); - if ($secret !== '') { - $headers['X-Webhook-Secret'] = $secret; - $headers['X-N8N-Secret'] = $secret; - $headers['X-API-Key'] = $secret; - $headers['Authorization'] = 'Bearer ' . $secret; - } - - throttle_webhook_channel('excel', 10); - $result = post_json($url, ['Bestellnummer' => $externalRef], $headers, 20); - - return [ - 'enabled' => true, - 'ok' => $result['ok'], - 'status' => $result['status'], - 'url' => $url, - 'message' => $result['ok'] ? 'Excel webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Excel webhook returned non-2xx'), - 'responseBody' => $result['body'], - ]; -} - -function normalize_match_key(string $value): string -{ - $value = trim(mb_strtolower($value, 'UTF-8')); - if ($value === '') { - return ''; - } - - if (function_exists('iconv')) { - $transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); - if ($transliterated !== false) { - $value = $transliterated; - } - } - - $value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? ''; - return trim($value); -} - -function detect_product_family_key(string $normalizedName): ?string -{ - if (str_contains($normalizedName, 'lion') && str_contains($normalizedName, 'mane')) { - return 'lionsmane'; - } - if (str_contains($normalizedName, 'chaga')) { - return 'chaga'; - } - if (str_contains($normalizedName, 'reishi')) { - return 'reishi'; - } - if (str_contains($normalizedName, 'shiitake')) { - return 'shiitake'; - } - - return null; -} - -function resolve_otc_product(PDO $pdo, string $title): array -{ - $normalizedTitle = normalize_match_key($title); - $familyKey = detect_product_family_key($normalizedTitle); - if ($familyKey === null) { - throw new RuntimeException("Kein Produkt-Matching fuer '{$title}' gefunden"); - } - - $stmt = $pdo->query( - "SELECT id, sku, name - FROM product - WHERE status = 'active' - ORDER BY id" - ); - - foreach ($stmt->fetchAll() as $product) { - $productName = (string) ($product['name'] ?? ''); - $productKey = detect_product_family_key(normalize_match_key($productName)); - if ($productKey === $familyKey) { - return [ - 'id' => (int) $product['id'], - 'sku' => (string) $product['sku'], - 'name' => $productName, - ]; - } - } - - throw new RuntimeException("Kein aktives ERP-Produkt fuer '{$title}' gefunden"); -} - -function resolve_otc_sellable_item(PDO $pdo, int $productId): array -{ - $stmt = $pdo->prepare( - "SELECT - si.id, - si.display_name, - eia.external_article_number - FROM sellable_item si - JOIN sellable_item_component sic ON sic.sellable_item_id = si.id - LEFT JOIN external_item_alias eia - ON eia.sellable_item_id = si.id - AND eia.source_system = 'wix' - AND eia.is_active = TRUE - WHERE si.status = 'active' - AND sic.product_id = :product_id - AND sic.qty_per_item = 1 - AND NOT EXISTS ( - SELECT 1 - FROM sellable_item_component sic_other - WHERE sic_other.sellable_item_id = si.id - AND sic_other.id <> sic.id - ) - ORDER BY - CASE WHEN eia.external_article_number IS NULL OR eia.external_article_number = '' THEN 1 ELSE 0 END, - eia.external_article_number, - si.id - LIMIT 1" - ); - $stmt->execute([':product_id' => $productId]); - $row = $stmt->fetch(); - - if ($row === false) { - throw new RuntimeException("Kein Einzelartikel fuer Produkt-ID {$productId} gefunden"); - } - - return [ - 'id' => (int) $row['id'], - 'display_name' => (string) $row['display_name'], - 'article_number' => trim((string) ($row['external_article_number'] ?? '')), - ]; -} - -function get_default_location_ids(PDO $pdo): array -{ - $row = $pdo->query( - "SELECT - (SELECT id FROM location WHERE type = 'storage' ORDER BY id LIMIT 1) AS storage_id, - (SELECT id FROM location WHERE type = 'dispatch' ORDER BY id LIMIT 1) AS dispatch_id" - )->fetch(); - - if (!is_array($row) || $row['storage_id'] === null || $row['dispatch_id'] === null) { - throw new RuntimeException('Erforderliche Lagerorte fehlen'); - } - - return [ - 'storage' => (int) $row['storage_id'], - 'dispatch' => (int) $row['dispatch_id'], - ]; -} - -function get_item_components(PDO $pdo, int $sellableItemId): array -{ - $stmt = $pdo->prepare( - 'SELECT product_id, qty_per_item - FROM sellable_item_component - WHERE sellable_item_id = :sellable_item_id - ORDER BY id' - ); - $stmt->execute([':sellable_item_id' => $sellableItemId]); - return $stmt->fetchAll(); -} - -function get_current_lot_balance_for_update(PDO $pdo, int $productId): array -{ - $stmt = $pdo->prepare( - "SELECT - sl.id AS lot_id, - COALESCE(( - SELECT v.qty_net - FROM v_stock_lot_balance v - WHERE v.stock_lot_id = sl.id - ), 0) AS qty_net - FROM stock_lot sl - WHERE sl.product_id = :product_id - AND sl.status = 'current' - LIMIT 1 - FOR UPDATE" - ); - $stmt->execute([':product_id' => $productId]); - $row = $stmt->fetch(); - - if ($row === false) { - throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden"); - } - - return [ - 'lot_id' => (int) $row['lot_id'], - 'qty_net' => (float) $row['qty_net'], - ]; -} - -function insert_stock_move_out( - PDO $pdo, - int $productId, - int $lotId, - float $qty, - int $fromLocationId, - int $toLocationId, - string $note -): int { - $stmt = $pdo->prepare( - "INSERT INTO stock_move ( - product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at - ) VALUES ( - :product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', NOW(), :note, NOW(), NOW() - ) - RETURNING id" - ); - $stmt->execute([ - ':product_id' => $productId, - ':lot_id' => $lotId, - ':from_location_id' => $fromLocationId, - ':to_location_id' => $toLocationId, - ':qty' => $qty, - ':note' => $note, - ]); - - $id = $stmt->fetchColumn(); - if ($id === false) { - throw new RuntimeException('Konnte Lagerabgang nicht schreiben'); - } - - return (int) $id; -} - -function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int -{ - $closeStmt = $pdo->prepare( - "UPDATE stock_lot - SET status = 'closed', updated_at = NOW() - WHERE id = :id" - ); - $closeStmt->execute([':id' => $oldCurrentLotId]); - - $openStmt = $pdo->prepare( - "SELECT id - FROM stock_lot - WHERE product_id = :product_id - AND status = 'open' - ORDER BY id - LIMIT 1 - FOR UPDATE" - ); - $openStmt->execute([':product_id' => $productId]); - $newCurrentLotId = $openStmt->fetchColumn(); - - if ($newCurrentLotId === false) { - throw new RuntimeException("Keine offene Platzhalter-Charge fuer Produkt {$productId} vorhanden"); - } - - $promoteStmt = $pdo->prepare( - "UPDATE stock_lot - SET status = 'current', - updated_at = NOW() - WHERE id = :id" - ); - $promoteStmt->execute([':id' => (int) $newCurrentLotId]); - - $createOpenStmt = $pdo->prepare( - "INSERT INTO stock_lot (product_id, status, created_at, updated_at) - VALUES (:product_id, 'open', NOW(), NOW()) - RETURNING id" - ); - $createOpenStmt->execute([':product_id' => $productId]); - $createdOpenLotId = $createOpenStmt->fetchColumn(); - if ($createdOpenLotId === false) { - throw new RuntimeException("Konnte keine neue offene Platzhalter-Charge fuer Produkt {$productId} anlegen"); - } - - return (int) $newCurrentLotId; -} - -function allocate_components_for_line( - PDO $pdo, - int $orderId, - int $lineId, - int $lineNo, - array $components, - float $lineQty, - array $locations -): array { - if ($components === []) { - throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden"); - } - - $allocationInsert = $pdo->prepare( - "INSERT INTO sales_order_line_lot_allocation ( - sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at - ) VALUES ( - :sales_order_line_id, :product_id, :lot_id, :qty, 'allocated', :stock_move_id, NOW(), NOW() - )" - ); - - foreach ($components as $component) { - $productId = (int) $component['product_id']; - $remaining = $lineQty * (float) $component['qty_per_item']; - $guard = 0; - - while ($remaining > 0.0000001) { - $guard++; - if ($guard > 100) { - throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}"); - } - - $current = get_current_lot_balance_for_update($pdo, $productId); - $lotId = $current['lot_id']; - $available = $current['qty_net']; - - if ($available <= 0.0000001) { - $lotId = switch_current_lot($pdo, $productId, $lotId, (int) $locations['storage']); - $current = get_current_lot_balance_for_update($pdo, $productId); - $available = $current['qty_net']; - $lotId = $current['lot_id']; - } - - if ($available <= 0.0000001) { - throw new RuntimeException("Kein verfuegbarer Bestand fuer Produkt {$productId}"); - } - - $take = min($remaining, $available); - $stockMoveId = insert_stock_move_out( - $pdo, - $productId, - $lotId, - $take, - (int) $locations['storage'], - (int) $locations['dispatch'], - "otc-order:order={$orderId}:line={$lineNo}:product={$productId}" - ); - - $allocationInsert->execute([ - ':sales_order_line_id' => $lineId, - ':product_id' => $productId, - ':lot_id' => $lotId, - ':qty' => $take, - ':stock_move_id' => $stockMoveId, - ]); - - $remaining -= $take; - } - } - - return [ - 'allocated' => true, - ]; -} - -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { +if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); } @@ -472,7 +51,7 @@ if ($totalPrice === null || $totalPrice <= 0) { } $resolvedProducts = []; -$totalQty = 0; +$totalQty = 0.0; foreach ($products as $product) { if (!is_array($product) || !isset($product['qty'], $product['title'])) { json_response(422, ['ok' => false, 'error' => 'Product missing title or qty']); @@ -496,87 +75,31 @@ if ($totalQty <= 0) { } try { - $pdo = connect_database(); + $env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env')); + $pdo = connect_database($env); $pdo->beginTransaction(); $locations = get_default_location_ids($pdo); - $paymentMethodStmt = $pdo->prepare('SELECT id FROM payment_method WHERE code = :code LIMIT 1'); - $paymentMethodStmt->execute([':code' => $paymentMethodCode]); - $paymentMethodId = $paymentMethodStmt->fetchColumn(); - if ($paymentMethodId === false) { + $paymentMethodId = lookup_method_id($pdo, 'payment_method', $paymentMethodCode); + if ($paymentMethodId === null) { throw new RuntimeException('Invalid payment method'); } - $partyName = trim(((string) ($billing['firstName'] ?? '')) . ' ' . ((string) ($billing['lastName'] ?? ''))); - $partyName = $partyName === '' ? 'OTC Kunde' : $partyName; + $partyId = find_or_create_party($pdo, $billing); + upsert_addresses($pdo, $partyId, $billing); - $partyStmt = $pdo->prepare( - "INSERT INTO party (type, name, status, created_at, updated_at) - VALUES ('customer', :name, 'active', NOW(), NOW()) - RETURNING id" - ); - $partyStmt->execute([':name' => $partyName]); - $partyId = $partyStmt->fetchColumn(); - if ($partyId === false) { - throw new RuntimeException('Could not create party'); - } - $partyId = (int) $partyId; - - $addressStmt = $pdo->prepare( - "INSERT INTO address ( - party_id, type, first_name, last_name, street, house_number, zip, city, country_name, created_at, updated_at - ) VALUES ( - :party_id, 'billing', :first_name, :last_name, :street, :house_number, :zip, :city, 'Switzerland', NOW(), NOW() - )" - ); - $addressStmt->execute([ + $order = create_direct_sales_order($pdo, [ ':party_id' => $partyId, - ':first_name' => trim((string) ($billing['firstName'] ?? '')), - ':last_name' => trim((string) ($billing['lastName'] ?? '')), - ':street' => trim((string) ($billing['street'] ?? '')), - ':house_number' => trim((string) ($billing['houseNumber'] ?? '')), - ':zip' => trim((string) ($billing['zip'] ?? '')), - ':city' => trim((string) ($billing['city'] ?? '')), - ]); - - $orderStmt = $pdo->prepare( - "INSERT INTO sales_order ( - external_ref, party_id, order_source, order_status, payment_status, payment_method_id, - amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, imported_at, created_at, updated_at - ) VALUES ( - '', :party_id, 'direct', 'imported', 'paid', :payment_method_id, - :amount_net, 0, 0, 0, :total_amount, 'CHF', NOW(), NOW(), NOW() - ) - RETURNING id, external_ref" - ); - $orderStmt->execute([ - ':party_id' => $partyId, - ':payment_method_id' => (int) $paymentMethodId, + ':payment_method_id' => $paymentMethodId, ':amount_net' => $totalPrice, ':total_amount' => $totalPrice, ]); - $order = $orderStmt->fetch(); - if ($order === false) { - throw new RuntimeException('Could not create order'); - } $orderId = (int) $order['id']; $externalRef = (string) $order['external_ref']; - $lineInsert = $pdo->prepare( - "INSERT INTO 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" - ); - $remainingTotal = round((float) $totalPrice, 2); - $remainingQty = (float) $totalQty; $lineNo = 0; foreach ($resolvedProducts as $product) { @@ -585,7 +108,10 @@ try { $title = $product['title']; $resolvedProduct = resolve_otc_product($pdo, $title); - $resolvedSellable = resolve_otc_sellable_item($pdo, (int) $resolvedProduct['id']); + $sellableItemId = resolve_sellable_item_id($pdo, '', $title); + if ($sellableItemId === null) { + throw new RuntimeException("Kein Artikel-Mapping fuer '{$title}' gefunden"); + } if ($lineNo === count($resolvedProducts)) { $unitPrice = round($remainingTotal / $qty, 4); @@ -594,39 +120,32 @@ try { $unitPrice = round((float) $totalPrice / (float) $totalQty, 4); $lineTotal = round($qty * $unitPrice, 2); $remainingTotal = round($remainingTotal - $lineTotal, 2); - $remainingQty -= $qty; } - $lineInsert->execute([ + $lineId = insert_sales_order_line($pdo, [ ':sales_order_id' => $orderId, ':line_no' => $lineNo, - ':sellable_item_id' => (int) $resolvedSellable['id'], - ':article_number' => $resolvedSellable['article_number'], - ':title' => $resolvedSellable['display_name'], + ':sellable_item_id' => $sellableItemId, + ':article_number' => (string) $resolvedProduct['sku'], + ':title' => (string) $resolvedProduct['name'], ':qty' => $qty, ':unit_price' => $unitPrice, ':line_total' => $lineTotal, ]); - $lineId = $lineInsert->fetchColumn(); - if ($lineId === false) { - throw new RuntimeException("Could not create line {$lineNo}"); - } - $components = get_item_components($pdo, (int) $resolvedSellable['id']); - allocate_components_for_line( + allocate_line_inventory( $pdo, $orderId, - (int) $lineId, + $lineId, $lineNo, - $components, $qty, + $sellableItemId, $locations ); } $pdo->commit(); - $env = expand_env_values(parse_env_file(__DIR__ . '/../../.env')); $excelTrigger = trigger_excel_webhook($externalRef, $env); json_response(201, [ diff --git a/modules/erp/import-integration/order-import.php b/modules/erp/import-integration/order-import.php index 9e3f37c..8ea110d 100644 --- a/modules/erp/import-integration/order-import.php +++ b/modules/erp/import-integration/order-import.php @@ -1,1388 +1,21 @@ = 2) { - $first = $value[0]; - $last = $value[strlen($value) - 1]; - if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { - $value = substr($value, 1, -1); - } - } - - $result[$key] = $value; - } - - return $result; -} - -function expand_env_values(array $env): array -{ - $expanded = $env; - $pattern = '/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/'; - - foreach ($expanded as $key => $value) { - $expanded[$key] = preg_replace_callback( - $pattern, - static function (array $matches) use (&$expanded): string { - $lookup = $matches[1]; - return (string) ($expanded[$lookup] ?? getenv($lookup) ?: ''); - }, - (string) $value - ) ?? (string) $value; - } - - return $expanded; -} - -function env_value(string $key, array $localEnv, string $default = ''): string -{ - $runtime = getenv($key); - if ($runtime !== false && $runtime !== '') { - return $runtime; - } - - if (isset($localEnv[$key]) && $localEnv[$key] !== '') { - return (string) $localEnv[$key]; - } - - return $default; -} - -function parse_number(mixed $value): ?float -{ - if ($value === null || $value === '') { - return null; - } - - if (is_int($value) || is_float($value)) { - return (float) $value; - } - - if (!is_string($value)) { - return null; - } - - $normalized = str_replace(["\u{00A0}", ' '], '', trim($value)); - $normalized = str_replace(',', '.', $normalized); - - if (!is_numeric($normalized)) { - return null; - } - - return (float) $normalized; -} - -function lookup_method_id(PDO $pdo, string $table, ?string $code): ?int -{ - if ($code === null || $code === '') { - return null; - } - - $stmt = $pdo->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 connect_database(array $localEnv): PDO -{ - $databaseUrl = env_value('DATABASE_URL', $localEnv); - if ($databaseUrl !== '') { - $parts = parse_url($databaseUrl); - if ($parts !== false && ($parts['scheme'] ?? '') === 'postgresql') { - $host = (string) ($parts['host'] ?? ''); - $port = (string) ($parts['port'] ?? '5432'); - $dbName = ltrim((string) ($parts['path'] ?? ''), '/'); - $user = (string) ($parts['user'] ?? ''); - $pass = (string) ($parts['pass'] ?? ''); - if ($host !== '' && $dbName !== '' && $user !== '') { - $dsn = "pgsql:host={$host};port={$port};dbname={$dbName}"; - return new PDO($dsn, $user, $pass, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - } - } - } - - $host = env_value('DB_HOST', $localEnv); - $port = env_value('DB_PORT', $localEnv, '5432'); - $dbName = env_value('DB_NAME', $localEnv); - $user = env_value('DB_USER', $localEnv); - $pass = env_value('DB_PASSWORD', $localEnv); - - if ($host === '' || $dbName === '' || $user === '') { - throw new RuntimeException('Missing DB configuration'); - } - - $dsn = "pgsql:host={$host};port={$port};dbname={$dbName}"; - return new PDO($dsn, $user, $pass, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); -} - -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 derive_label_webhook_url(array $localEnv): string -{ - $explicit = env_value('N8N_LABEL_WEBHOOK_URL', $localEnv); - if ($explicit !== '') { - return $explicit; - } - - $legacy = env_value('N8N_OUTBOUND_URL_ADRESSE', $localEnv); - if ($legacy !== '' && str_contains(strtolower($legacy), 'adressetikette')) { - return $legacy; - } - - $base = env_value('N8N_BASE_URL', $localEnv); - if ($base === '') { - return ''; - } - - $root = preg_replace('#/api/v1/?$#', '', rtrim($base, '/')); - if (!is_string($root) || $root === '') { - return ''; - } - - return $root . '/webhook/naurua_erp_adressetikette'; -} - -function derive_excel_webhook_url(array $localEnv): string -{ - $explicit = env_value('N8N_EXCEL_WEBHOOK_URL', $localEnv); - if ($explicit !== '') { - return $explicit; - } - - $legacy = env_value('N8N_OUTBOUND_URL_ADRESSE', $localEnv); - if ($legacy !== '' && str_contains(strtolower($legacy), 'excel_befuellen')) { - return $legacy; - } - - $base = env_value('N8N_BASE_URL', $localEnv); - if ($base === '') { - return ''; - } - - $root = preg_replace('#/api/v1/?$#', '', rtrim($base, '/')); - if (!is_string($root) || $root === '') { - return ''; - } - - return $root . '/webhook/excel_befuellen'; -} - -function post_json(string $url, array $payload, array $headers = [], int $timeoutSeconds = 15): array -{ - $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($body === false) { - return ['ok' => false, 'status' => 0, 'body' => '', 'error' => 'Could not encode payload']; - } - - $headerLines = ['Content-Type: application/json']; - foreach ($headers as $name => $value) { - if ($name === '' || $value === '') { - continue; - } - $headerLines[] = $name . ': ' . $value; - } - - $context = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => implode("\r\n", $headerLines), - 'content' => $body, - 'timeout' => $timeoutSeconds, - 'ignore_errors' => true, - ], - ]); - - $responseBody = @file_get_contents($url, false, $context); - $responseHeaders = $http_response_header ?? []; - - $status = 0; - if (isset($responseHeaders[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $responseHeaders[0], $m) === 1) { - $status = (int) $m[1]; - } - - if ($responseBody === false) { - $responseBody = ''; - } - - return [ - 'ok' => $status >= 200 && $status < 300, - 'status' => $status, - 'body' => substr($responseBody, 0, 500), - 'error' => ($status === 0) ? 'Request failed or timed out' : '', - ]; -} - -function trigger_shipping_label_flow(array $order, array $localEnv): array -{ - $url = derive_label_webhook_url($localEnv); - if ($url === '') { - return [ - 'enabled' => false, - 'ok' => false, - 'message' => 'Label webhook URL not configured', - ]; - } - - $payload = [ - 'BestellungNr' => (string) ($order['BestellungNr'] ?? ''), - 'Vorname_LfAdr' => (string) ($order['Vorname_LfAdr'] ?? $order['Vorname'] ?? ''), - 'Nachname_LfAdr' => (string) ($order['Nachname_LfAdr'] ?? $order['Nachname'] ?? ''), - 'Strasse_LfAdr' => (string) ($order['Strasse_LfAdr'] ?? $order['Strasse'] ?? ''), - 'Hausnummer_LfAdr' => (string) ($order['Hausnummer_LfAdr'] ?? $order['Hausnummer'] ?? ''), - 'PLZ_LfAdr' => (string) ($order['PLZ_LfAdr'] ?? $order['PLZ'] ?? ''), - 'Stadt_LfAdr' => (string) ($order['Stadt_LfAdr'] ?? $order['Stadt'] ?? ''), - 'Land_LfAdr' => (string) ($order['Land_LfAdr'] ?? $order['Land'] ?? ''), - // Also include flat keys to be compatible with both mapping and direct template usage. - 'Vorname' => (string) ($order['Vorname_LfAdr'] ?? $order['Vorname'] ?? ''), - 'Nachname' => (string) ($order['Nachname_LfAdr'] ?? $order['Nachname'] ?? ''), - 'Strasse' => (string) ($order['Strasse_LfAdr'] ?? $order['Strasse'] ?? ''), - 'Hausnummer' => (string) ($order['Hausnummer_LfAdr'] ?? $order['Hausnummer'] ?? ''), - 'PLZ' => (string) ($order['PLZ_LfAdr'] ?? $order['PLZ'] ?? ''), - 'Stadt' => (string) ($order['Stadt_LfAdr'] ?? $order['Stadt'] ?? ''), - 'Land' => (string) ($order['Land_LfAdr'] ?? $order['Land'] ?? ''), - ]; - - $headers = []; - $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); - if ($secret !== '') { - $headers['X-Webhook-Secret'] = $secret; - $headers['X-N8N-Secret'] = $secret; - $headers['X-API-Key'] = $secret; - $headers['Authorization'] = 'Bearer ' . $secret; - } - - $result = post_json($url, $payload, $headers, 20); - - return [ - 'enabled' => true, - 'ok' => $result['ok'], - 'status' => $result['status'], - 'url' => $url, - 'message' => $result['ok'] ? 'Label flow triggered' : ($result['error'] !== '' ? $result['error'] : 'Label flow returned non-2xx'), - 'responseBody' => $result['body'], - ]; -} - -function trigger_excel_webhook(string $externalRef, array $localEnv): array -{ - $url = derive_excel_webhook_url($localEnv); - if ($url === '') { - return [ - 'enabled' => false, - 'ok' => false, - 'message' => 'Excel webhook URL not configured', - ]; - } - - $payload = [ - 'Bestellnummer' => $externalRef, - ]; - - $headers = []; - $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); - if ($secret !== '') { - $headers['X-Webhook-Secret'] = $secret; - $headers['X-N8N-Secret'] = $secret; - $headers['X-API-Key'] = $secret; - $headers['Authorization'] = 'Bearer ' . $secret; - } - - throttle_webhook_channel('excel', 10); - $result = post_json($url, $payload, $headers, 20); - - return [ - 'enabled' => true, - 'ok' => $result['ok'], - 'status' => $result['status'], - 'url' => $url, - 'message' => $result['ok'] ? 'Excel webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Excel webhook returned non-2xx'), - 'responseBody' => $result['body'], - ]; -} - -function find_or_create_party(PDO $pdo, array $data): int -{ - $email = trim((string) ($data['EmailKunde'] ?? '')); - $firstName = trim((string) ($data['Vorname_RgAdr'] ?? '')); - $lastName = trim((string) ($data['Nachname_RgAdr'] ?? '')); - $name = trim($firstName . ' ' . $lastName); - if ($name === '') { - $name = 'Online-Shop Kunde'; - } - - if ($email !== '') { - $findStmt = $pdo->prepare('SELECT id FROM public.party WHERE lower(email) = lower(:email) ORDER BY id ASC LIMIT 1'); - $findStmt->execute([':email' => $email]); - $existing = $findStmt->fetchColumn(); - if ($existing !== false) { - $partyId = (int) $existing; - $updateStmt = $pdo->prepare('UPDATE public.party SET name = :name, updated_at = NOW() WHERE id = :id'); - $updateStmt->execute([':id' => $partyId, ':name' => $name]); - return $partyId; - } - } - - $insertStmt = $pdo->prepare( - 'INSERT INTO public.party (type, name, email, status, created_at, updated_at) - VALUES (\'customer\', :name, :email, \'active\', NOW(), NOW()) - RETURNING id' - ); - $insertStmt->execute([ - ':name' => $name, - ':email' => $email !== '' ? $email : null, - ]); - - $id = $insertStmt->fetchColumn(); - if ($id === false) { - throw new RuntimeException('Could not create party'); - } - - return (int) $id; -} - -function upsert_addresses(PDO $pdo, int $partyId, array $data): void -{ - $delete = $pdo->prepare('DELETE FROM public.address WHERE party_id = :party_id AND type IN (\'billing\', \'shipping\')'); - $delete->execute([':party_id' => $partyId]); - - $insert = $pdo->prepare( - 'INSERT INTO public.address ( - party_id, type, first_name, last_name, street, house_number, zip, city, state_code, country_name, raw_payload, created_at, updated_at - ) VALUES ( - :party_id, :type, :first_name, :last_name, :street, :house_number, :zip, :city, :state_code, :country_name, :raw_payload::jsonb, NOW(), NOW() - )' - ); - - $insert->execute([ - ':party_id' => $partyId, - ':type' => 'billing', - ':first_name' => trim((string) ($data['Vorname_RgAdr'] ?? '')), - ':last_name' => trim((string) ($data['Nachname_RgAdr'] ?? '')), - ':street' => trim((string) ($data['Strasse_RgAdr'] ?? '')), - ':house_number' => trim((string) ($data['Hausnummer_RgAdr'] ?? '')), - ':zip' => trim((string) ($data['PLZ_RgAdr'] ?? '')), - ':city' => trim((string) ($data['Stadt_RgAdr'] ?? '')), - ':state_code' => trim((string) ($data['Bundesland_RgAdr'] ?? '')), - ':country_name' => trim((string) ($data['Land_RgAdr'] ?? '')), - ':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ]); - - $insert->execute([ - ':party_id' => $partyId, - ':type' => 'shipping', - ':first_name' => trim((string) ($data['Vorname_LfAdr'] ?? '')), - ':last_name' => trim((string) ($data['Nachname_LfAdr'] ?? '')), - ':street' => trim((string) ($data['Strasse_LfAdr'] ?? '')), - ':house_number' => trim((string) ($data['Hausnummer_LfAdr'] ?? '')), - ':zip' => trim((string) ($data['PLZ_LfAdr'] ?? '')), - ':city' => trim((string) ($data['Stadt_LfAdr'] ?? '')), - ':state_code' => trim((string) ($data['Bundesland_LfAdr'] ?? '')), - ':country_name' => trim((string) ($data['Land_LfAdr'] ?? '')), - ':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ]); -} - -function normalize_title_key(string $value): string -{ - $value = trim(strtolower($value)); - $value = preg_replace('/\s+/', ' ', $value) ?? $value; - return $value; -} - -function normalize_match_key(string $value): string -{ - $value = trim($value); - if ($value === '') { - return ''; - } - - $ascii = $value; - if (function_exists('iconv')) { - $converted = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); - if (is_string($converted) && $converted !== '') { - $ascii = $converted; - } - } - - $ascii = strtolower($ascii); - $ascii = preg_replace('/[^a-z0-9]+/', ' ', $ascii) ?? $ascii; - $ascii = preg_replace('/\s+/', ' ', trim($ascii)) ?? $ascii; - return $ascii; -} - -function find_existing_order_id(PDO $pdo, string $externalRef): ?int -{ - $stmt = $pdo->prepare('SELECT id FROM public.sales_order WHERE external_ref = :external_ref LIMIT 1'); - $stmt->execute([':external_ref' => $externalRef]); - $id = $stmt->fetchColumn(); - return $id === false ? null : (int) $id; -} - -function get_default_location_ids(PDO $pdo): array -{ - $storageId = $pdo->query("SELECT id FROM public.location WHERE type = 'storage' ORDER BY id LIMIT 1")->fetchColumn(); - $dispatchId = $pdo->query("SELECT id FROM public.location WHERE type = 'dispatch' ORDER BY id LIMIT 1")->fetchColumn(); - - if ($storageId === false || $dispatchId === false) { - $warehouseId = $pdo->query('SELECT id FROM public.warehouse ORDER BY id LIMIT 1')->fetchColumn(); - if ($warehouseId === false) { - $createWarehouse = $pdo->prepare( - "INSERT INTO public.warehouse (code, name, created_at, updated_at) - VALUES ('MAIN', 'Main Warehouse', NOW(), NOW()) - RETURNING id" - ); - $createWarehouse->execute(); - $warehouseId = $createWarehouse->fetchColumn(); - } - $warehouseId = (int) $warehouseId; - - if ($storageId === false) { - $existingStorage = $pdo->prepare( - "SELECT id FROM public.location - WHERE warehouse_id = :warehouse_id AND type = 'storage' - ORDER BY id LIMIT 1" - ); - $existingStorage->execute([':warehouse_id' => $warehouseId]); - $storageId = $existingStorage->fetchColumn(); - if ($storageId === false) { - $insertStorage = $pdo->prepare( - "INSERT INTO public.location (warehouse_id, code, name, type, created_at, updated_at) - VALUES (:warehouse_id, 'STORAGE', 'Storage', 'storage', NOW(), NOW()) - RETURNING id" - ); - $insertStorage->execute([':warehouse_id' => $warehouseId]); - $storageId = $insertStorage->fetchColumn(); - } - } - - if ($dispatchId === false) { - $existingDispatch = $pdo->prepare( - "SELECT id FROM public.location - WHERE warehouse_id = :warehouse_id AND type = 'dispatch' - ORDER BY id LIMIT 1" - ); - $existingDispatch->execute([':warehouse_id' => $warehouseId]); - $dispatchId = $existingDispatch->fetchColumn(); - if ($dispatchId === false) { - $insertDispatch = $pdo->prepare( - "INSERT INTO public.location (warehouse_id, code, name, type, created_at, updated_at) - VALUES (:warehouse_id, 'DISPATCH', 'Dispatch', 'dispatch', NOW(), NOW()) - RETURNING id" - ); - $insertDispatch->execute([':warehouse_id' => $warehouseId]); - $dispatchId = $insertDispatch->fetchColumn(); - } - } - } - - if ($storageId === false || $dispatchId === false) { - throw new RuntimeException('Missing required locations after auto-bootstrap'); - } - - return [ - 'storage' => (int) $storageId, - 'dispatch' => (int) $dispatchId, - ]; -} - -function resolve_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int -{ - $articleNumber = trim($articleNumber); - $title = trim($title); - $titleNorm = normalize_title_key($title); - - $stmt = $pdo->prepare( - "SELECT sellable_item_id - FROM public.external_item_alias - WHERE source_system = 'wix' - AND is_active = TRUE - AND ( - (:article_number <> '' AND external_article_number = :article_number) - OR (:title_norm <> '' AND title_normalized = :title_norm) - OR (:title <> '' AND lower(external_title) = lower(:title)) - ) - ORDER BY - CASE - WHEN :article_number <> '' AND external_article_number = :article_number THEN 0 - WHEN :title_norm <> '' AND title_normalized = :title_norm THEN 1 - ELSE 2 - END, - id - LIMIT 1" - ); - $stmt->execute([ - ':article_number' => $articleNumber, - ':title_norm' => $titleNorm, - ':title' => $title, - ]); - $id = $stmt->fetchColumn(); - - return $id === false ? null : (int) $id; -} - -function get_item_components(PDO $pdo, int $sellableItemId): array -{ - $stmt = $pdo->prepare( - 'SELECT product_id, qty_per_item - FROM public.sellable_item_component - WHERE sellable_item_id = :sellable_item_id - ORDER BY id' - ); - $stmt->execute([':sellable_item_id' => $sellableItemId]); - return $stmt->fetchAll(); -} - -function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $title): ?int -{ - $title = trim($title); - - if ($title !== '') { - $stmt = $pdo->prepare('SELECT id FROM public.product WHERE lower(name) = lower(:name) ORDER BY id LIMIT 1'); - $stmt->execute([':name' => $title]); - $id = $stmt->fetchColumn(); - if ($id !== false) { - return (int) $id; - } - } - - return null; -} - -function detect_product_family_key(string $normalizedName): ?string -{ - if (str_contains($normalizedName, 'lion') && str_contains($normalizedName, 'mane')) { - return 'lionsmane'; - } - if (str_contains($normalizedName, 'chaga')) { - return 'chaga'; - } - if (str_contains($normalizedName, 'reishi')) { - return 'reishi'; - } - if (str_contains($normalizedName, 'shiitake')) { - return 'shiitake'; - } - return null; -} - -function title_contains_family(string $normalizedTitle, string $familyKey): bool -{ - if ($familyKey === 'lionsmane') { - return str_contains($normalizedTitle, 'lion') && str_contains($normalizedTitle, 'mane'); - } - return str_contains($normalizedTitle, $familyKey); -} - -function infer_components_from_title(PDO $pdo, string $title): array -{ - $normalizedTitle = normalize_match_key($title); - if ($normalizedTitle === '') { - return []; - } - - $products = $pdo->query("SELECT id, name FROM public.product WHERE status = 'active' ORDER BY id")->fetchAll(); - $components = []; - $usedProductIds = []; - - foreach ($products as $product) { - $productId = (int) $product['id']; - $productNameNorm = normalize_match_key((string) ($product['name'] ?? '')); - $familyKey = detect_product_family_key($productNameNorm); - if ($familyKey === null) { - continue; - } - - if (!title_contains_family($normalizedTitle, $familyKey)) { - continue; - } - - if (isset($usedProductIds[$productId])) { - continue; - } - - $components[] = [ - 'product_id' => $productId, - 'qty_per_item' => 1.0, - ]; - $usedProductIds[$productId] = true; - } - - return $components; -} - -function find_alias_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int -{ - $articleNumber = trim($articleNumber); - $title = trim($title); - $titleNorm = normalize_title_key($title); - - $stmt = $pdo->prepare( - "SELECT sellable_item_id - FROM public.external_item_alias - WHERE source_system = 'wix' - AND ( - (:article_number <> '' AND external_article_number = :article_number) - OR (:title_norm <> '' AND title_normalized = :title_norm) - OR (:title <> '' AND lower(external_title) = lower(:title)) - ) - ORDER BY id - LIMIT 1 - FOR UPDATE" - ); - $stmt->execute([ - ':article_number' => $articleNumber, - ':title_norm' => $titleNorm, - ':title' => $title, - ]); - $id = $stmt->fetchColumn(); - return $id === false ? null : (int) $id; -} - -function ensure_sellable_mapping_from_title_components( - PDO $pdo, - string $articleNumber, - string $title, - array $components -): array { - if ($components === []) { - throw new RuntimeException('Cannot create sellable mapping without components'); - } - - $sellableItemId = find_alias_sellable_item_id($pdo, $articleNumber, $title); - $createdSellable = false; - - if ($sellableItemId === null || !sellable_item_exists($pdo, $sellableItemId)) { - $itemCodeSeed = trim($articleNumber) !== '' ? trim($articleNumber) : $title; - $itemCode = ensure_unique_sellable_item_code($pdo, $itemCodeSeed); - $displayName = trim($title) !== '' ? trim($title) : $itemCode; - - $insertSellable = $pdo->prepare( - 'INSERT INTO public.sellable_item (item_code, display_name, status, created_at, updated_at) - VALUES (:item_code, :display_name, \'active\', NOW(), NOW()) - RETURNING id' - ); - $insertSellable->execute([ - ':item_code' => $itemCode, - ':display_name' => $displayName, - ]); - $id = $insertSellable->fetchColumn(); - if ($id === false) { - throw new RuntimeException('Could not create sellable_item from title components'); - } - $sellableItemId = (int) $id; - $createdSellable = true; - } else { - $updateName = $pdo->prepare( - 'UPDATE public.sellable_item - SET display_name = :display_name, updated_at = NOW() - WHERE id = :id' - ); - $updateName->execute([ - ':display_name' => trim($title) !== '' ? trim($title) : "AUTO-ITEM-{$sellableItemId}", - ':id' => $sellableItemId, - ]); - } - - $syncComponent = $pdo->prepare( - 'INSERT INTO public.sellable_item_component (sellable_item_id, product_id, qty_per_item, created_at, updated_at) - VALUES (:sellable_item_id, :product_id, :qty_per_item, NOW(), NOW()) - ON CONFLICT (sellable_item_id, product_id) - DO UPDATE SET qty_per_item = EXCLUDED.qty_per_item, updated_at = NOW()' - ); - $componentIds = []; - foreach ($components as $component) { - $productId = (int) $component['product_id']; - $qtyPerItem = (float) $component['qty_per_item']; - if ($productId <= 0 || $qtyPerItem <= 0) { - continue; - } - $syncComponent->execute([ - ':sellable_item_id' => $sellableItemId, - ':product_id' => $productId, - ':qty_per_item' => $qtyPerItem, - ]); - $componentIds[] = $productId; - } - - if ($componentIds !== []) { - $placeholders = []; - $params = [':sellable_item_id' => $sellableItemId]; - foreach ($componentIds as $idx => $productId) { - $key = ':product_id_' . $idx; - $placeholders[] = $key; - $params[$key] = $productId; - } - $deleteStmt = $pdo->prepare( - 'DELETE FROM public.sellable_item_component - WHERE sellable_item_id = :sellable_item_id - AND product_id NOT IN (' . implode(', ', $placeholders) . ')' - ); - $deleteStmt->execute($params); - } - - $aliasChanged = ensure_alias_points_to_sellable_item($pdo, $sellableItemId, $articleNumber, $title); - - return [ - 'sellableItemId' => $sellableItemId, - 'createdSellableItem' => $createdSellable, - 'aliasCreatedOrUpdated' => $aliasChanged, - ]; -} - -function sellable_item_exists(PDO $pdo, int $sellableItemId): bool -{ - $stmt = $pdo->prepare('SELECT 1 FROM public.sellable_item WHERE id = :id LIMIT 1'); - $stmt->execute([':id' => $sellableItemId]); - return $stmt->fetchColumn() !== false; -} - -function find_sellable_item_for_product(PDO $pdo, int $productId): ?int -{ - $stmt = $pdo->prepare( - 'SELECT sellable_item_id - FROM public.sellable_item_component - WHERE product_id = :product_id - ORDER BY id - LIMIT 1' - ); - $stmt->execute([':product_id' => $productId]); - $id = $stmt->fetchColumn(); - return $id === false ? null : (int) $id; -} - -function find_product_name(PDO $pdo, int $productId): ?string -{ - $stmt = $pdo->prepare('SELECT name FROM public.product WHERE id = :id LIMIT 1'); - $stmt->execute([':id' => $productId]); - $name = $stmt->fetchColumn(); - if ($name === false) { - return null; - } - $name = trim((string) $name); - return $name === '' ? null : $name; -} - -function ensure_unique_sellable_item_code(PDO $pdo, string $preferred): string -{ - $base = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($preferred)) ?? ''; - $base = trim($base, '-'); - if ($base === '') { - $base = 'AUTO-ITEM'; - } - $base = strtoupper(substr($base, 0, 60)); - - $existsStmt = $pdo->prepare('SELECT 1 FROM public.sellable_item WHERE item_code = :item_code LIMIT 1'); - $candidate = $base; - $suffix = 1; - - while (true) { - $existsStmt->execute([':item_code' => $candidate]); - if ($existsStmt->fetchColumn() === false) { - return $candidate; - } - - $suffix++; - $prefixMaxLen = max(1, 60 - strlen((string) $suffix) - 1); - $candidate = substr($base, 0, $prefixMaxLen) . '-' . $suffix; - } -} - -function ensure_alias_points_to_sellable_item( - PDO $pdo, - int $sellableItemId, - string $articleNumber, - string $title -): bool { - $articleNumber = trim($articleNumber); - $title = trim($title); - $titleNorm = normalize_title_key($title); - - if ($articleNumber === '' && $title === '') { - return false; - } - - $findExisting = $pdo->prepare( - "SELECT id, sellable_item_id - FROM public.external_item_alias - WHERE source_system = 'wix' - AND ( - (:article_number <> '' AND external_article_number = :article_number) - OR (:title_norm <> '' AND title_normalized = :title_norm) - OR (:title <> '' AND lower(external_title) = lower(:title)) - ) - ORDER BY id - LIMIT 1 - FOR UPDATE" - ); - $findExisting->execute([ - ':article_number' => $articleNumber, - ':title_norm' => $titleNorm, - ':title' => $title, - ]); - $existing = $findExisting->fetch(); - - if (is_array($existing)) { - $existingSellable = isset($existing['sellable_item_id']) ? (int) $existing['sellable_item_id'] : 0; - $aliasId = isset($existing['id']) ? (int) $existing['id'] : 0; - if ($existingSellable === $sellableItemId) { - $touchStmt = $pdo->prepare('UPDATE public.external_item_alias SET is_active = TRUE, updated_at = NOW() WHERE id = :id'); - $touchStmt->execute([':id' => $aliasId]); - return false; - } - - $updateStmt = $pdo->prepare( - 'UPDATE public.external_item_alias - SET sellable_item_id = :sellable_item_id, - external_article_number = :article_number, - external_title = :title, - title_normalized = :title_norm, - is_active = TRUE, - updated_at = NOW() - WHERE id = :id' - ); - $updateStmt->execute([ - ':sellable_item_id' => $sellableItemId, - ':article_number' => $articleNumber !== '' ? $articleNumber : null, - ':title' => $title !== '' ? $title : null, - ':title_norm' => $titleNorm !== '' ? $titleNorm : null, - ':id' => $aliasId, - ]); - return true; - } - - $insertStmt = $pdo->prepare( - "INSERT INTO public.external_item_alias ( - source_system, external_article_number, external_title, title_normalized, sellable_item_id, is_active, created_at, updated_at - ) VALUES ( - 'wix', :article_number, :title, :title_norm, :sellable_item_id, TRUE, NOW(), NOW() - )" - ); - $insertStmt->execute([ - ':article_number' => $articleNumber !== '' ? $articleNumber : null, - ':title' => $title !== '' ? $title : null, - ':title_norm' => $titleNorm !== '' ? $titleNorm : null, - ':sellable_item_id' => $sellableItemId, - ]); - return true; -} - -function ensure_sellable_mapping_from_product_fallback( - PDO $pdo, - int $productId, - string $articleNumber, - string $title -): array { - $articleNumber = trim($articleNumber); - $title = trim($title); - - $sellableItemId = find_sellable_item_for_product($pdo, $productId); - $createdSellable = false; - if ($sellableItemId === null || !sellable_item_exists($pdo, $sellableItemId)) { - $productName = find_product_name($pdo, $productId); - $itemCodeSeed = $articleNumber !== '' ? $articleNumber : "AUTO-PROD-{$productId}"; - $itemCode = ensure_unique_sellable_item_code($pdo, $itemCodeSeed); - $displayName = $title !== '' ? $title : ($productName ?? $itemCode); - - $insertSellable = $pdo->prepare( - 'INSERT INTO public.sellable_item (item_code, display_name, status, created_at, updated_at) - VALUES (:item_code, :display_name, \'active\', NOW(), NOW()) - RETURNING id' - ); - $insertSellable->execute([ - ':item_code' => $itemCode, - ':display_name' => $displayName, - ]); - $id = $insertSellable->fetchColumn(); - if ($id === false) { - throw new RuntimeException("Could not create sellable_item for fallback product {$productId}"); - } - $sellableItemId = (int) $id; - $createdSellable = true; - } - - $insertComponent = $pdo->prepare( - 'INSERT INTO public.sellable_item_component (sellable_item_id, product_id, qty_per_item, created_at, updated_at) - VALUES (:sellable_item_id, :product_id, 1.0, NOW(), NOW()) - ON CONFLICT (sellable_item_id, product_id) DO NOTHING' - ); - $insertComponent->execute([ - ':sellable_item_id' => $sellableItemId, - ':product_id' => $productId, - ]); - - $aliasChanged = ensure_alias_points_to_sellable_item($pdo, $sellableItemId, $articleNumber, $title); - - return [ - 'sellableItemId' => $sellableItemId, - 'createdSellableItem' => $createdSellable, - 'aliasCreatedOrUpdated' => $aliasChanged, - ]; -} - -function get_current_lot_balance_for_update(PDO $pdo, int $productId): array -{ - $lotStmt = $pdo->prepare( - "SELECT id - FROM public.stock_lot - WHERE product_id = :product_id - AND status = 'current' - ORDER BY id - LIMIT 1 - FOR UPDATE" - ); - $lotStmt->execute([':product_id' => $productId]); - $lotId = $lotStmt->fetchColumn(); - if ($lotId === false) { - throw new RuntimeException("No current lot found for product {$productId}"); - } - - $balStmt = $pdo->prepare('SELECT qty_net FROM public.v_stock_lot_balance WHERE stock_lot_id = :lot_id'); - $balStmt->execute([':lot_id' => (int) $lotId]); - $qtyNet = $balStmt->fetchColumn(); - - return [ - 'lot_id' => (int) $lotId, - 'qty_net' => $qtyNet === false ? 0.0 : (float) $qtyNet, - ]; -} - -function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int -{ - $closeStmt = $pdo->prepare( - "UPDATE public.stock_lot - SET status = 'closed', updated_at = NOW() - WHERE id = :id AND status = 'current'" - ); - $closeStmt->execute([':id' => $oldCurrentLotId]); - - $openStmt = $pdo->prepare( - "SELECT id - FROM public.stock_lot - WHERE product_id = :product_id - AND status = 'open' - ORDER BY id - LIMIT 1 - FOR UPDATE" - ); - $openStmt->execute([':product_id' => $productId]); - $newCurrentLotId = $openStmt->fetchColumn(); - if ($newCurrentLotId === false) { - throw new RuntimeException("No open lot available for product {$productId} during switch"); - } - - $makeCurrentStmt = $pdo->prepare( - "UPDATE public.stock_lot - SET status = 'current', - lot_number = COALESCE(lot_number, :auto_lot_number), - updated_at = NOW() - WHERE id = :id" - ); - $makeCurrentStmt->execute([ - ':id' => (int) $newCurrentLotId, - ':auto_lot_number' => 'AUTO-' . $productId . '-' . (int) $newCurrentLotId, - ]); - - $balStmt = $pdo->prepare('SELECT qty_net FROM public.v_stock_lot_balance WHERE stock_lot_id = :lot_id'); - $balStmt->execute([':lot_id' => (int) $newCurrentLotId]); - $newCurrentQty = $balStmt->fetchColumn(); - $newCurrentQty = $newCurrentQty === false ? 0.0 : (float) $newCurrentQty; - - // Auto-seed newly promoted current lot so allocation can continue without manual stock-in. - if ($newCurrentQty <= 0.0000001) { - insert_stock_move_in( - $pdo, - $productId, - (int) $newCurrentLotId, - 200.0, - $storageLocationId, - "auto-seed-current-lot:product={$productId}:lot=" . (int) $newCurrentLotId - ); - } - - $createOpenStmt = $pdo->prepare( - "INSERT INTO public.stock_lot (product_id, lot_number, status, created_at, updated_at) - VALUES (:product_id, NULL, 'open', NOW(), NOW())" - ); - $createOpenStmt->execute([':product_id' => $productId]); - - return (int) $newCurrentLotId; -} - -function insert_stock_move_out( - PDO $pdo, - int $productId, - int $lotId, - float $qty, - int $fromLocationId, - int $toLocationId, - string $note -): int { - $stmt = $pdo->prepare( - "INSERT INTO public.stock_move ( - product_id, lot_id, from_location_id, to_location_id, qty, move_type, note, move_date, created_at, updated_at - ) VALUES ( - :product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', :note, NOW(), NOW(), NOW() - ) - RETURNING id" - ); - $stmt->execute([ - ':product_id' => $productId, - ':lot_id' => $lotId, - ':from_location_id' => $fromLocationId, - ':to_location_id' => $toLocationId, - ':qty' => $qty, - ':note' => $note, - ]); - $id = $stmt->fetchColumn(); - if ($id === false) { - throw new RuntimeException('Could not create stock_move out'); - } - return (int) $id; -} - -function insert_stock_move_in( - PDO $pdo, - int $productId, - int $lotId, - float $qty, - int $toLocationId, - string $note -): int { - $stmt = $pdo->prepare( - "INSERT INTO public.stock_move ( - product_id, lot_id, from_location_id, to_location_id, qty, move_type, note, move_date, created_at, updated_at - ) VALUES ( - :product_id, :lot_id, NULL, :to_location_id, :qty, 'in', :note, NOW(), NOW(), NOW() - ) - RETURNING id" - ); - $stmt->execute([ - ':product_id' => $productId, - ':lot_id' => $lotId, - ':to_location_id' => $toLocationId, - ':qty' => $qty, - ':note' => $note, - ]); - $id = $stmt->fetchColumn(); - if ($id === false) { - throw new RuntimeException('Could not create stock_move in'); - } - return (int) $id; -} - -function reverse_existing_allocations_for_order(PDO $pdo, int $orderId, int $fallbackStorageLocationId): array -{ - $stmt = $pdo->prepare( - "SELECT - a.id AS allocation_id, - a.product_id, - a.lot_id, - a.qty, - a.stock_move_id, - sm.from_location_id - FROM public.sales_order_line sol - JOIN public.sales_order_line_lot_allocation a ON a.sales_order_line_id = sol.id - LEFT JOIN public.stock_move sm ON sm.id = a.stock_move_id - WHERE sol.sales_order_id = :order_id - AND a.stock_move_id IS NOT NULL" - ); - $stmt->execute([':order_id' => $orderId]); - $rows = $stmt->fetchAll(); - - $reversedMoves = 0; - $reversedQty = 0.0; - - foreach ($rows as $row) { - $qty = (float) $row['qty']; - if ($qty <= 0) { - continue; - } - - $toLocationId = $row['from_location_id'] !== null - ? (int) $row['from_location_id'] - : $fallbackStorageLocationId; - - insert_stock_move_in( - $pdo, - (int) $row['product_id'], - (int) $row['lot_id'], - $qty, - $toLocationId, - "order-import-reverse:order={$orderId}:alloc=" . (int) $row['allocation_id'] - ); - - $reversedMoves++; - $reversedQty += $qty; - } - - return [ - 'reversedMoves' => $reversedMoves, - 'reversedQty' => round($reversedQty, 4), - ]; -} - -function has_available_stock_for_product(PDO $pdo, int $productId, float $epsilon = 0.0000001): bool -{ - $stmt = $pdo->prepare( - "SELECT 1 - FROM public.stock_lot sl - JOIN public.v_stock_lot_balance v ON v.stock_lot_id = sl.id - WHERE sl.product_id = :product_id - AND v.qty_net > :epsilon - LIMIT 1" - ); - $stmt->execute([ - ':product_id' => $productId, - ':epsilon' => $epsilon, - ]); - - return $stmt->fetchColumn() !== false; -} - -function allocate_components_for_line( - PDO $pdo, - int $orderId, - int $lineId, - int $lineNo, - array $components, - float $lineQty, - array $locations -): array { - if ($components === []) { - return [ - 'allocated' => false, - 'reason' => 'no_components', - 'allocations' => [], - ]; - } - - $savepoint = 'sp_alloc_line_' . $lineId; - $pdo->exec("SAVEPOINT {$savepoint}"); - - $allocationInsert = $pdo->prepare( - "INSERT INTO public.sales_order_line_lot_allocation ( - sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at - ) VALUES ( - :sales_order_line_id, :product_id, :lot_id, :qty, 'allocated', :stock_move_id, NOW(), NOW() - )" - ); - - $allocations = []; - - foreach ($components as $component) { - $productId = (int) $component['product_id']; - $required = $lineQty * (float) $component['qty_per_item']; - $remaining = $required; - $guard = 0; - - while ($remaining > 0.0000001) { - $guard++; - if ($guard > 100) { - $pdo->exec("ROLLBACK TO SAVEPOINT {$savepoint}"); - $pdo->exec("RELEASE SAVEPOINT {$savepoint}"); - return [ - 'allocated' => false, - 'reason' => "loop_guard_exceeded:product={$productId}", - 'allocations' => [], - ]; - } - - $current = get_current_lot_balance_for_update($pdo, $productId); - $lotId = (int) $current['lot_id']; - $available = (float) $current['qty_net']; - - if ($available <= 0.0000001) { - switch_current_lot($pdo, $productId, $lotId, (int) $locations['storage']); - continue; - } - - $take = min($remaining, $available); - $note = "order-import:order={$orderId}:line={$lineNo}:product={$productId}"; - $stockMoveId = insert_stock_move_out( - $pdo, - $productId, - $lotId, - $take, - $locations['storage'], - $locations['dispatch'], - $note - ); - - $allocationInsert->execute([ - ':sales_order_line_id' => $lineId, - ':product_id' => $productId, - ':lot_id' => $lotId, - ':qty' => $take, - ':stock_move_id' => $stockMoveId, - ]); - - $allocations[] = [ - 'productId' => $productId, - 'lotId' => $lotId, - 'qty' => round($take, 4), - 'stockMoveId' => $stockMoveId, - ]; - - $remaining -= $take; - } - } - - $pdo->exec("RELEASE SAVEPOINT {$savepoint}"); - - return [ - 'allocated' => true, - 'reason' => '', - 'allocations' => $allocations, - ]; -} - -function allocate_line_inventory( - PDO $pdo, - int $orderId, - int $lineId, - int $lineNo, - float $lineQty, - int $sellableItemId, - array $locations -): array { - $components = get_item_components($pdo, $sellableItemId); - return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations); -} - -function allocate_line_inventory_fallback_product( - PDO $pdo, - int $orderId, - int $lineId, - int $lineNo, - float $lineQty, - int $productId, - array $locations -): array { - $components = [[ - 'product_id' => $productId, - 'qty_per_item' => 1.0, - ]]; - return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations); -} - if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); } -$env = parse_env_file(__DIR__ . '/.env'); -$env = array_merge($env, parse_env_file(dirname(__DIR__) . '/.env')); +$env = parse_env_file(__DIR__ . '/../../../.env'); $env = expand_env_values($env); $expectedSecret = env_value('N8N_WEBHOOK_SECRET', $env); @@ -1411,7 +44,6 @@ if (!is_array($data)) { json_response(400, ['ok' => false, 'error' => 'JSON object expected']); } -// n8n can send either a JSON object or a single-item array with the order object. if (array_is_list($data)) { if (!isset($data[0]) || !is_array($data[0])) { json_response(400, ['ok' => false, 'error' => 'Array payload must contain one order object']); @@ -1433,6 +65,7 @@ try { $pdo = connect_database($env); ensure_required_tables_exist($pdo); $pdo->beginTransaction(); + $locations = get_default_location_ids($pdo); $existingOrderId = find_existing_order_id($pdo, $externalRef); @@ -1442,36 +75,12 @@ try { $paymentMethodId = lookup_method_id($pdo, 'payment_method', map_payment_code((string) ($data['Zahlungsmethode'] ?? ''))); $shippingMethodId = lookup_method_id($pdo, 'shipping_method', map_shipping_code((string) ($data['Liefermethode'] ?? ''))); - $orderStmt = $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, \'wix\', \'imported\', \'paid\', :payment_method_id, :shipping_method_id, - :amount_net, :amount_shipping, :amount_tax, :amount_discount, :total_amount, \'CHF\', :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' - ); - - $orderStmt->execute([ + $orderId = upsert_sales_order($pdo, [ ':external_ref' => $externalRef, ':party_id' => $partyId, + ':order_source' => 'wix', + ':order_status' => 'imported', + ':payment_status' => 'paid', ':payment_method_id' => $paymentMethodId, ':shipping_method_id' => $shippingMethodId, ':amount_net' => parse_number($data['Netto'] ?? null), @@ -1479,15 +88,10 @@ try { ':amount_tax' => parse_number($data['Mehrwertsteuer'] ?? null), ':amount_discount' => parse_number($data['Rabatt'] ?? null), ':total_amount' => parse_number($data['Gesamtsumme'] ?? null), + ':currency' => 'CHF', ':webhook_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); - $orderId = $orderStmt->fetchColumn(); - if ($orderId === false) { - throw new RuntimeException('Could not upsert order'); - } - $orderId = (int) $orderId; - $inventoryRollback = [ 'reversedMoves' => 0, 'reversedQty' => 0.0, @@ -1496,24 +100,11 @@ try { $inventoryRollback = reverse_existing_allocations_for_order($pdo, $existingOrderId, $locations['storage']); } - $deleteLines = $pdo->prepare('DELETE FROM public.sales_order_line WHERE sales_order_id = :sales_order_id'); - $deleteLines->execute([':sales_order_id' => $orderId]); - - $lineInsert = $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' - ); + delete_sales_order_lines($pdo, $orderId); $insertedLines = 0; $inventory = [ 'linesMapped' => 0, - 'linesMappedViaFallbackProduct' => 0, 'linesUnmapped' => 0, 'allocationCount' => 0, 'warnings' => [], @@ -1534,10 +125,9 @@ try { $unitPrice = parse_number($lineItem['preisEinheit'] ?? null); $lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null; $sellableItemId = resolve_sellable_item_id($pdo, $articleNumber, $title); - $autoMappingMeta = null; $lineNo = $index + 1; - $lineInsert->execute([ + $lineId = insert_sales_order_line($pdo, [ ':sales_order_id' => $orderId, ':line_no' => $lineNo, ':sellable_item_id' => $sellableItemId, @@ -1547,38 +137,29 @@ try { ':unit_price' => $unitPrice, ':line_total' => $lineTotal, ]); - $lineId = $lineInsert->fetchColumn(); - if ($lineId === false) { - throw new RuntimeException("Could not insert sales_order_line for line {$lineNo}"); - } - $lineId = (int) $lineId; + + $insertedLines++; if ($sellableItemId === null) { $inventory['linesUnmapped']++; $inventory['warnings'][] = "No sellable item mapping for line {$lineNo} (artikelnummer='{$articleNumber}', titel='{$title}')"; - } else { - if (is_array($autoMappingMeta)) { - $inventory['linesMappedViaFallbackProduct']++; - $inventory['warnings'][] = "Line {$lineNo} auto-mapped from title/product fallback (sellable_item_id={$sellableItemId})"; - } - $inventory['linesMapped']++; - $allocationResult = allocate_line_inventory( - $pdo, - $orderId, - $lineId, - $lineNo, - (float) $qty, - $sellableItemId, - $locations - ); - if ($allocationResult['allocated'] === false) { - $inventory['warnings'][] = "No inventory allocation for line {$lineNo}: " . $allocationResult['reason']; - } else { - $inventory['allocationCount'] += count($allocationResult['allocations']); - } + continue; } - $insertedLines++; + $inventory['linesMapped']++; + $allocationResult = allocate_line_inventory( + $pdo, + $orderId, + $lineId, + $lineNo, + (float) $qty, + $sellableItemId, + $locations + ); + + if (($allocationResult['allocated'] ?? false) === true) { + $inventory['allocationCount'] += (int) ($allocationResult['allocationCount'] ?? 0); + } } $pdo->commit(); diff --git a/modules/erp/import-integration/service.php b/modules/erp/import-integration/service.php new file mode 100644 index 0000000..039c103 --- /dev/null +++ b/modules/erp/import-integration/service.php @@ -0,0 +1,163 @@ + false, 'status' => 0, 'body' => '', 'error' => 'Could not encode payload']; + } + + $headerLines = ['Content-Type: application/json']; + foreach ($headers as $name => $value) { + if ($name === '' || $value === '') { + continue; + } + $headerLines[] = $name . ': ' . $value; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headerLines), + 'content' => $body, + 'timeout' => $timeoutSeconds, + 'ignore_errors' => true, + ], + ]); + + $responseBody = @file_get_contents($url, false, $context); + $responseHeaders = $http_response_header ?? []; + + $status = 0; + if (isset($responseHeaders[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $responseHeaders[0], $m) === 1) { + $status = (int) $m[1]; + } + + if ($responseBody === false) { + $responseBody = ''; + } + + return [ + 'ok' => $status >= 200 && $status < 300, + 'status' => $status, + 'body' => substr($responseBody, 0, 500), + 'error' => ($status === 0) ? 'Request failed or timed out' : '', + ]; +} + +function trigger_shipping_label_flow(array $order, array $localEnv): array +{ + $url = derive_label_webhook_url($localEnv); + if ($url === '') { + return [ + 'enabled' => false, + 'ok' => false, + 'message' => 'Label webhook URL not configured', + ]; + } + + $headers = []; + $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); + if ($secret !== '') { + $headers['X-Webhook-Secret'] = $secret; + $headers['X-N8N-Secret'] = $secret; + $headers['X-API-Key'] = $secret; + $headers['Authorization'] = 'Bearer ' . $secret; + } + + throttle_webhook_channel('label', 10); + $result = post_json($url, $order, $headers, 20); + + return [ + 'enabled' => true, + 'ok' => $result['ok'], + 'status' => $result['status'], + 'url' => $url, + 'message' => $result['ok'] ? 'Label webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Label webhook returned non-2xx'), + 'responseBody' => $result['body'], + ]; +} + +function trigger_excel_webhook(string $externalRef, array $localEnv): array +{ + $url = derive_excel_webhook_url($localEnv); + if ($url === '') { + return [ + 'enabled' => false, + 'ok' => false, + 'message' => 'Excel webhook URL not configured', + ]; + } + + $headers = []; + $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); + if ($secret !== '') { + $headers['X-Webhook-Secret'] = $secret; + $headers['X-N8N-Secret'] = $secret; + $headers['X-API-Key'] = $secret; + $headers['Authorization'] = 'Bearer ' . $secret; + } + + throttle_webhook_channel('excel', 10); + $result = post_json($url, ['Bestellnummer' => $externalRef], $headers, 20); + + return [ + 'enabled' => true, + 'ok' => $result['ok'], + 'status' => $result['status'], + 'url' => $url, + 'message' => $result['ok'] ? 'Excel webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Excel webhook returned non-2xx'), + 'responseBody' => $result['body'], + ]; +} diff --git a/modules/erp/kontakte/service.php b/modules/erp/kontakte/service.php new file mode 100644 index 0000000..fbeab54 --- /dev/null +++ b/modules/erp/kontakte/service.php @@ -0,0 +1,81 @@ +prepare('SELECT id FROM party WHERE email = :email LIMIT 1'); + $stmt->execute([':email' => $email]); + $existing = $stmt->fetchColumn(); + if ($existing !== false) { + return (int) $existing; + } + } + + $name = trim((string) ($data['Vorname'] ?? $data['firstName'] ?? '')) . ' ' . trim((string) ($data['Nachname'] ?? $data['lastName'] ?? '')); + $name = trim($name); + if ($name === '') { + $name = trim((string) ($data['Firmenname'] ?? $data['company'] ?? '')); + } + if ($name === '') { + $name = 'Unbekannter Kontakt'; + } + + $stmt = $pdo->prepare( + "INSERT INTO party (type, name, email, status, created_at, updated_at) + VALUES ('customer', :name, :email, 'active', NOW(), NOW()) + RETURNING id" + ); + $stmt->execute([ + ':name' => $name, + ':email' => $email !== '' ? $email : null, + ]); + + $id = $stmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Could not create party'); + } + + return (int) $id; +} + +function upsert_addresses(PDO $pdo, int $partyId, array $data): void +{ + $addressData = [ + 'billing' => [ + 'first_name' => trim((string) ($data['Vorname'] ?? $data['firstName'] ?? '')), + 'last_name' => trim((string) ($data['Nachname'] ?? $data['lastName'] ?? '')), + 'street' => trim((string) ($data['Strasse'] ?? $data['street'] ?? '')), + 'house_number' => trim((string) ($data['Hausnummer'] ?? $data['houseNumber'] ?? '')), + 'zip' => trim((string) ($data['PLZ'] ?? $data['zip'] ?? '')), + 'city' => trim((string) ($data['Ort'] ?? $data['city'] ?? '')), + 'country_name' => trim((string) ($data['Land'] ?? $data['country'] ?? 'Switzerland')), + ], + ]; + + foreach ($addressData as $type => $address) { + if ($address['street'] === '' && $address['city'] === '' && $address['zip'] === '') { + continue; + } + + $stmt = $pdo->prepare( + "INSERT INTO address ( + party_id, type, first_name, last_name, street, house_number, zip, city, country_name, created_at, updated_at + ) VALUES ( + :party_id, :type, :first_name, :last_name, :street, :house_number, :zip, :city, :country_name, NOW(), NOW() + )" + ); + $stmt->execute([ + ':party_id' => $partyId, + ':type' => $type, + ':first_name' => $address['first_name'], + ':last_name' => $address['last_name'], + ':street' => $address['street'], + ':house_number' => $address['house_number'], + ':zip' => $address['zip'], + ':city' => $address['city'], + ':country_name' => $address['country_name'] !== '' ? $address['country_name'] : 'Switzerland', + ]); + } +} diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php new file mode 100644 index 0000000..dff6f56 --- /dev/null +++ b/modules/erp/lager/service.php @@ -0,0 +1,334 @@ +query( + "SELECT + (SELECT id FROM location WHERE type = 'storage' ORDER BY id LIMIT 1) AS storage_id, + (SELECT id FROM location WHERE type = 'dispatch' ORDER BY id LIMIT 1) AS dispatch_id" + )->fetch(); + + if (!is_array($row) || $row['storage_id'] === null || $row['dispatch_id'] === null) { + throw new RuntimeException('Erforderliche Lagerorte fehlen'); + } + + return [ + 'storage' => (int) $row['storage_id'], + 'dispatch' => (int) $row['dispatch_id'], + ]; +} + +function get_current_lot_balance_for_update(PDO $pdo, int $productId): array +{ + $stmt = $pdo->prepare( + "SELECT + sl.id AS lot_id, + COALESCE(( + SELECT v.qty_net + FROM v_stock_lot_balance v + WHERE v.stock_lot_id = sl.id + ), 0) AS qty_net + FROM stock_lot sl + WHERE sl.product_id = :product_id + AND sl.status = 'current' + LIMIT 1 + FOR UPDATE" + ); + $stmt->execute([':product_id' => $productId]); + $row = $stmt->fetch(); + + if ($row === false) { + throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden"); + } + + return [ + 'lot_id' => (int) $row['lot_id'], + 'qty_net' => (float) $row['qty_net'], + ]; +} + +function insert_stock_move_out( + PDO $pdo, + int $productId, + int $lotId, + float $qty, + int $fromLocationId, + int $toLocationId, + string $note +): int { + $stmt = $pdo->prepare( + "INSERT INTO stock_move ( + product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at + ) VALUES ( + :product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', NOW(), :note, NOW(), NOW() + ) + RETURNING id" + ); + $stmt->execute([ + ':product_id' => $productId, + ':lot_id' => $lotId, + ':from_location_id' => $fromLocationId, + ':to_location_id' => $toLocationId, + ':qty' => $qty, + ':note' => $note, + ]); + + $id = $stmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Konnte Lagerabgang nicht schreiben'); + } + + return (int) $id; +} + +function insert_stock_move_in( + PDO $pdo, + int $productId, + int $lotId, + float $qty, + int $fromLocationId, + int $toLocationId, + string $note +): int { + $stmt = $pdo->prepare( + "INSERT INTO stock_move ( + product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at + ) VALUES ( + :product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'in', NOW(), :note, NOW(), NOW() + ) + RETURNING id" + ); + $stmt->execute([ + ':product_id' => $productId, + ':lot_id' => $lotId, + ':from_location_id' => $fromLocationId, + ':to_location_id' => $toLocationId, + ':qty' => $qty, + ':note' => $note, + ]); + + $id = $stmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Konnte Lagerzugang nicht schreiben'); + } + + return (int) $id; +} + +function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int +{ + $closeStmt = $pdo->prepare( + "UPDATE stock_lot + SET status = 'closed', updated_at = NOW() + WHERE id = :id" + ); + $closeStmt->execute([':id' => $oldCurrentLotId]); + + $openStmt = $pdo->prepare( + "SELECT id + FROM stock_lot + WHERE product_id = :product_id + AND status = 'open' + ORDER BY id + LIMIT 1 + FOR UPDATE" + ); + $openStmt->execute([':product_id' => $productId]); + $newCurrentLotId = $openStmt->fetchColumn(); + + if ($newCurrentLotId === false) { + throw new RuntimeException("Keine offene Platzhalter-Charge fuer Produkt {$productId} vorhanden"); + } + + $promoteStmt = $pdo->prepare( + "UPDATE stock_lot + SET status = 'current', + updated_at = NOW() + WHERE id = :id" + ); + $promoteStmt->execute([':id' => (int) $newCurrentLotId]); + + $createOpenStmt = $pdo->prepare( + "INSERT INTO stock_lot (product_id, status, created_at, updated_at) + VALUES (:product_id, 'open', NOW(), NOW()) + RETURNING id" + ); + $createOpenStmt->execute([':product_id' => $productId]); + $createdOpenLotId = $createOpenStmt->fetchColumn(); + if ($createdOpenLotId === false) { + throw new RuntimeException("Konnte keine neue offene Platzhalter-Charge fuer Produkt {$productId} anlegen"); + } + + return (int) $newCurrentLotId; +} + +function reverse_existing_allocations_for_order(PDO $pdo, int $orderId, int $fallbackStorageLocationId): array +{ + $stmt = $pdo->prepare( + "SELECT + a.stock_move_id, + a.product_id, + a.lot_id, + a.qty, + sm.from_location_id, + sm.to_location_id + FROM sales_order_line_lot_allocation a + LEFT JOIN stock_move sm ON sm.id = a.stock_move_id + INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id + WHERE sol.sales_order_id = :order_id + ORDER BY a.id DESC" + ); + $stmt->execute([':order_id' => $orderId]); + $rows = $stmt->fetchAll(); + + $reverseCount = 0; + $reverseQty = 0.0; + + foreach ($rows as $row) { + $productId = (int) $row['product_id']; + $lotId = (int) $row['lot_id']; + $qty = (float) $row['qty']; + $fromLocationId = (int) ($row['from_location_id'] ?? $fallbackStorageLocationId); + $toLocationId = (int) ($row['to_location_id'] ?? $fallbackStorageLocationId); + + insert_stock_move_in( + $pdo, + $productId, + $lotId, + $qty, + $toLocationId, + $fromLocationId, + "reverse-order={$orderId}" + ); + + $reverseCount++; + $reverseQty += $qty; + } + + return [ + 'reversedMoves' => $reverseCount, + 'reversedQty' => $reverseQty, + ]; +} + +function has_available_stock_for_product(PDO $pdo, int $productId, float $epsilon = 0.0000001): bool +{ + $stmt = $pdo->prepare( + "SELECT COALESCE(SUM(v.qty_net), 0) AS qty_net + FROM v_stock_lot_balance v + INNER JOIN stock_lot sl ON sl.id = v.stock_lot_id + WHERE sl.product_id = :product_id + AND sl.status = 'current'" + ); + $stmt->execute([':product_id' => $productId]); + $qty = (float) ($stmt->fetchColumn() ?: 0); + return $qty > $epsilon; +} + +function allocate_components_for_line( + PDO $pdo, + int $orderId, + int $lineId, + int $lineNo, + array $components, + float $lineQty, + array $locations +): array { + if ($components === []) { + throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden"); + } + + $allocationCount = 0; + $allocationInsert = $pdo->prepare( + "INSERT INTO sales_order_line_lot_allocation ( + sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at + ) VALUES ( + :sales_order_line_id, :product_id, :lot_id, :qty, 'allocated', :stock_move_id, NOW(), NOW() + )" + ); + + foreach ($components as $component) { + $productId = (int) $component['product_id']; + $remaining = $lineQty * (float) $component['qty_per_item']; + $guard = 0; + + while ($remaining > 0.0000001) { + $guard++; + if ($guard > 100) { + throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}"); + } + + $current = get_current_lot_balance_for_update($pdo, $productId); + $lotId = $current['lot_id']; + $available = $current['qty_net']; + + if ($available <= 0.0000001) { + $lotId = switch_current_lot($pdo, $productId, $lotId, (int) $locations['storage']); + $current = get_current_lot_balance_for_update($pdo, $productId); + $available = $current['qty_net']; + $lotId = $current['lot_id']; + } + + if ($available <= 0.0000001) { + throw new RuntimeException("Kein verfuegbarer Bestand fuer Produkt {$productId}"); + } + + $take = min($remaining, $available); + $stockMoveId = insert_stock_move_out( + $pdo, + $productId, + $lotId, + $take, + (int) $locations['storage'], + (int) $locations['dispatch'], + "otc-order:order={$orderId}:line={$lineNo}:product={$productId}" + ); + + $allocationInsert->execute([ + ':sales_order_line_id' => $lineId, + ':product_id' => $productId, + ':lot_id' => $lotId, + ':qty' => $take, + ':stock_move_id' => $stockMoveId, + ]); + $allocationCount++; + + $remaining -= $take; + } + } + + return [ + 'allocated' => true, + 'allocationCount' => $allocationCount, + ]; +} + +function allocate_line_inventory( + PDO $pdo, + int $orderId, + int $lineId, + int $lineNo, + float $lineQty, + int $sellableItemId, + array $locations +): array { + $components = get_item_components($pdo, $sellableItemId); + return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations); +} + +function allocate_line_inventory_fallback_product( + PDO $pdo, + int $orderId, + int $lineId, + int $lineNo, + float $lineQty, + int $productId, + array $locations +): array { + $components = [[ + 'product_id' => $productId, + 'qty_per_item' => 1.0, + ]]; + return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations); +}