Add OTC sales UI with direct sales support
- Create OTC UI form at public/otc/index.php - Add API endpoint public/api/otc-order.php - Extract shared DB connection to includes/db.php - Add migration for OTC products (0006_phase1_otc_products.sql) - Support order_source='direct' with automatic DIR- reference generation - Include billing address capture with default values - Add payment methods cash and paypal for direct sales - Implement stock allocation and inventory management
This commit is contained in:
107
db/migrations/0006_phase1_otc_products.sql
Normal file
107
db/migrations/0006_phase1_otc_products.sql
Normal file
@@ -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;
|
||||||
151
includes/db.php
Normal file
151
includes/db.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function parse_env_file(string $path): array
|
||||||
|
{
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
if ($lines === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$trimmed = trim($line);
|
||||||
|
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pos = strpos($trimmed, '=');
|
||||||
|
if ($pos === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = trim(substr($trimmed, 0, $pos));
|
||||||
|
$value = trim(substr($trimmed, $pos + 1));
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($value) >= 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;
|
||||||
|
}
|
||||||
338
public/api/otc-order.php
Normal file
338
public/api/otc-order.php
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../includes/db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
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 $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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
495
public/otc/index.php
Normal file
495
public/otc/index.php
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OTC-Verkauf</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 25px 0 15px 0;
|
||||||
|
color: #34495e;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"],
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]:focus,
|
||||||
|
input[type="text"]:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-breakdown {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-line.total {
|
||||||
|
font-weight: bold;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding-top: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid .form-group:first-child,
|
||||||
|
.billing-grid .form-group:nth-child(2) {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid .form-group:nth-child(3) {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid .form-group:nth-child(4),
|
||||||
|
.billing-grid .form-group:nth-child(5),
|
||||||
|
.billing-grid .form-group:nth-child(6) {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid .form-group:nth-child(4) {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid .form-group:nth-child(5) {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid .form-group:nth-child(6) {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 14px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.3s;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #95a5a6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 5px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: #2ecc71;
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 20px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: transparent;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-right: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-grid .form-group:nth-child(3),
|
||||||
|
.billing-grid .form-group:nth-child(6) {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>OTC-Verkauf</h1>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>PRODUKTE</h2>
|
||||||
|
|
||||||
|
<div class="product-row">
|
||||||
|
<label for="product1">PURE Shiitake Extrakt Tinktur 50ml</label>
|
||||||
|
<input type="number" id="product1" min="0" step="1" value="0" data-title="PURE Shiitake Extrakt Tinktur 50ml">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-row">
|
||||||
|
<label for="product2">PURE Reishi Extrakt Tinktur 50ml</label>
|
||||||
|
<input type="number" id="product2" min="0" step="1" value="0" data-title="PURE Reishi Extrakt Tinktur 50ml">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-row">
|
||||||
|
<label for="product3">PURE Lion's Mane Extrakt Tinktur 50ml</label>
|
||||||
|
<input type="number" id="product3" min="0" step="1" value="0" data-title="PURE Lion's Mane Extrakt Tinktur 50ml">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-row">
|
||||||
|
<label for="product4">PURE Chaga Aroma Extrakt Tinktur 50ml</label>
|
||||||
|
<input type="number" id="product4" min="0" step="1" value="0" data-title="PURE Chaga Aroma Extrakt Tinktur 50ml">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totalPrice">Preis alle Flaschen brutto (CHF)</label>
|
||||||
|
<input type="number" id="totalPrice" min="0" step="0.01" value="0.00">
|
||||||
|
<div class="price-note">
|
||||||
|
Der Preis wird durch die Anzahl aller Flaschen geteilt und das Ergebnis ist der Preis jeder einzelnen Flasche.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="priceBreakdown" class="price-breakdown hidden">
|
||||||
|
<div class="price-line">
|
||||||
|
<span>Total Flaschen:</span>
|
||||||
|
<span id="totalBottles">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="price-line">
|
||||||
|
<span>Preis pro Flasche:</span>
|
||||||
|
<span id="pricePerBottle">CHF 0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="price-line total">
|
||||||
|
<span>Gesamtpreis:</span>
|
||||||
|
<span id="displayTotalPrice">CHF 0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="paymentMethod">Bezahlung</label>
|
||||||
|
<select id="paymentMethod">
|
||||||
|
<option value="twint">Twint</option>
|
||||||
|
<option value="cash">Barzahlung</option>
|
||||||
|
<option value="paypal">PayPal</option>
|
||||||
|
<option value="bank_transfer">Überweisung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>RECHNUNGSADRESSE</h2>
|
||||||
|
<div class="billing-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="firstName">Vorname</label>
|
||||||
|
<input type="text" id="firstName" value="Fabienne">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lastName">Nachname</label>
|
||||||
|
<input type="text" id="lastName" value="Föhn">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="street">Strasse</label>
|
||||||
|
<input type="text" id="street" value="Im Hochrain">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="houseNumber">Hausnummer</label>
|
||||||
|
<input type="text" id="houseNumber" value="2">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="zip">PLZ</label>
|
||||||
|
<input type="text" id="zip" value="8102">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="city">Ort</label>
|
||||||
|
<input type="text" id="city" value="Oberengstringen">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error" class="error"></div>
|
||||||
|
|
||||||
|
<button id="submitBtn" onclick="submitOrder()">
|
||||||
|
Verkaufen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="success" class="success">
|
||||||
|
Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const productInputs = [
|
||||||
|
document.getElementById('product1'),
|
||||||
|
document.getElementById('product2'),
|
||||||
|
document.getElementById('product3'),
|
||||||
|
document.getElementById('product4')
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalPriceInput = document.getElementById('totalPrice');
|
||||||
|
const priceBreakdown = document.getElementById('priceBreakdown');
|
||||||
|
const totalBottlesEl = document.getElementById('totalBottles');
|
||||||
|
const pricePerBottleEl = document.getElementById('pricePerBottle');
|
||||||
|
const displayTotalPriceEl = document.getElementById('displayTotalPrice');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const successEl = document.getElementById('success');
|
||||||
|
|
||||||
|
function updatePriceBreakdown() {
|
||||||
|
const totalQty = productInputs.reduce((sum, input) => sum + parseInt(input.value || 0), 0);
|
||||||
|
const totalPrice = parseFloat(totalPriceInput.value || 0);
|
||||||
|
|
||||||
|
if (totalQty > 0 && totalPrice > 0) {
|
||||||
|
priceBreakdown.classList.remove('hidden');
|
||||||
|
totalBottlesEl.textContent = totalQty;
|
||||||
|
const pricePerBottle = totalPrice / totalQty;
|
||||||
|
pricePerBottleEl.textContent = 'CHF ' + pricePerBottle.toFixed(2);
|
||||||
|
displayTotalPriceEl.textContent = 'CHF ' + totalPrice.toFixed(2);
|
||||||
|
} else {
|
||||||
|
priceBreakdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
validateForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
const totalQty = productInputs.reduce((sum, input) => sum + parseInt(input.value || 0), 0);
|
||||||
|
const totalPrice = parseFloat(totalPriceInput.value || 0);
|
||||||
|
const paymentMethod = document.getElementById('paymentMethod').value;
|
||||||
|
|
||||||
|
const firstName = document.getElementById('firstName').value.trim();
|
||||||
|
const lastName = document.getElementById('lastName').value.trim();
|
||||||
|
const street = document.getElementById('street').value.trim();
|
||||||
|
const houseNumber = document.getElementById('houseNumber').value.trim();
|
||||||
|
const zip = document.getElementById('zip').value.trim();
|
||||||
|
const city = document.getElementById('city').value.trim();
|
||||||
|
|
||||||
|
let isValid = true;
|
||||||
|
let errorMsg = '';
|
||||||
|
|
||||||
|
if (totalQty === 0) {
|
||||||
|
isValid = false;
|
||||||
|
errorMsg = 'Mindestens ein Produkt mit Menge > 0 erforderlich.';
|
||||||
|
} else if (totalPrice <= 0) {
|
||||||
|
isValid = false;
|
||||||
|
errorMsg = 'Preis muss größer als 0 sein.';
|
||||||
|
} else if (!paymentMethod) {
|
||||||
|
isValid = false;
|
||||||
|
errorMsg = 'Zahlungsart auswählen.';
|
||||||
|
} else if (!firstName || !lastName || !street || !houseNumber || !zip || !city) {
|
||||||
|
isValid = false;
|
||||||
|
errorMsg = 'Alle Rechnungsadress-Felder ausfüllen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
errorEl.textContent = errorMsg;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
} else {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
productInputs.forEach(input => {
|
||||||
|
input.addEventListener('input', updatePriceBreakdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
totalPriceInput.addEventListener('input', updatePriceBreakdown);
|
||||||
|
|
||||||
|
document.getElementById('paymentMethod').addEventListener('change', validateForm);
|
||||||
|
document.getElementById('firstName').addEventListener('input', validateForm);
|
||||||
|
document.getElementById('lastName').addEventListener('input', validateForm);
|
||||||
|
document.getElementById('street').addEventListener('input', validateForm);
|
||||||
|
document.getElementById('houseNumber').addEventListener('input', validateForm);
|
||||||
|
document.getElementById('zip').addEventListener('input', validateForm);
|
||||||
|
document.getElementById('city').addEventListener('input', validateForm);
|
||||||
|
|
||||||
|
async function submitOrder() {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = productInputs
|
||||||
|
.filter(input => parseInt(input.value || 0) > 0)
|
||||||
|
.map(input => ({
|
||||||
|
title: input.dataset.title,
|
||||||
|
qty: parseInt(input.value)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const orderData = {
|
||||||
|
products: products,
|
||||||
|
totalPrice: parseFloat(totalPriceInput.value),
|
||||||
|
paymentMethod: document.getElementById('paymentMethod').value,
|
||||||
|
billing: {
|
||||||
|
firstName: document.getElementById('firstName').value.trim(),
|
||||||
|
lastName: document.getElementById('lastName').value.trim(),
|
||||||
|
street: document.getElementById('street').value.trim(),
|
||||||
|
houseNumber: document.getElementById('houseNumber').value.trim(),
|
||||||
|
zip: document.getElementById('zip').value.trim(),
|
||||||
|
city: document.getElementById('city').value.trim()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalBtnText = submitBtn.innerHTML;
|
||||||
|
submitBtn.innerHTML = '<span class="loading"></span> Wird verarbeitet...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/otc-order.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(orderData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.ok) {
|
||||||
|
successEl.style.display = 'block';
|
||||||
|
productInputs.forEach(input => input.value = '0');
|
||||||
|
totalPriceInput.value = '0.00';
|
||||||
|
priceBreakdown.classList.add('hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
successEl.style.display = 'none';
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = result.error || 'Unbekannter Fehler';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorEl.textContent = 'Netzwerkfehler: ' + error.message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitBtn.innerHTML = originalBtnText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
validateForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePriceBreakdown();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user