diff --git a/db/migrations/0006_phase1_otc_products.sql b/db/migrations/0006_phase1_otc_products.sql new file mode 100644 index 0000000..c4b2423 --- /dev/null +++ b/db/migrations/0006_phase1_otc_products.sql @@ -0,0 +1,107 @@ +BEGIN; + +-- OTC products for direct sales +INSERT INTO product (sku, name, status, uom) VALUES + ('SHIITAKE-50ML', 'PURE Shiitake Extrakt Tinktur 50ml', 'active', 'unit'), + ('REISHI-50ML', 'PURE Reishi Extrakt Tinktur 50ml', 'active', 'unit'), + ('LIONSMANE-50ML', 'PURE Lion''s Mane Extrakt Tinktur 50ml', 'active', 'unit'), + ('CHAGA-50ML', 'PURE Chaga Aroma Extrakt Tinktur 50ml', 'active', 'unit') +ON CONFLICT (sku) DO UPDATE SET + name = EXCLUDED.name, + status = EXCLUDED.status, + uom = EXCLUDED.uom, + updated_at = NOW(); + +-- Create sellable items for OTC products +INSERT INTO sellable_item (item_code, display_name, status) +SELECT + 'OTC-' || p.id, + p.name, + 'active' +FROM product p +WHERE p.sku IN ('SHIITAKE-50ML', 'REISHI-50ML', 'LIONSMANE-50ML', 'CHAGA-50ML') +ON CONFLICT (item_code) DO UPDATE SET + display_name = EXCLUDED.display_name, + status = EXCLUDED.status, + updated_at = NOW(); + +-- Link sellable items to products +INSERT INTO sellable_item_component (sellable_item_id, product_id, qty_per_item) +SELECT + si.id, + p.id, + 1.0 +FROM sellable_item si +JOIN product p ON p.name = si.display_name +WHERE p.sku IN ('SHIITAKE-50ML', 'REISHI-50ML', 'LIONSMANE-50ML', 'CHAGA-50ML') +ON CONFLICT (sellable_item_id, product_id) DO UPDATE SET + qty_per_item = EXCLUDED.qty_per_item, + updated_at = NOW(); + +-- Ensure we have current lots for OTC products +DO $$ +DECLARE + prod_record RECORD; + current_lot_id BIGINT; + open_lot_id BIGINT; +BEGIN + FOR prod_record IN + SELECT id FROM product + WHERE sku IN ('SHIITAKE-50ML', 'REISHI-50ML', 'LIONSMANE-50ML', 'CHAGA-50ML') + LOOP + -- Check if current lot exists + SELECT id INTO current_lot_id + FROM stock_lot + WHERE product_id = prod_record.id + AND status = 'current' + LIMIT 1; + + IF current_lot_id IS NULL THEN + -- Check for open lot to promote + SELECT id INTO open_lot_id + FROM stock_lot + WHERE product_id = prod_record.id + AND status = 'open' + ORDER BY id + LIMIT 1; + + IF open_lot_id IS NULL THEN + -- Create both current and open lots + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (prod_record.id, 'OTC-INIT-' || prod_record.id, 'current') + RETURNING id INTO current_lot_id; + + INSERT INTO stock_lot (product_id, status) + VALUES (prod_record.id, 'open'); + + -- Add initial stock + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + SELECT + prod_record.id, + current_lot_id, + NULL, + l.id, + 100.0, + 'in', + 'Initial OTC stock' + FROM location l + WHERE l.type = 'storage' + ORDER BY l.id + LIMIT 1; + ELSE + -- Promote open lot to current + UPDATE stock_lot + SET status = 'current', + lot_number = COALESCE(lot_number, 'OTC-' || prod_record.id || '-' || open_lot_id), + updated_at = NOW() + WHERE id = open_lot_id; + + -- Create new open lot + INSERT INTO stock_lot (product_id, status) + VALUES (prod_record.id, 'open'); + END IF; + END IF; + END LOOP; +END $$; + +COMMIT; \ No newline at end of file diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..0f7ca32 --- /dev/null +++ b/includes/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; +} \ No newline at end of file diff --git a/public/api/otc-order.php b/public/api/otc-order.php new file mode 100644 index 0000000..eea3cb3 --- /dev/null +++ b/public/api/otc-order.php @@ -0,0 +1,338 @@ + 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(), + ]); +} \ No newline at end of file diff --git a/public/otc/index.php b/public/otc/index.php new file mode 100644 index 0000000..0b07a2e --- /dev/null +++ b/public/otc/index.php @@ -0,0 +1,495 @@ + + +
+ + +