Move app into module structure
This commit is contained in:
+2
-53
@@ -1,55 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
$method = $_SERVER['REQUEST_METHOD'] ?? '';
|
declare(strict_types=1);
|
||||||
if ($method !== 'POST') {
|
|
||||||
http_response_code(405);
|
|
||||||
echo 'Method Not Allowed';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = file_get_contents('php://input');
|
require_once __DIR__ . '/modules/system/deploy.php';
|
||||||
if ($payload === false || $payload === '') {
|
|
||||||
http_response_code(400);
|
|
||||||
echo 'Empty payload';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$event = $_SERVER['HTTP_X_GITEA_EVENT'] ?? '';
|
|
||||||
$secret = getenv('GITEA_WEBHOOK_SECRET');
|
|
||||||
$signature = $_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? '';
|
|
||||||
|
|
||||||
if ($secret !== false && $secret !== '') {
|
|
||||||
if ($signature === '') {
|
|
||||||
http_response_code(401);
|
|
||||||
echo 'Missing signature';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
$hash = hash_hmac('sha256', $payload, $secret, false);
|
|
||||||
if (!hash_equals($hash, $signature)) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo 'Invalid signature';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($payload, true);
|
|
||||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo 'Invalid JSON';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($event !== 'push') {
|
|
||||||
http_response_code(202);
|
|
||||||
echo 'Ignored';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ref = $decoded['ref'] ?? '';
|
|
||||||
if ($ref !== 'refs/heads/main') {
|
|
||||||
http_response_code(202);
|
|
||||||
echo 'Ignored';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
exec('/bin/sudo -u admin_hz2 /bin/bash /volume2/webssd/erpnaurua/dev/deploy-staging.sh > /dev/null 2>&1 &');
|
|
||||||
echo 'Deploy triggered';
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
+1
-148
@@ -1,151 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
function parse_env_file(string $path): array
|
require_once __DIR__ . '/../modules/shared/db.php';
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
function throttle_webhook_channel(string $channel, int $minIntervalSeconds): void
|
require_once __DIR__ . '/../modules/shared/webhook_throttle.php';
|
||||||
{
|
|
||||||
if ($channel === '' || $minIntervalSeconds <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sanitizedChannel = preg_replace('/[^a-z0-9_-]+/i', '_', $channel) ?? 'default';
|
|
||||||
$lockPath = sys_get_temp_dir() . '/erp_naurua_webhook_throttle_' . $sanitizedChannel . '.lock';
|
|
||||||
|
|
||||||
$handle = fopen($lockPath, 'c+');
|
|
||||||
if ($handle === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!flock($handle, LOCK_EX)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rewind($handle);
|
|
||||||
$raw = stream_get_contents($handle);
|
|
||||||
$lastSentAt = is_string($raw) ? (float) trim($raw) : 0.0;
|
|
||||||
$now = microtime(true);
|
|
||||||
$waitSeconds = ($lastSentAt + $minIntervalSeconds) - $now;
|
|
||||||
|
|
||||||
if ($waitSeconds > 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../shared/db.php';
|
||||||
|
require_once __DIR__ . '/../../../shared/webhook_throttle.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
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_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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
<!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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay.is-visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22);
|
||||||
|
padding: 28px 24px 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #4b5563;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-order {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-close {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|
||||||
|
<div id="successOverlay" class="overlay" aria-hidden="true">
|
||||||
|
<div class="overlay-card" role="dialog" aria-modal="true" aria-labelledby="successOverlayTitle">
|
||||||
|
<div id="successOverlayTitle" class="overlay-title">Bestellung erhalten</div>
|
||||||
|
<div class="overlay-text">Die Bestellung wurde erfolgreich im System gespeichert.</div>
|
||||||
|
<div id="successOverlayOrder" class="overlay-order">Bestellnummer wird angezeigt, sobald sie vorliegt.</div>
|
||||||
|
<button id="closeOverlayBtn" class="overlay-close" type="button">Schliessen</button>
|
||||||
|
</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');
|
||||||
|
const successOverlayEl = document.getElementById('successOverlay');
|
||||||
|
const successOverlayOrderEl = document.getElementById('successOverlayOrder');
|
||||||
|
const closeOverlayBtn = document.getElementById('closeOverlayBtn');
|
||||||
|
|
||||||
|
function closeSuccessOverlay() {
|
||||||
|
successOverlayEl.classList.remove('is-visible');
|
||||||
|
successOverlayEl.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSuccessOverlay(externalRef) {
|
||||||
|
if (externalRef) {
|
||||||
|
successOverlayOrderEl.textContent = 'Bestellnummer: ' + externalRef;
|
||||||
|
} else {
|
||||||
|
successOverlayOrderEl.textContent = 'Bestellnummer wird angezeigt, sobald sie vorliegt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
successOverlayEl.classList.add('is-visible');
|
||||||
|
successOverlayEl.setAttribute('aria-hidden', 'false');
|
||||||
|
closeOverlayBtn.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
closeOverlayBtn.addEventListener('click', closeSuccessOverlay);
|
||||||
|
successOverlayEl.addEventListener('click', (event) => {
|
||||||
|
if (event.target === successOverlayEl) {
|
||||||
|
closeSuccessOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && successOverlayEl.classList.contains('is-visible')) {
|
||||||
|
closeSuccessOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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('/public/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');
|
||||||
|
openSuccessOverlay(result.externalRef || '');
|
||||||
|
|
||||||
|
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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function throttle_webhook_channel(string $channel, int $minIntervalSeconds): void
|
||||||
|
{
|
||||||
|
if ($channel === '' || $minIntervalSeconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitizedChannel = preg_replace('/[^a-z0-9_-]+/i', '_', $channel) ?? 'default';
|
||||||
|
$lockPath = sys_get_temp_dir() . '/erp_naurua_webhook_throttle_' . $sanitizedChannel . '.lock';
|
||||||
|
|
||||||
|
$handle = fopen($lockPath, 'c+');
|
||||||
|
if ($handle === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!flock($handle, LOCK_EX)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rewind($handle);
|
||||||
|
$raw = stream_get_contents($handle);
|
||||||
|
$lastSentAt = is_string($raw) ? (float) trim($raw) : 0.0;
|
||||||
|
$now = microtime(true);
|
||||||
|
$waitSeconds = ($lastSentAt + $minIntervalSeconds) - $now;
|
||||||
|
|
||||||
|
if ($waitSeconds > 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? '';
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo 'Method Not Allowed';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = file_get_contents('php://input');
|
||||||
|
if ($payload === false || $payload === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo 'Empty payload';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = $_SERVER['HTTP_X_GITEA_EVENT'] ?? '';
|
||||||
|
$secret = getenv('GITEA_WEBHOOK_SECRET');
|
||||||
|
$signature = $_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? '';
|
||||||
|
|
||||||
|
if ($secret !== false && $secret !== '') {
|
||||||
|
if ($signature === '') {
|
||||||
|
http_response_code(401);
|
||||||
|
echo 'Missing signature';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$hash = hash_hmac('sha256', $payload, $secret, false);
|
||||||
|
if (!hash_equals($hash, $signature)) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo 'Invalid signature';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($payload, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo 'Invalid JSON';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event !== 'push') {
|
||||||
|
http_response_code(202);
|
||||||
|
echo 'Ignored';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ref = $decoded['ref'] ?? '';
|
||||||
|
if ($ref !== 'refs/heads/main') {
|
||||||
|
http_response_code(202);
|
||||||
|
echo 'Ignored';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
exec('/bin/sudo -u admin_hz2 /bin/bash /volume2/webssd/erpnaurua/dev/deploy-staging.sh > /dev/null 2>&1 &');
|
||||||
|
echo 'Deploy triggered';
|
||||||
+1
-1606
File diff suppressed because it is too large
Load Diff
+1
-646
@@ -1,649 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../../includes/db.php';
|
require_once __DIR__ . '/../../modules/erp/direktverkauf/api/otc-order.php';
|
||||||
require_once __DIR__ . '/../../includes/webhook_throttle.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
|
|
||||||
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_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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-53
@@ -1,55 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
$method = $_SERVER['REQUEST_METHOD'] ?? '';
|
declare(strict_types=1);
|
||||||
if ($method !== 'POST') {
|
|
||||||
http_response_code(405);
|
|
||||||
echo 'Method Not Allowed';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = file_get_contents('php://input');
|
require_once dirname(__DIR__) . '/deploy.php';
|
||||||
if ($payload === false || $payload === '') {
|
|
||||||
http_response_code(400);
|
|
||||||
echo 'Empty payload';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$event = $_SERVER['HTTP_X_GITEA_EVENT'] ?? '';
|
|
||||||
$secret = getenv('GITEA_WEBHOOK_SECRET');
|
|
||||||
$signature = $_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? '';
|
|
||||||
|
|
||||||
if ($secret !== false && $secret !== '') {
|
|
||||||
if ($signature === '') {
|
|
||||||
http_response_code(401);
|
|
||||||
echo 'Missing signature';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
$hash = hash_hmac('sha256', $payload, $secret, false);
|
|
||||||
if (!hash_equals($hash, $signature)) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo 'Invalid signature';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($payload, true);
|
|
||||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo 'Invalid JSON';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($event !== 'push') {
|
|
||||||
http_response_code(202);
|
|
||||||
echo 'Ignored';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ref = $decoded['ref'] ?? '';
|
|
||||||
if ($ref !== 'refs/heads/main') {
|
|
||||||
http_response_code(202);
|
|
||||||
echo 'Ignored';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
exec('/bin/sudo -u admin_hz2 /bin/bash /volume2/webssd/erpnaurua/dev/deploy-staging.sh > /dev/null 2>&1 &');
|
|
||||||
echo 'Deploy triggered';
|
|
||||||
|
|||||||
+3
-585
@@ -1,586 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<?php
|
||||||
<html lang="de">
|
declare(strict_types=1);
|
||||||
<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 {
|
require_once __DIR__ . '/../../modules/erp/direktverkauf/ui/index.php';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(15, 23, 42, 0.45);
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay.is-visible {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-card {
|
|
||||||
width: min(420px, 100%);
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22);
|
|
||||||
padding: 28px 24px 22px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-title {
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-text {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #4b5563;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-order {
|
|
||||||
font-size: 15px;
|
|
||||||
color: #0f172a;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-close {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
|
|
||||||
<div id="successOverlay" class="overlay" aria-hidden="true">
|
|
||||||
<div class="overlay-card" role="dialog" aria-modal="true" aria-labelledby="successOverlayTitle">
|
|
||||||
<div id="successOverlayTitle" class="overlay-title">Bestellung erhalten</div>
|
|
||||||
<div class="overlay-text">Die Bestellung wurde erfolgreich im System gespeichert.</div>
|
|
||||||
<div id="successOverlayOrder" class="overlay-order">Bestellnummer wird angezeigt, sobald sie vorliegt.</div>
|
|
||||||
<button id="closeOverlayBtn" class="overlay-close" type="button">Schliessen</button>
|
|
||||||
</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');
|
|
||||||
const successOverlayEl = document.getElementById('successOverlay');
|
|
||||||
const successOverlayOrderEl = document.getElementById('successOverlayOrder');
|
|
||||||
const closeOverlayBtn = document.getElementById('closeOverlayBtn');
|
|
||||||
|
|
||||||
function closeSuccessOverlay() {
|
|
||||||
successOverlayEl.classList.remove('is-visible');
|
|
||||||
successOverlayEl.setAttribute('aria-hidden', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSuccessOverlay(externalRef) {
|
|
||||||
if (externalRef) {
|
|
||||||
successOverlayOrderEl.textContent = 'Bestellnummer: ' + externalRef;
|
|
||||||
} else {
|
|
||||||
successOverlayOrderEl.textContent = 'Bestellnummer wird angezeigt, sobald sie vorliegt.';
|
|
||||||
}
|
|
||||||
|
|
||||||
successOverlayEl.classList.add('is-visible');
|
|
||||||
successOverlayEl.setAttribute('aria-hidden', 'false');
|
|
||||||
closeOverlayBtn.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
closeOverlayBtn.addEventListener('click', closeSuccessOverlay);
|
|
||||||
successOverlayEl.addEventListener('click', (event) => {
|
|
||||||
if (event.target === successOverlayEl) {
|
|
||||||
closeSuccessOverlay();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Escape' && successOverlayEl.classList.contains('is-visible')) {
|
|
||||||
closeSuccessOverlay();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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('/public/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');
|
|
||||||
openSuccessOverlay(result.externalRef || '');
|
|
||||||
|
|
||||||
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