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 $e) { 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 (!isset($data[$field])) { json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]); } } $products = $data['products']; $totalPrice = parse_number($data['totalPrice']); $paymentMethodCode = trim((string) $data['paymentMethod']); $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']); } $totalQty = 0; foreach ($products as $product) { if (!isset($product['qty']) || !isset($product['title'])) { json_response(422, ['ok' => false, 'error' => 'Product missing title or qty']); } $qty = parse_number($product['qty']); if ($qty === null || $qty <= 0) { json_response(422, ['ok' => false, 'error' => 'Invalid product quantity']); } $totalQty += $qty; } try { $pdo = connect_database(); $pdo->beginTransaction(); $partyName = trim($billing['firstName'] . ' ' . $billing['lastName']); $partyName = $partyName === '' ? 'OTC Kunde' : $partyName; $stmt = $pdo->prepare(' INSERT INTO party (type, name, status, created_at, updated_at) VALUES (\'customer\', :name, \'active\', NOW(), NOW()) RETURNING id '); $stmt->execute([':name' => $partyName]); $partyId = (int) $stmt->fetchColumn(); $stmt = $pdo->prepare(' INSERT INTO address ( party_id, type, first_name, last_name, street, house_number, zip, city, country_name, created_at, updated_at ) VALUES ( :party_id, \'billing\', :first_name, :last_name, :street, :house_number, :zip, :city, \'Switzerland\', NOW(), NOW() ) '); $stmt->execute([ ':party_id' => $partyId, ':first_name' => trim($billing['firstName'] ?? ''), ':last_name' => trim($billing['lastName'] ?? ''), ':street' => trim($billing['street'] ?? ''), ':house_number' => trim($billing['houseNumber'] ?? ''), ':zip' => trim($billing['zip'] ?? ''), ':city' => trim($billing['city'] ?? ''), ]); $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'); } $paymentMethodId = (int) $paymentMethodId; $orderStmt = $pdo->prepare(' INSERT INTO sales_order ( external_ref, party_id, order_source, order_status, payment_status, payment_method_id, total_amount, currency, imported_at, created_at, updated_at ) VALUES ( :external_ref, :party_id, \'direct\', \'imported\', \'paid\', :payment_method_id, :total_amount, \'CHF\', NOW(), NOW(), NOW() ) RETURNING id, external_ref '); $orderStmt->execute([ ':external_ref' => '', ':party_id' => $partyId, ':payment_method_id' => $paymentMethodId, ':total_amount' => $totalPrice, ]); $order = $orderStmt->fetch(); if ($order === false) { throw new RuntimeException('Could not create order'); } $orderId = (int) $order['id']; $externalRef = $order['external_ref']; $unitPrice = $totalPrice / $totalQty; $lineInsert = $pdo->prepare(' INSERT INTO sales_order_line ( sales_order_id, line_no, raw_external_title, qty, unit_price, line_total, created_at, updated_at ) VALUES ( :sales_order_id, :line_no, :title, :qty, :unit_price, :line_total, NOW(), NOW() ) RETURNING id '); $locationsStmt = $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 '); $locations = $locationsStmt->fetch(); $storageId = (int) $locations['storage_id']; $dispatchId = (int) $locations['dispatch_id']; foreach ($products as $index => $product) { $qty = parse_number($product['qty']); $title = trim($product['title']); $lineTotal = round($qty * $unitPrice, 2); $lineInsert->execute([ ':sales_order_id' => $orderId, ':line_no' => $index + 1, ':title' => $title, ':qty' => $qty, ':unit_price' => $unitPrice, ':line_total' => $lineTotal, ]); $lineId = (int) $lineInsert->fetchColumn(); $resolveProductStmt = $pdo->prepare(' SELECT id FROM product WHERE name ILIKE :pattern ORDER BY id LIMIT 1 '); $pattern = ''; if (strpos($title, 'Shiitake') !== false) { $pattern = '%Shiitake%'; } elseif (strpos($title, 'Reishi') !== false) { $pattern = '%Reishi%'; } elseif (strpos($title, 'Lion') !== false) { $pattern = '%Lion%'; } elseif (strpos($title, 'Chaga') !== false) { $pattern = '%Chaga%'; } $productId = null; if ($pattern !== '') { $resolveProductStmt->execute([':pattern' => $pattern]); $productId = $resolveProductStmt->fetchColumn(); } if ($productId === false) { $productStmt = $pdo->prepare(' INSERT INTO product (sku, name, status, uom, created_at, updated_at) VALUES (:sku, :name, \'active\', \'unit\', NOW(), NOW()) RETURNING id '); $productStmt->execute([ ':sku' => 'OTC-' . preg_replace('/[^A-Za-z0-9]/', '', substr($title, 0, 20)), ':name' => $title, ]); $productId = (int) $productStmt->fetchColumn(); } else { $productId = (int) $productId; } $sellableItemStmt = $pdo->prepare(' SELECT id FROM sellable_item WHERE item_code = :item_code ORDER BY id LIMIT 1 '); $sellableItemStmt->execute([':item_code' => 'OTC-' . $productId]); $sellableItemId = $sellableItemStmt->fetchColumn(); if ($sellableItemId === false) { $sellableInsert = $pdo->prepare(' INSERT INTO sellable_item (item_code, display_name, status, created_at, updated_at) VALUES (:item_code, :display_name, \'active\', NOW(), NOW()) RETURNING id '); $sellableInsert->execute([ ':item_code' => 'OTC-' . $productId, ':display_name' => $title, ]); $sellableItemId = (int) $sellableInsert->fetchColumn(); } else { $sellableItemId = (int) $sellableItemId; } $updateLineStmt = $pdo->prepare(' UPDATE sales_order_line SET sellable_item_id = :sellable_item_id WHERE id = :id '); $updateLineStmt->execute([ ':sellable_item_id' => $sellableItemId, ':id' => $lineId, ]); $currentLotStmt = $pdo->prepare(' SELECT sl.id, COALESCE(v.qty_net, 0) AS qty_net FROM stock_lot sl LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE sl.product_id = :product_id AND sl.status = \'current\' LIMIT 1 FOR UPDATE '); $currentLotStmt->execute([':product_id' => $productId]); $lot = $currentLotStmt->fetch(); if ($lot === false || (float) $lot['qty_net'] < $qty) { $closeLotStmt = $pdo->prepare(' UPDATE stock_lot SET status = \'closed\', updated_at = NOW() WHERE id = :id '); $closeLotStmt->execute([':id' => (int) $lot['id']]); $openLotStmt = $pdo->prepare(' SELECT id FROM stock_lot WHERE product_id = :product_id AND status = \'open\' ORDER BY id LIMIT 1 FOR UPDATE '); $openLotStmt->execute([':product_id' => $productId]); $openLotId = $openLotStmt->fetchColumn(); if ($openLotId === false) { $createOpenLotStmt = $pdo->prepare(' INSERT INTO stock_lot (product_id, status, created_at, updated_at) VALUES (:product_id, \'open\', NOW(), NOW()) RETURNING id '); $createOpenLotStmt->execute([':product_id' => $productId]); $openLotId = (int) $createOpenLotStmt->fetchColumn(); } else { $openLotId = (int) $openLotId; } $promoteLotStmt = $pdo->prepare(' UPDATE stock_lot SET status = \'current\', lot_number = COALESCE(lot_number, \'OTC-\' || :product_id || \'-\' || :lot_id), updated_at = NOW() WHERE id = :lot_id '); $promoteLotStmt->execute([ ':product_id' => $productId, ':lot_id' => $openLotId, ]); $lotId = $openLotId; } else { $lotId = (int) $lot['id']; } $stockMoveStmt = $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 '); $stockMoveStmt->execute([ ':product_id' => $productId, ':lot_id' => $lotId, ':from_location_id' => $storageId, ':to_location_id' => $dispatchId, ':qty' => $qty, ':note' => 'OTC order ' . $orderId . ': ' . $title, ]); $stockMoveId = (int) $stockMoveStmt->fetchColumn(); $allocationStmt = $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 ( :line_id, :product_id, :lot_id, :qty, \'allocated\', :stock_move_id, NOW(), NOW() ) '); $allocationStmt->execute([ ':line_id' => $lineId, ':product_id' => $productId, ':lot_id' => $lotId, ':qty' => $qty, ':stock_move_id' => $stockMoveId, ]); } $pdo->commit(); json_response(201, [ 'ok' => true, 'orderId' => $orderId, 'externalRef' => $externalRef, 'partyId' => $partyId, 'message' => 'OTC order created successfully', ]); } catch (Exception $e) { if (isset($pdo) && $pdo->inTransaction()) { $pdo->rollBack(); } json_response(500, [ 'ok' => false, 'error' => 'Internal server error: ' . $e->getMessage(), ]); }