diff --git a/deploy.php b/deploy.php
index 337c0a1..1e4b884 100644
--- a/deploy.php
+++ b/deploy.php
@@ -1,55 +1,4 @@
/dev/null 2>&1 &');
-echo 'Deploy triggered';
+require_once __DIR__ . '/modules/system/deploy.php';
diff --git a/docs/architektur/database/module_db_ownership.md b/docs/architektur/database/module_db_ownership.md
new file mode 100644
index 0000000..8c18a47
--- /dev/null
+++ b/docs/architektur/database/module_db_ownership.md
@@ -0,0 +1,51 @@
+# DB Ownership
+Stand: 2026-06-15
+Status: Verbindliche Ownership-Referenz
+
+## 1. Zweck
+
+Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte den owning Modulen zu.
+
+## 2. Phase-1 Ownership
+
+### `kontakte`
+
+- `party`
+- `address`
+- `contact`
+
+### `bestellungen`
+
+- `sales_order`
+- `sales_order_line`
+- `sales_order_line_lot_allocation`
+- `payment_method`
+- `shipping_method`
+
+### `lager`
+
+- `product`
+- `warehouse`
+- `location`
+- `stock_lot`
+- `stock_move`
+- `v_stock_lot_balance`
+
+### `artikel-mapping`
+
+- `sellable_item`
+- `external_item_alias`
+- `sellable_item_component`
+
+### `system`
+
+- `audit_log`
+- `outbound_webhook_event`
+
+## 3. Hinweise
+
+- Die Ownership ist fachlich und technisch bindend.
+- Fremde Module schreiben nicht direkt in diese Artefakte.
+- `shared` besitzt keine fachlichen Tabellen.
+- Neue Tabellen fuer `buchhaltung` und `kundenberatung` werden erst mit deren Modulvertraegen definiert.
+
diff --git a/includes/db.php b/includes/db.php
index 0f7ca32..ce9f51e 100644
--- a/includes/db.php
+++ b/includes/db.php
@@ -1,151 +1,4 @@
= 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 connect_database(): PDO
-{
- $env = parse_env_file(__DIR__ . '/../.env');
- $env = expand_env_values($env);
-
- $databaseUrl = env_value('DATABASE_URL', $env);
- 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', $env);
- $port = env_value('DB_PORT', $env, '5432');
- $dbName = env_value('DB_NAME', $env);
- $user = env_value('DB_USER', $env);
- $pass = env_value('DB_PASSWORD', $env);
-
- 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 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 json_response(int $status, array $payload): void
-{
- http_response_code($status);
- header('Content-Type: application/json; charset=utf-8');
- echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- exit;
-}
\ No newline at end of file
+require_once __DIR__ . '/../modules/shared/db.php';
diff --git a/includes/webhook_throttle.php b/includes/webhook_throttle.php
index eb063a0..d4fe307 100644
--- a/includes/webhook_throttle.php
+++ b/includes/webhook_throttle.php
@@ -1,42 +1,4 @@
0) {
- usleep((int) ceil($waitSeconds * 1000000));
- }
-
- $sentAt = microtime(true);
- rewind($handle);
- ftruncate($handle, 0);
- fwrite($handle, sprintf('%.6f', $sentAt));
- fflush($handle);
- } finally {
- flock($handle, LOCK_UN);
- fclose($handle);
- }
-}
+require_once __DIR__ . '/../modules/shared/webhook_throttle.php';
diff --git a/modules/erp/direktverkauf/api/otc-order.php b/modules/erp/direktverkauf/api/otc-order.php
new file mode 100644
index 0000000..815944b
--- /dev/null
+++ b/modules/erp/direktverkauf/api/otc-order.php
@@ -0,0 +1,649 @@
+ 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') {
+ json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
+}
+
+$jsonInput = file_get_contents('php://input');
+if ($jsonInput === false || trim($jsonInput) === '') {
+ json_response(400, ['ok' => false, 'error' => 'Empty payload']);
+}
+
+try {
+ $data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
+} catch (JsonException) {
+ json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
+}
+
+if (!is_array($data)) {
+ json_response(400, ['ok' => false, 'error' => 'JSON object expected']);
+}
+
+$required = ['products', 'totalPrice', 'paymentMethod', 'billing'];
+foreach ($required as $field) {
+ if (!array_key_exists($field, $data)) {
+ json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
+ }
+}
+
+$products = $data['products'];
+$totalPrice = parse_number($data['totalPrice']);
+$paymentMethodCode = trim((string) $data['paymentMethod']);
+$billing = is_array($data['billing']) ? $data['billing'] : [];
+
+if (!is_array($products) || count($products) === 0) {
+ json_response(422, ['ok' => false, 'error' => 'No products specified']);
+}
+
+if ($totalPrice === null || $totalPrice <= 0) {
+ json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
+}
+
+$resolvedProducts = [];
+$totalQty = 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']);
+ }
+
+ $qty = parse_number($product['qty']);
+ $title = trim((string) $product['title']);
+ if ($qty === null || $qty <= 0 || $title === '') {
+ json_response(422, ['ok' => false, 'error' => 'Invalid product quantity or title']);
+ }
+
+ $resolvedProducts[] = [
+ 'qty' => (float) $qty,
+ 'title' => $title,
+ ];
+ $totalQty += (float) $qty;
+}
+
+if ($totalQty <= 0) {
+ json_response(422, ['ok' => false, 'error' => 'No product quantity specified']);
+}
+
+try {
+ $pdo = connect_database();
+ $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) {
+ throw new RuntimeException('Invalid payment method');
+ }
+
+ $partyName = trim(((string) ($billing['firstName'] ?? '')) . ' ' . ((string) ($billing['lastName'] ?? '')));
+ $partyName = $partyName === '' ? 'OTC Kunde' : $partyName;
+
+ $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([
+ ':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,
+ ':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) {
+ $lineNo++;
+ $qty = (float) $product['qty'];
+ $title = $product['title'];
+
+ $resolvedProduct = resolve_otc_product($pdo, $title);
+ $resolvedSellable = resolve_otc_sellable_item($pdo, (int) $resolvedProduct['id']);
+
+ if ($lineNo === count($resolvedProducts)) {
+ $unitPrice = round($remainingTotal / $qty, 4);
+ $lineTotal = $remainingTotal;
+ } else {
+ $unitPrice = round((float) $totalPrice / (float) $totalQty, 4);
+ $lineTotal = round($qty * $unitPrice, 2);
+ $remainingTotal = round($remainingTotal - $lineTotal, 2);
+ $remainingQty -= $qty;
+ }
+
+ $lineInsert->execute([
+ ':sales_order_id' => $orderId,
+ ':line_no' => $lineNo,
+ ':sellable_item_id' => (int) $resolvedSellable['id'],
+ ':article_number' => $resolvedSellable['article_number'],
+ ':title' => $resolvedSellable['display_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(
+ $pdo,
+ $orderId,
+ (int) $lineId,
+ $lineNo,
+ $components,
+ $qty,
+ $locations
+ );
+ }
+
+ $pdo->commit();
+
+ $env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
+ $excelTrigger = trigger_excel_webhook($externalRef, $env);
+
+ json_response(201, [
+ 'ok' => true,
+ 'orderId' => $orderId,
+ 'externalRef' => $externalRef,
+ 'partyId' => $partyId,
+ 'excelTrigger' => $excelTrigger,
+ 'message' => 'OTC order created successfully',
+ ]);
+} catch (Throwable $e) {
+ if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+
+ json_response(500, [
+ 'ok' => false,
+ 'error' => 'Internal server error: ' . $e->getMessage(),
+ ]);
+}
diff --git a/modules/erp/direktverkauf/ui/index.php b/modules/erp/direktverkauf/ui/index.php
new file mode 100644
index 0000000..e5804a5
--- /dev/null
+++ b/modules/erp/direktverkauf/ui/index.php
@@ -0,0 +1,586 @@
+
+
+
+
+
+ OTC-Verkauf
+
+
+
+
+
OTC-Verkauf
+
+
+
+
+
+
+
+
+ Total Flaschen:
+ 0
+
+
+ Preis pro Flasche:
+ CHF 0.00
+
+
+ Gesamtpreis:
+ CHF 0.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert.
+
+
+
+
+
+
Bestellung erhalten
+
Die Bestellung wurde erfolgreich im System gespeichert.
+
Bestellnummer wird angezeigt, sobald sie vorliegt.
+
+
+
+
+
+
+
diff --git a/modules/erp/import-integration/order-import.php b/modules/erp/import-integration/order-import.php
new file mode 100644
index 0000000..9e3f37c
--- /dev/null
+++ b/modules/erp/import-integration/order-import.php
@@ -0,0 +1,1609 @@
+= 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 = expand_env_values($env);
+
+$expectedSecret = env_value('N8N_WEBHOOK_SECRET', $env);
+$providedSecret = (string) ($_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '');
+
+if ($expectedSecret === '') {
+ json_response(500, ['ok' => false, 'error' => 'N8N_WEBHOOK_SECRET not configured']);
+}
+
+if ($providedSecret === '' || !hash_equals($expectedSecret, $providedSecret)) {
+ json_response(401, ['ok' => false, 'error' => 'Unauthorized']);
+}
+
+$rawPayload = file_get_contents('php://input');
+if ($rawPayload === false || trim($rawPayload) === '') {
+ json_response(400, ['ok' => false, 'error' => 'Empty payload']);
+}
+
+try {
+ $data = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
+} catch (JsonException) {
+ json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
+}
+
+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']);
+ }
+ $data = $data[0];
+}
+
+$externalRef = trim((string) ($data['BestellungNr'] ?? ''));
+if ($externalRef === '') {
+ json_response(422, ['ok' => false, 'error' => 'BestellungNr is required']);
+}
+
+$lineItems = $data['lineItems'] ?? [];
+if (!is_array($lineItems)) {
+ $lineItems = [];
+}
+
+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);
+
+ $partyId = find_or_create_party($pdo, $data);
+ upsert_addresses($pdo, $partyId, $data);
+
+ $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([
+ ':external_ref' => $externalRef,
+ ':party_id' => $partyId,
+ ':payment_method_id' => $paymentMethodId,
+ ':shipping_method_id' => $shippingMethodId,
+ ':amount_net' => parse_number($data['Netto'] ?? null),
+ ':amount_shipping' => parse_number($data['Versandkosten'] ?? null),
+ ':amount_tax' => parse_number($data['Mehrwertsteuer'] ?? null),
+ ':amount_discount' => parse_number($data['Rabatt'] ?? null),
+ ':total_amount' => parse_number($data['Gesamtsumme'] ?? null),
+ ':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,
+ ];
+ if ($existingOrderId !== null) {
+ $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'
+ );
+
+ $insertedLines = 0;
+ $inventory = [
+ 'linesMapped' => 0,
+ 'linesMappedViaFallbackProduct' => 0,
+ 'linesUnmapped' => 0,
+ 'allocationCount' => 0,
+ 'warnings' => [],
+ ];
+
+ foreach ($lineItems as $index => $lineItem) {
+ if (!is_array($lineItem)) {
+ continue;
+ }
+
+ $articleNumber = trim((string) ($lineItem['artikelnummer'] ?? ''));
+ $title = trim((string) ($lineItem['titel'] ?? ''));
+ $qty = parse_number($lineItem['artikelanzahl'] ?? null);
+ if ($qty === null || $qty <= 0) {
+ continue;
+ }
+
+ $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([
+ ':sales_order_id' => $orderId,
+ ':line_no' => $lineNo,
+ ':sellable_item_id' => $sellableItemId,
+ ':article_number' => $articleNumber,
+ ':title' => $title,
+ ':qty' => $qty,
+ ':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;
+
+ 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']);
+ }
+ }
+
+ $insertedLines++;
+ }
+
+ $pdo->commit();
+
+ $labelTrigger = trigger_shipping_label_flow($data, $env);
+ $excelTrigger = trigger_excel_webhook($externalRef, $env);
+
+ json_response(200, [
+ 'ok' => true,
+ 'orderId' => $orderId,
+ 'externalRef' => $externalRef,
+ 'lineItemsImported' => $insertedLines,
+ 'inventory' => $inventory,
+ 'inventoryRollback' => $inventoryRollback,
+ 'labelTrigger' => $labelTrigger,
+ 'excelTrigger' => $excelTrigger,
+ ]);
+} catch (Throwable $e) {
+ if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+
+ json_response(500, [
+ 'ok' => false,
+ 'error' => 'Order import failed',
+ 'detail' => $e->getMessage(),
+ ]);
+}
diff --git a/modules/shared/db.php b/modules/shared/db.php
new file mode 100644
index 0000000..54e224f
--- /dev/null
+++ b/modules/shared/db.php
@@ -0,0 +1,151 @@
+= 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 connect_database(): PDO
+{
+ $env = parse_env_file(__DIR__ . '/../.env');
+ $env = expand_env_values($env);
+
+ $databaseUrl = env_value('DATABASE_URL', $env);
+ 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', $env);
+ $port = env_value('DB_PORT', $env, '5432');
+ $dbName = env_value('DB_NAME', $env);
+ $user = env_value('DB_USER', $env);
+ $pass = env_value('DB_PASSWORD', $env);
+
+ 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 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 json_response(int $status, array $payload): void
+{
+ http_response_code($status);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
diff --git a/modules/shared/webhook_throttle.php b/modules/shared/webhook_throttle.php
new file mode 100644
index 0000000..eb063a0
--- /dev/null
+++ b/modules/shared/webhook_throttle.php
@@ -0,0 +1,42 @@
+ 0) {
+ usleep((int) ceil($waitSeconds * 1000000));
+ }
+
+ $sentAt = microtime(true);
+ rewind($handle);
+ ftruncate($handle, 0);
+ fwrite($handle, sprintf('%.6f', $sentAt));
+ fflush($handle);
+ } finally {
+ flock($handle, LOCK_UN);
+ fclose($handle);
+ }
+}
diff --git a/modules/system/deploy.php b/modules/system/deploy.php
new file mode 100644
index 0000000..337c0a1
--- /dev/null
+++ b/modules/system/deploy.php
@@ -0,0 +1,55 @@
+ /dev/null 2>&1 &');
+echo 'Deploy triggered';
diff --git a/order-import.php b/order-import.php
index 11200bc..ebe996a 100644
--- a/order-import.php
+++ b/order-import.php
@@ -1,1609 +1,4 @@
= 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 = expand_env_values($env);
-
-$expectedSecret = env_value('N8N_WEBHOOK_SECRET', $env);
-$providedSecret = (string) ($_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '');
-
-if ($expectedSecret === '') {
- json_response(500, ['ok' => false, 'error' => 'N8N_WEBHOOK_SECRET not configured']);
-}
-
-if ($providedSecret === '' || !hash_equals($expectedSecret, $providedSecret)) {
- json_response(401, ['ok' => false, 'error' => 'Unauthorized']);
-}
-
-$rawPayload = file_get_contents('php://input');
-if ($rawPayload === false || trim($rawPayload) === '') {
- json_response(400, ['ok' => false, 'error' => 'Empty payload']);
-}
-
-try {
- $data = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
-} catch (JsonException) {
- json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
-}
-
-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']);
- }
- $data = $data[0];
-}
-
-$externalRef = trim((string) ($data['BestellungNr'] ?? ''));
-if ($externalRef === '') {
- json_response(422, ['ok' => false, 'error' => 'BestellungNr is required']);
-}
-
-$lineItems = $data['lineItems'] ?? [];
-if (!is_array($lineItems)) {
- $lineItems = [];
-}
-
-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);
-
- $partyId = find_or_create_party($pdo, $data);
- upsert_addresses($pdo, $partyId, $data);
-
- $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([
- ':external_ref' => $externalRef,
- ':party_id' => $partyId,
- ':payment_method_id' => $paymentMethodId,
- ':shipping_method_id' => $shippingMethodId,
- ':amount_net' => parse_number($data['Netto'] ?? null),
- ':amount_shipping' => parse_number($data['Versandkosten'] ?? null),
- ':amount_tax' => parse_number($data['Mehrwertsteuer'] ?? null),
- ':amount_discount' => parse_number($data['Rabatt'] ?? null),
- ':total_amount' => parse_number($data['Gesamtsumme'] ?? null),
- ':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,
- ];
- if ($existingOrderId !== null) {
- $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'
- );
-
- $insertedLines = 0;
- $inventory = [
- 'linesMapped' => 0,
- 'linesMappedViaFallbackProduct' => 0,
- 'linesUnmapped' => 0,
- 'allocationCount' => 0,
- 'warnings' => [],
- ];
-
- foreach ($lineItems as $index => $lineItem) {
- if (!is_array($lineItem)) {
- continue;
- }
-
- $articleNumber = trim((string) ($lineItem['artikelnummer'] ?? ''));
- $title = trim((string) ($lineItem['titel'] ?? ''));
- $qty = parse_number($lineItem['artikelanzahl'] ?? null);
- if ($qty === null || $qty <= 0) {
- continue;
- }
-
- $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([
- ':sales_order_id' => $orderId,
- ':line_no' => $lineNo,
- ':sellable_item_id' => $sellableItemId,
- ':article_number' => $articleNumber,
- ':title' => $title,
- ':qty' => $qty,
- ':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;
-
- 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']);
- }
- }
-
- $insertedLines++;
- }
-
- $pdo->commit();
-
- $labelTrigger = trigger_shipping_label_flow($data, $env);
- $excelTrigger = trigger_excel_webhook($externalRef, $env);
-
- json_response(200, [
- 'ok' => true,
- 'orderId' => $orderId,
- 'externalRef' => $externalRef,
- 'lineItemsImported' => $insertedLines,
- 'inventory' => $inventory,
- 'inventoryRollback' => $inventoryRollback,
- 'labelTrigger' => $labelTrigger,
- 'excelTrigger' => $excelTrigger,
- ]);
-} catch (Throwable $e) {
- if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
- $pdo->rollBack();
- }
-
- json_response(500, [
- 'ok' => false,
- 'error' => 'Order import failed',
- 'detail' => $e->getMessage(),
- ]);
-}
+require_once __DIR__ . '/modules/erp/import-integration/order-import.php';
diff --git a/public/api/otc-order.php b/public/api/otc-order.php
index 7821660..a94af5e 100644
--- a/public/api/otc-order.php
+++ b/public/api/otc-order.php
@@ -1,649 +1,4 @@
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') {
- json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
-}
-
-$jsonInput = file_get_contents('php://input');
-if ($jsonInput === false || trim($jsonInput) === '') {
- json_response(400, ['ok' => false, 'error' => 'Empty payload']);
-}
-
-try {
- $data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
-} catch (JsonException) {
- json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
-}
-
-if (!is_array($data)) {
- json_response(400, ['ok' => false, 'error' => 'JSON object expected']);
-}
-
-$required = ['products', 'totalPrice', 'paymentMethod', 'billing'];
-foreach ($required as $field) {
- if (!array_key_exists($field, $data)) {
- json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
- }
-}
-
-$products = $data['products'];
-$totalPrice = parse_number($data['totalPrice']);
-$paymentMethodCode = trim((string) $data['paymentMethod']);
-$billing = is_array($data['billing']) ? $data['billing'] : [];
-
-if (!is_array($products) || count($products) === 0) {
- json_response(422, ['ok' => false, 'error' => 'No products specified']);
-}
-
-if ($totalPrice === null || $totalPrice <= 0) {
- json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
-}
-
-$resolvedProducts = [];
-$totalQty = 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']);
- }
-
- $qty = parse_number($product['qty']);
- $title = trim((string) $product['title']);
- if ($qty === null || $qty <= 0 || $title === '') {
- json_response(422, ['ok' => false, 'error' => 'Invalid product quantity or title']);
- }
-
- $resolvedProducts[] = [
- 'qty' => (float) $qty,
- 'title' => $title,
- ];
- $totalQty += (float) $qty;
-}
-
-if ($totalQty <= 0) {
- json_response(422, ['ok' => false, 'error' => 'No product quantity specified']);
-}
-
-try {
- $pdo = connect_database();
- $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) {
- throw new RuntimeException('Invalid payment method');
- }
-
- $partyName = trim(((string) ($billing['firstName'] ?? '')) . ' ' . ((string) ($billing['lastName'] ?? '')));
- $partyName = $partyName === '' ? 'OTC Kunde' : $partyName;
-
- $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([
- ':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,
- ':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) {
- $lineNo++;
- $qty = (float) $product['qty'];
- $title = $product['title'];
-
- $resolvedProduct = resolve_otc_product($pdo, $title);
- $resolvedSellable = resolve_otc_sellable_item($pdo, (int) $resolvedProduct['id']);
-
- if ($lineNo === count($resolvedProducts)) {
- $unitPrice = round($remainingTotal / $qty, 4);
- $lineTotal = $remainingTotal;
- } else {
- $unitPrice = round((float) $totalPrice / (float) $totalQty, 4);
- $lineTotal = round($qty * $unitPrice, 2);
- $remainingTotal = round($remainingTotal - $lineTotal, 2);
- $remainingQty -= $qty;
- }
-
- $lineInsert->execute([
- ':sales_order_id' => $orderId,
- ':line_no' => $lineNo,
- ':sellable_item_id' => (int) $resolvedSellable['id'],
- ':article_number' => $resolvedSellable['article_number'],
- ':title' => $resolvedSellable['display_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(
- $pdo,
- $orderId,
- (int) $lineId,
- $lineNo,
- $components,
- $qty,
- $locations
- );
- }
-
- $pdo->commit();
-
- $env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
- $excelTrigger = trigger_excel_webhook($externalRef, $env);
-
- json_response(201, [
- 'ok' => true,
- 'orderId' => $orderId,
- 'externalRef' => $externalRef,
- 'partyId' => $partyId,
- 'excelTrigger' => $excelTrigger,
- 'message' => 'OTC order created successfully',
- ]);
-} catch (Throwable $e) {
- if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
- $pdo->rollBack();
- }
-
- json_response(500, [
- 'ok' => false,
- 'error' => 'Internal server error: ' . $e->getMessage(),
- ]);
-}
+require_once __DIR__ . '/../../modules/erp/direktverkauf/api/otc-order.php';
diff --git a/public/deploy.php b/public/deploy.php
index 337c0a1..aaf975a 100644
--- a/public/deploy.php
+++ b/public/deploy.php
@@ -1,55 +1,4 @@
/dev/null 2>&1 &');
-echo 'Deploy triggered';
+require_once dirname(__DIR__) . '/deploy.php';
diff --git a/public/otc/index.php b/public/otc/index.php
index e5804a5..6afbe6e 100644
--- a/public/otc/index.php
+++ b/public/otc/index.php
@@ -1,586 +1,4 @@
-
-
-
-
-
- OTC-Verkauf
-
-
-
-
-
OTC-Verkauf
-
-
-
-
-
-
-
-
- Total Flaschen:
- 0
-
-
- Preis pro Flasche:
- CHF 0.00
-
-
- Gesamtpreis:
- CHF 0.00
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert.
-
-
-
-
-
-
Bestellung erhalten
-
Die Bestellung wurde erfolgreich im System gespeichert.
-
Bestellnummer wird angezeigt, sobald sie vorliegt.
-
-
-
-
-
-
-
+require_once __DIR__ . '/../../modules/erp/direktverkauf/ui/index.php';