Add architecture module map

This commit is contained in:
2026-06-15 09:58:33 +02:00
parent 39d936cfba
commit c6b5a0572c
19 changed files with 1134 additions and 2372 deletions
+548 -237
View File
@@ -2,9 +2,436 @@
declare(strict_types=1);
require_once __DIR__ . '/../../includes/db.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']);
}
@@ -16,7 +443,7 @@ if ($jsonInput === false || trim($jsonInput) === '') {
try {
$data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
} catch (JsonException) {
json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
}
@@ -26,7 +453,7 @@ if (!is_array($data)) {
$required = ['products', 'totalPrice', 'paymentMethod', 'billing'];
foreach ($required as $field) {
if (!isset($data[$field])) {
if (!array_key_exists($field, $data)) {
json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
}
}
@@ -34,7 +461,7 @@ foreach ($required as $field) {
$products = $data['products'];
$totalPrice = parse_number($data['totalPrice']);
$paymentMethodCode = trim((string) $data['paymentMethod']);
$billing = $data['billing'];
$billing = is_array($data['billing']) ? $data['billing'] : [];
if (!is_array($products) || count($products) === 0) {
json_response(422, ['ok' => false, 'error' => 'No products specified']);
@@ -44,51 +471,35 @@ if ($totalPrice === null || $totalPrice <= 0) {
json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
}
$resolvedProducts = [];
$totalQty = 0;
foreach ($products as $product) {
if (!isset($product['qty']) || !isset($product['title'])) {
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']);
if ($qty === null || $qty <= 0) {
json_response(422, ['ok' => false, 'error' => 'Invalid product quantity']);
$title = trim((string) $product['title']);
if ($qty === null || $qty <= 0 || $title === '') {
json_response(422, ['ok' => false, 'error' => 'Invalid product quantity or title']);
}
$totalQty += $qty;
$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();
$partyName = trim($billing['firstName'] . ' ' . $billing['lastName']);
$partyName = $partyName === '' ? 'OTC Kunde' : $partyName;
$stmt = $pdo->prepare('
INSERT INTO party (type, name, status, created_at, updated_at)
VALUES (\'customer\', :name, \'active\', NOW(), NOW())
RETURNING id
');
$stmt->execute([':name' => $partyName]);
$partyId = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare('
INSERT INTO address (
party_id, type, first_name, last_name, street, house_number, zip, city,
country_name, created_at, updated_at
) VALUES (
:party_id, \'billing\', :first_name, :last_name, :street, :house_number, :zip, :city,
\'Switzerland\', NOW(), NOW()
)
');
$stmt->execute([
':party_id' => $partyId,
':first_name' => trim($billing['firstName'] ?? ''),
':last_name' => trim($billing['lastName'] ?? ''),
':street' => trim($billing['street'] ?? ''),
':house_number' => trim($billing['houseNumber'] ?? ''),
':zip' => trim($billing['zip'] ?? ''),
':city' => trim($billing['city'] ?? ''),
]);
$locations = get_default_location_ids($pdo);
$paymentMethodStmt = $pdo->prepare('SELECT id FROM payment_method WHERE code = :code LIMIT 1');
$paymentMethodStmt->execute([':code' => $paymentMethodCode]);
@@ -96,243 +507,143 @@ try {
if ($paymentMethodId === false) {
throw new RuntimeException('Invalid payment method');
}
$paymentMethodId = (int) $paymentMethodId;
$orderStmt = $pdo->prepare('
INSERT INTO sales_order (
external_ref, party_id, order_source, order_status, payment_status, payment_method_id,
total_amount, currency, imported_at, created_at, updated_at
) VALUES (
:external_ref, :party_id, \'direct\', \'imported\', \'paid\', :payment_method_id,
:total_amount, \'CHF\', NOW(), NOW(), NOW()
)
RETURNING id, external_ref
');
$orderStmt->execute([
':external_ref' => '',
$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,
':payment_method_id' => $paymentMethodId,
':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 = $order['external_ref'];
$externalRef = (string) $order['external_ref'];
$unitPrice = $totalPrice / $totalQty;
$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"
);
$lineInsert = $pdo->prepare('
INSERT INTO sales_order_line (
sales_order_id, line_no, raw_external_title, qty, unit_price, line_total,
created_at, updated_at
) VALUES (
:sales_order_id, :line_no, :title, :qty, :unit_price, :line_total,
NOW(), NOW()
)
RETURNING id
');
$remainingTotal = round((float) $totalPrice, 2);
$remainingQty = (float) $totalQty;
$lineNo = 0;
$locationsStmt = $pdo->query('
SELECT
(SELECT id FROM location WHERE type = \'storage\' ORDER BY id LIMIT 1) AS storage_id,
(SELECT id FROM location WHERE type = \'dispatch\' ORDER BY id LIMIT 1) AS dispatch_id
');
$locations = $locationsStmt->fetch();
$storageId = (int) $locations['storage_id'];
$dispatchId = (int) $locations['dispatch_id'];
foreach ($resolvedProducts as $product) {
$lineNo++;
$qty = (float) $product['qty'];
$title = $product['title'];
foreach ($products as $index => $product) {
$qty = parse_number($product['qty']);
$title = trim($product['title']);
$lineTotal = round($qty * $unitPrice, 2);
$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' => $index + 1,
':title' => $title,
':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 = (int) $lineInsert->fetchColumn();
$resolveProductStmt = $pdo->prepare('
SELECT id FROM product WHERE name ILIKE :pattern ORDER BY id LIMIT 1
');
$pattern = '';
if (strpos($title, 'Shiitake') !== false) {
$pattern = '%Shiitake%';
} elseif (strpos($title, 'Reishi') !== false) {
$pattern = '%Reishi%';
} elseif (strpos($title, 'Lion') !== false) {
$pattern = '%Lion%';
} elseif (strpos($title, 'Chaga') !== false) {
$pattern = '%Chaga%';
}
$productId = null;
if ($pattern !== '') {
$resolveProductStmt->execute([':pattern' => $pattern]);
$productId = $resolveProductStmt->fetchColumn();
}
if ($productId === false) {
$productStmt = $pdo->prepare('
INSERT INTO product (sku, name, status, uom, created_at, updated_at)
VALUES (:sku, :name, \'active\', \'unit\', NOW(), NOW())
RETURNING id
');
$productStmt->execute([
':sku' => 'OTC-' . preg_replace('/[^A-Za-z0-9]/', '', substr($title, 0, 20)),
':name' => $title,
]);
$productId = (int) $productStmt->fetchColumn();
} else {
$productId = (int) $productId;
$lineId = $lineInsert->fetchColumn();
if ($lineId === false) {
throw new RuntimeException("Could not create line {$lineNo}");
}
$sellableItemStmt = $pdo->prepare('
SELECT id FROM sellable_item WHERE item_code = :item_code ORDER BY id LIMIT 1
');
$sellableItemStmt->execute([':item_code' => 'OTC-' . $productId]);
$sellableItemId = $sellableItemStmt->fetchColumn();
if ($sellableItemId === false) {
$sellableInsert = $pdo->prepare('
INSERT INTO sellable_item (item_code, display_name, status, created_at, updated_at)
VALUES (:item_code, :display_name, \'active\', NOW(), NOW())
RETURNING id
');
$sellableInsert->execute([
':item_code' => 'OTC-' . $productId,
':display_name' => $title,
]);
$sellableItemId = (int) $sellableInsert->fetchColumn();
} else {
$sellableItemId = (int) $sellableItemId;
}
$updateLineStmt = $pdo->prepare('
UPDATE sales_order_line
SET sellable_item_id = :sellable_item_id
WHERE id = :id
');
$updateLineStmt->execute([
':sellable_item_id' => $sellableItemId,
':id' => $lineId,
]);
$currentLotStmt = $pdo->prepare('
SELECT sl.id, COALESCE(v.qty_net, 0) AS qty_net
FROM stock_lot sl
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = \'current\'
LIMIT 1
FOR UPDATE
');
$currentLotStmt->execute([':product_id' => $productId]);
$lot = $currentLotStmt->fetch();
if ($lot === false || (float) $lot['qty_net'] < $qty) {
$closeLotStmt = $pdo->prepare('
UPDATE stock_lot
SET status = \'closed\', updated_at = NOW()
WHERE id = :id
');
$closeLotStmt->execute([':id' => (int) $lot['id']]);
$openLotStmt = $pdo->prepare('
SELECT id FROM stock_lot
WHERE product_id = :product_id
AND status = \'open\'
ORDER BY id LIMIT 1
FOR UPDATE
');
$openLotStmt->execute([':product_id' => $productId]);
$openLotId = $openLotStmt->fetchColumn();
if ($openLotId === false) {
$createOpenLotStmt = $pdo->prepare('
INSERT INTO stock_lot (product_id, status, created_at, updated_at)
VALUES (:product_id, \'open\', NOW(), NOW())
RETURNING id
');
$createOpenLotStmt->execute([':product_id' => $productId]);
$openLotId = (int) $createOpenLotStmt->fetchColumn();
} else {
$openLotId = (int) $openLotId;
}
$promoteLotStmt = $pdo->prepare('
UPDATE stock_lot
SET status = \'current\',
lot_number = COALESCE(lot_number, \'OTC-\' || :product_id || \'-\' || :lot_id),
updated_at = NOW()
WHERE id = :lot_id
');
$promoteLotStmt->execute([
':product_id' => $productId,
':lot_id' => $openLotId,
]);
$lotId = $openLotId;
} else {
$lotId = (int) $lot['id'];
}
$stockMoveStmt = $pdo->prepare('
INSERT INTO stock_move (
product_id, lot_id, from_location_id, to_location_id, qty, move_type,
move_date, note, created_at, updated_at
) VALUES (
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, \'out\',
NOW(), :note, NOW(), NOW()
)
RETURNING id
');
$stockMoveStmt->execute([
':product_id' => $productId,
':lot_id' => $lotId,
':from_location_id' => $storageId,
':to_location_id' => $dispatchId,
':qty' => $qty,
':note' => 'OTC order ' . $orderId . ': ' . $title,
]);
$stockMoveId = (int) $stockMoveStmt->fetchColumn();
$allocationStmt = $pdo->prepare('
INSERT INTO sales_order_line_lot_allocation (
sales_order_line_id, product_id, lot_id, qty, allocation_status,
stock_move_id, created_at, updated_at
) VALUES (
:line_id, :product_id, :lot_id, :qty, \'allocated\',
:stock_move_id, NOW(), NOW()
)
');
$allocationStmt->execute([
':line_id' => $lineId,
':product_id' => $productId,
':lot_id' => $lotId,
':qty' => $qty,
':stock_move_id' => $stockMoveId,
]);
$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 (Exception $e) {
if (isset($pdo) && $pdo->inTransaction()) {
} catch (Throwable $e) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
$pdo->rollBack();
}
json_response(500, [
'ok' => false,
'error' => 'Internal server error: ' . $e->getMessage(),
]);
}
}
+93 -2
View File
@@ -187,6 +187,56 @@
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;
@@ -334,6 +384,15 @@
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 = [
@@ -351,6 +410,26 @@
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);
@@ -423,6 +502,17 @@
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()) {
@@ -456,7 +546,7 @@
errorEl.style.display = 'none';
try {
const response = await fetch('/api/otc-order.php', {
const response = await fetch('/public/api/otc-order.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -471,6 +561,7 @@
productInputs.forEach(input => input.value = '0');
totalPriceInput.value = '0.00';
priceBreakdown.classList.add('hidden');
openSuccessOverlay(result.externalRef || '');
setTimeout(() => {
successEl.style.display = 'none';
@@ -492,4 +583,4 @@
updatePriceBreakdown();
</script>
</body>
</html>
</html>