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(), ]); }