Split ERP flows into modules
This commit is contained in:
@@ -292,7 +292,7 @@ Fachliche Aufgabe:
|
||||
|
||||
- E-Mail auf `Neue Bestellung` erkennen
|
||||
- Payload in ERP-JSON umformen
|
||||
- JSON per HTTP POST an `modules/erp/import-integration/order-import.php` senden
|
||||
- JSON per HTTP POST an `public/order-import.php` senden
|
||||
|
||||
Liest:
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ Regeln:
|
||||
- Submodule werden im README des owning Moduls kurz benannt, wenn sie nicht eigenes Ownership tragen.
|
||||
- Code, Doku und Datenmodell sollen dieser Struktur folgen.
|
||||
- Bereits technisch umgesetzt sind aktuell nur:
|
||||
- `modules/erp/kontakte`
|
||||
- `modules/erp/bestellungen`
|
||||
- `modules/erp/lager`
|
||||
- `modules/erp/artikel-mapping`
|
||||
- `modules/erp/import-integration`
|
||||
- `modules/erp/direktverkauf`
|
||||
- `modules/shared`
|
||||
|
||||
@@ -36,7 +36,12 @@ ERP ist der operative Container fuer die Kerndomane des Tagesgeschaefts.
|
||||
|
||||
## Technisch umgesetzt
|
||||
|
||||
- `modules/erp/kontakte/service.php`
|
||||
- `modules/erp/bestellungen/service.php`
|
||||
- `modules/erp/lager/service.php`
|
||||
- `modules/erp/artikel-mapping/service.php`
|
||||
- `modules/erp/import-integration/order-import.php`
|
||||
- `modules/erp/import-integration/service.php`
|
||||
- `modules/erp/direktverkauf/api/otc-order.php`
|
||||
- `modules/erp/direktverkauf/ui/index.php`
|
||||
|
||||
|
||||
@@ -29,3 +29,6 @@ Aufloesung externer Shopdaten auf interne verkaufbare Artikel und Produkte.
|
||||
- Keine Lagerbewegungen.
|
||||
- Keine Buchhaltung.
|
||||
|
||||
## Technisch umgesetzt
|
||||
|
||||
- `modules/erp/artikel-mapping/service.php`
|
||||
|
||||
@@ -35,3 +35,6 @@ Operative Verarbeitung von Bestellungen, Positionen und Status.
|
||||
- Keine Lagerbestandswahrheit.
|
||||
- Kein Direktverkaufs-UI.
|
||||
|
||||
## Technisch umgesetzt
|
||||
|
||||
- `modules/erp/bestellungen/service.php`
|
||||
|
||||
@@ -35,3 +35,6 @@ Zentraler Kontaktstamm fuer Kunden, Lieferanten und sonstige Parteien.
|
||||
- Kontaktabfrage
|
||||
- Lookup fuer andere Module
|
||||
|
||||
## Technisch umgesetzt
|
||||
|
||||
- `modules/erp/kontakte/service.php`
|
||||
|
||||
@@ -37,3 +37,6 @@ Bestand, Charge, MHD und Bewegungen.
|
||||
- Keine Beratungslogik.
|
||||
- Keine fachfremden Artikelzuordnungen.
|
||||
|
||||
## Technisch umgesetzt
|
||||
|
||||
- `modules/erp/lager/service.php`
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function normalize_title_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 normalize_match_key(string $value): string
|
||||
{
|
||||
return normalize_title_key($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 title_contains_family(string $normalizedTitle, string $familyKey): bool
|
||||
{
|
||||
return $familyKey !== '' && str_contains($normalizedTitle, $familyKey);
|
||||
}
|
||||
|
||||
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 find_alias_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT si.id
|
||||
FROM external_item_alias eia
|
||||
JOIN sellable_item si ON si.id = eia.sellable_item_id
|
||||
WHERE eia.source_system = 'wix'
|
||||
AND eia.is_active = TRUE
|
||||
AND (
|
||||
eia.external_article_number = :article_number
|
||||
OR eia.external_title = :title
|
||||
OR eia.title_normalized = :normalized_title
|
||||
)
|
||||
ORDER BY eia.id
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([
|
||||
':article_number' => trim($articleNumber),
|
||||
':title' => trim($title),
|
||||
':normalized_title' => normalize_match_key($title),
|
||||
]);
|
||||
|
||||
$id = $stmt->fetchColumn();
|
||||
return $id === false ? null : (int) $id;
|
||||
}
|
||||
|
||||
function sellable_item_exists(PDO $pdo, int $sellableItemId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM sellable_item WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $sellableItemId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
function find_sellable_item_for_product(PDO $pdo, int $productId): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT sellable_item_id
|
||||
FROM sellable_item_component
|
||||
WHERE product_id = :product_id
|
||||
ORDER BY id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':product_id' => $productId]);
|
||||
$id = $stmt->fetchColumn();
|
||||
return $id === false ? null : (int) $id;
|
||||
}
|
||||
|
||||
function find_product_name(PDO $pdo, int $productId): ?string
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT name FROM product WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $productId]);
|
||||
$name = $stmt->fetchColumn();
|
||||
return $name === false ? null : (string) $name;
|
||||
}
|
||||
|
||||
function ensure_unique_sellable_item_code(PDO $pdo, string $preferred): string
|
||||
{
|
||||
$candidate = trim($preferred);
|
||||
if ($candidate === '') {
|
||||
$candidate = 'si';
|
||||
}
|
||||
|
||||
$base = $candidate;
|
||||
$counter = 1;
|
||||
while (true) {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM sellable_item WHERE item_code = :item_code LIMIT 1');
|
||||
$stmt->execute([':item_code' => $candidate]);
|
||||
if ($stmt->fetchColumn() === false) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$candidate = $base . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
|
||||
function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $title): ?int
|
||||
{
|
||||
$normalizedTitle = normalize_match_key($title);
|
||||
$familyKey = detect_product_family_key($normalizedTitle);
|
||||
if ($familyKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->query("SELECT id, 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 (int) $product['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function infer_components_from_title(PDO $pdo, string $title): array
|
||||
{
|
||||
$productId = resolve_product_id_fallback($pdo, '', $title);
|
||||
if ($productId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [['product_id' => $productId, 'qty_per_item' => 1.0]];
|
||||
}
|
||||
|
||||
function ensure_alias_points_to_sellable_item(
|
||||
PDO $pdo,
|
||||
string $articleNumber,
|
||||
string $title,
|
||||
int $sellableItemId
|
||||
): void {
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO external_item_alias (
|
||||
source_system, external_article_number, external_title, title_normalized, sellable_item_id, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
'wix', :article_number, :title, :normalized_title, :sellable_item_id, TRUE, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT DO NOTHING"
|
||||
);
|
||||
$stmt->execute([
|
||||
':article_number' => trim($articleNumber),
|
||||
':title' => trim($title),
|
||||
':normalized_title' => normalize_match_key($title),
|
||||
':sellable_item_id' => $sellableItemId,
|
||||
]);
|
||||
}
|
||||
|
||||
function ensure_sellable_mapping_from_title_components(PDO $pdo, string $articleNumber, string $title): ?int
|
||||
{
|
||||
$sellableItemId = find_alias_sellable_item_id($pdo, $articleNumber, $title);
|
||||
if ($sellableItemId !== null) {
|
||||
return $sellableItemId;
|
||||
}
|
||||
|
||||
$components = infer_components_from_title($pdo, $title);
|
||||
if ($components === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$productId = (int) $components[0]['product_id'];
|
||||
$sellableItemId = find_sellable_item_for_product($pdo, $productId);
|
||||
if ($sellableItemId === null) {
|
||||
$productName = find_product_name($pdo, $productId) ?? 'Mapped item';
|
||||
$code = ensure_unique_sellable_item_code($pdo, normalize_match_key($productName));
|
||||
$stmt = $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"
|
||||
);
|
||||
$stmt->execute([
|
||||
':item_code' => $code,
|
||||
':display_name' => $productName,
|
||||
]);
|
||||
$sellableItemId = (int) $stmt->fetchColumn();
|
||||
|
||||
$componentStmt = $pdo->prepare(
|
||||
"INSERT INTO sellable_item_component (sellable_item_id, product_id, qty_per_item, created_at, updated_at)
|
||||
VALUES (:sellable_item_id, :product_id, 1, NOW(), NOW())"
|
||||
);
|
||||
$componentStmt->execute([
|
||||
':sellable_item_id' => $sellableItemId,
|
||||
':product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
ensure_alias_points_to_sellable_item($pdo, $articleNumber, $title, $sellableItemId);
|
||||
|
||||
return $sellableItemId;
|
||||
}
|
||||
|
||||
function ensure_sellable_mapping_from_product_fallback(PDO $pdo, string $articleNumber, string $title): ?int
|
||||
{
|
||||
return ensure_sellable_mapping_from_title_components($pdo, $articleNumber, $title);
|
||||
}
|
||||
|
||||
function resolve_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int
|
||||
{
|
||||
$id = find_alias_sellable_item_id($pdo, $articleNumber, $title);
|
||||
if ($id !== null) {
|
||||
return $id;
|
||||
}
|
||||
|
||||
return ensure_sellable_mapping_from_product_fallback($pdo, $articleNumber, $title);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function lookup_method_id(PDO $pdo, string $table, ?string $code): ?int
|
||||
{
|
||||
if ($code === null || $code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id FROM public.{$table} WHERE code = :code LIMIT 1");
|
||||
$stmt->execute([':code' => $code]);
|
||||
$id = $stmt->fetchColumn();
|
||||
|
||||
return $id === false ? null : (int) $id;
|
||||
}
|
||||
|
||||
function map_payment_code(string $input): ?string
|
||||
{
|
||||
$v = strtolower(trim($input));
|
||||
if ($v === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($v, 'twint')) {
|
||||
return 'twint';
|
||||
}
|
||||
if (str_contains($v, 'bank') || str_contains($v, 'vorauskasse') || str_contains($v, 'ueberweisung')) {
|
||||
return 'bank_transfer';
|
||||
}
|
||||
if (str_contains($v, 'kredit') || str_contains($v, 'debit') || str_contains($v, 'card')) {
|
||||
return 'card';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function map_shipping_code(string $input): ?string
|
||||
{
|
||||
$v = strtolower(trim($input));
|
||||
if ($v === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($v, 'abholung') || str_contains($v, 'pickup')) {
|
||||
return 'pickup';
|
||||
}
|
||||
if (str_contains($v, 'post') || str_contains($v, 'versand')) {
|
||||
return 'post_standard';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensure_required_tables_exist(PDO $pdo): void
|
||||
{
|
||||
$required = [
|
||||
'party',
|
||||
'address',
|
||||
'sales_order',
|
||||
'sales_order_line',
|
||||
'payment_method',
|
||||
'shipping_method',
|
||||
];
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'"
|
||||
);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
$existing = array_map('strval', $rows ?: []);
|
||||
$missing = array_values(array_diff($required, $existing));
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException('DB schema not initialized. Missing tables: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
function find_existing_order_id(PDO $pdo, string $externalRef): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id FROM sales_order WHERE external_ref = :external_ref LIMIT 1');
|
||||
$stmt->execute([':external_ref' => $externalRef]);
|
||||
$id = $stmt->fetchColumn();
|
||||
|
||||
return $id === false ? null : (int) $id;
|
||||
}
|
||||
|
||||
function upsert_sales_order(PDO $pdo, array $fields): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO public.sales_order (
|
||||
external_ref, party_id, order_source, order_status, payment_status, payment_method_id, shipping_method_id,
|
||||
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, webhook_payload, imported_at, created_at, updated_at
|
||||
) VALUES (
|
||||
:external_ref, :party_id, :order_source, :order_status, :payment_status, :payment_method_id, :shipping_method_id,
|
||||
:amount_net, :amount_shipping, :amount_tax, :amount_discount, :total_amount, :currency, :webhook_payload::jsonb, NOW(), NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (external_ref) DO UPDATE SET
|
||||
party_id = EXCLUDED.party_id,
|
||||
order_source = EXCLUDED.order_source,
|
||||
order_status = EXCLUDED.order_status,
|
||||
payment_status = EXCLUDED.payment_status,
|
||||
payment_method_id = EXCLUDED.payment_method_id,
|
||||
shipping_method_id = EXCLUDED.shipping_method_id,
|
||||
amount_net = EXCLUDED.amount_net,
|
||||
amount_shipping = EXCLUDED.amount_shipping,
|
||||
amount_tax = EXCLUDED.amount_tax,
|
||||
amount_discount = EXCLUDED.amount_discount,
|
||||
total_amount = EXCLUDED.total_amount,
|
||||
currency = EXCLUDED.currency,
|
||||
webhook_payload = EXCLUDED.webhook_payload,
|
||||
imported_at = NOW(),
|
||||
updated_at = NOW()
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute($fields);
|
||||
|
||||
$id = $stmt->fetchColumn();
|
||||
if ($id === false) {
|
||||
throw new RuntimeException('Could not upsert order');
|
||||
}
|
||||
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
function create_direct_sales_order(PDO $pdo, array $fields): array
|
||||
{
|
||||
$stmt = $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"
|
||||
);
|
||||
$stmt->execute($fields);
|
||||
|
||||
$row = $stmt->fetch();
|
||||
if ($row === false) {
|
||||
throw new RuntimeException('Could not create order');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'external_ref' => (string) $row['external_ref'],
|
||||
];
|
||||
}
|
||||
|
||||
function delete_sales_order_lines(PDO $pdo, int $orderId): void
|
||||
{
|
||||
$deleteLines = $pdo->prepare('DELETE FROM public.sales_order_line WHERE sales_order_id = :sales_order_id');
|
||||
$deleteLines->execute([':sales_order_id' => $orderId]);
|
||||
}
|
||||
|
||||
function insert_sales_order_line(PDO $pdo, array $fields): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO public.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'
|
||||
);
|
||||
$stmt->execute($fields);
|
||||
|
||||
$id = $stmt->fetchColumn();
|
||||
if ($id === false) {
|
||||
throw new RuntimeException('Could not insert sales_order_line');
|
||||
}
|
||||
|
||||
return (int) $id;
|
||||
}
|
||||
@@ -1,438 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../shared/db.php';
|
||||
require_once __DIR__ . '/../../../shared/webhook_throttle.php';
|
||||
require_once __DIR__ . '/../../../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../../../modules/shared/webhook_throttle.php';
|
||||
require_once __DIR__ . '/../../kontakte/service.php';
|
||||
require_once __DIR__ . '/../../bestellungen/service.php';
|
||||
require_once __DIR__ . '/../../artikel-mapping/service.php';
|
||||
require_once __DIR__ . '/../../lager/service.php';
|
||||
require_once __DIR__ . '/../../import-integration/service.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') {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
|
||||
}
|
||||
|
||||
@@ -472,7 +51,7 @@ if ($totalPrice === null || $totalPrice <= 0) {
|
||||
}
|
||||
|
||||
$resolvedProducts = [];
|
||||
$totalQty = 0;
|
||||
$totalQty = 0.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']);
|
||||
@@ -496,87 +75,31 @@ if ($totalQty <= 0) {
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = connect_database();
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
$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) {
|
||||
$paymentMethodId = lookup_method_id($pdo, 'payment_method', $paymentMethodCode);
|
||||
if ($paymentMethodId === null) {
|
||||
throw new RuntimeException('Invalid payment method');
|
||||
}
|
||||
|
||||
$partyName = trim(((string) ($billing['firstName'] ?? '')) . ' ' . ((string) ($billing['lastName'] ?? '')));
|
||||
$partyName = $partyName === '' ? 'OTC Kunde' : $partyName;
|
||||
$partyId = find_or_create_party($pdo, $billing);
|
||||
upsert_addresses($pdo, $partyId, $billing);
|
||||
|
||||
$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([
|
||||
$order = create_direct_sales_order($pdo, [
|
||||
':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,
|
||||
':payment_method_id' => $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) {
|
||||
@@ -585,7 +108,10 @@ try {
|
||||
$title = $product['title'];
|
||||
|
||||
$resolvedProduct = resolve_otc_product($pdo, $title);
|
||||
$resolvedSellable = resolve_otc_sellable_item($pdo, (int) $resolvedProduct['id']);
|
||||
$sellableItemId = resolve_sellable_item_id($pdo, '', $title);
|
||||
if ($sellableItemId === null) {
|
||||
throw new RuntimeException("Kein Artikel-Mapping fuer '{$title}' gefunden");
|
||||
}
|
||||
|
||||
if ($lineNo === count($resolvedProducts)) {
|
||||
$unitPrice = round($remainingTotal / $qty, 4);
|
||||
@@ -594,39 +120,32 @@ try {
|
||||
$unitPrice = round((float) $totalPrice / (float) $totalQty, 4);
|
||||
$lineTotal = round($qty * $unitPrice, 2);
|
||||
$remainingTotal = round($remainingTotal - $lineTotal, 2);
|
||||
$remainingQty -= $qty;
|
||||
}
|
||||
|
||||
$lineInsert->execute([
|
||||
$lineId = insert_sales_order_line($pdo, [
|
||||
':sales_order_id' => $orderId,
|
||||
':line_no' => $lineNo,
|
||||
':sellable_item_id' => (int) $resolvedSellable['id'],
|
||||
':article_number' => $resolvedSellable['article_number'],
|
||||
':title' => $resolvedSellable['display_name'],
|
||||
':sellable_item_id' => $sellableItemId,
|
||||
':article_number' => (string) $resolvedProduct['sku'],
|
||||
':title' => (string) $resolvedProduct['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(
|
||||
allocate_line_inventory(
|
||||
$pdo,
|
||||
$orderId,
|
||||
(int) $lineId,
|
||||
$lineId,
|
||||
$lineNo,
|
||||
$components,
|
||||
$qty,
|
||||
$sellableItemId,
|
||||
$locations
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
|
||||
$excelTrigger = trigger_excel_webhook($externalRef, $env);
|
||||
|
||||
json_response(201, [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function derive_label_webhook_url(array $localEnv): string
|
||||
{
|
||||
$explicit = env_value('N8N_LABEL_WEBHOOK_URL', $localEnv);
|
||||
if ($explicit !== '') {
|
||||
return $explicit;
|
||||
}
|
||||
|
||||
$legacy = env_value('N8N_OUTBOUND_URL_ADRESSE', $localEnv);
|
||||
if ($legacy !== '' && str_contains(strtolower($legacy), 'adressetikette')) {
|
||||
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/naurua_erp_adressetikette';
|
||||
}
|
||||
|
||||
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_shipping_label_flow(array $order, array $localEnv): array
|
||||
{
|
||||
$url = derive_label_webhook_url($localEnv);
|
||||
if ($url === '') {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'ok' => false,
|
||||
'message' => 'Label 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('label', 10);
|
||||
$result = post_json($url, $order, $headers, 20);
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'ok' => $result['ok'],
|
||||
'status' => $result['status'],
|
||||
'url' => $url,
|
||||
'message' => $result['ok'] ? 'Label webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Label webhook returned non-2xx'),
|
||||
'responseBody' => $result['body'],
|
||||
];
|
||||
}
|
||||
|
||||
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'],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function find_or_create_party(PDO $pdo, array $data): int
|
||||
{
|
||||
$email = trim((string) ($data['E-Mail'] ?? $data['email'] ?? ''));
|
||||
if ($email !== '') {
|
||||
$stmt = $pdo->prepare('SELECT id FROM party WHERE email = :email LIMIT 1');
|
||||
$stmt->execute([':email' => $email]);
|
||||
$existing = $stmt->fetchColumn();
|
||||
if ($existing !== false) {
|
||||
return (int) $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$name = trim((string) ($data['Vorname'] ?? $data['firstName'] ?? '')) . ' ' . trim((string) ($data['Nachname'] ?? $data['lastName'] ?? ''));
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
$name = trim((string) ($data['Firmenname'] ?? $data['company'] ?? ''));
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = 'Unbekannter Kontakt';
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO party (type, name, email, status, created_at, updated_at)
|
||||
VALUES ('customer', :name, :email, 'active', NOW(), NOW())
|
||||
RETURNING id"
|
||||
);
|
||||
$stmt->execute([
|
||||
':name' => $name,
|
||||
':email' => $email !== '' ? $email : null,
|
||||
]);
|
||||
|
||||
$id = $stmt->fetchColumn();
|
||||
if ($id === false) {
|
||||
throw new RuntimeException('Could not create party');
|
||||
}
|
||||
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
function upsert_addresses(PDO $pdo, int $partyId, array $data): void
|
||||
{
|
||||
$addressData = [
|
||||
'billing' => [
|
||||
'first_name' => trim((string) ($data['Vorname'] ?? $data['firstName'] ?? '')),
|
||||
'last_name' => trim((string) ($data['Nachname'] ?? $data['lastName'] ?? '')),
|
||||
'street' => trim((string) ($data['Strasse'] ?? $data['street'] ?? '')),
|
||||
'house_number' => trim((string) ($data['Hausnummer'] ?? $data['houseNumber'] ?? '')),
|
||||
'zip' => trim((string) ($data['PLZ'] ?? $data['zip'] ?? '')),
|
||||
'city' => trim((string) ($data['Ort'] ?? $data['city'] ?? '')),
|
||||
'country_name' => trim((string) ($data['Land'] ?? $data['country'] ?? 'Switzerland')),
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($addressData as $type => $address) {
|
||||
if ($address['street'] === '' && $address['city'] === '' && $address['zip'] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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, :type, :first_name, :last_name, :street, :house_number, :zip, :city, :country_name, NOW(), NOW()
|
||||
)"
|
||||
);
|
||||
$stmt->execute([
|
||||
':party_id' => $partyId,
|
||||
':type' => $type,
|
||||
':first_name' => $address['first_name'],
|
||||
':last_name' => $address['last_name'],
|
||||
':street' => $address['street'],
|
||||
':house_number' => $address['house_number'],
|
||||
':zip' => $address['zip'],
|
||||
':city' => $address['city'],
|
||||
':country_name' => $address['country_name'] !== '' ? $address['country_name'] : 'Switzerland',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
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_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 insert_stock_move_in(
|
||||
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, 'in', 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 Lagerzugang 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 reverse_existing_allocations_for_order(PDO $pdo, int $orderId, int $fallbackStorageLocationId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
a.stock_move_id,
|
||||
a.product_id,
|
||||
a.lot_id,
|
||||
a.qty,
|
||||
sm.from_location_id,
|
||||
sm.to_location_id
|
||||
FROM sales_order_line_lot_allocation a
|
||||
LEFT JOIN stock_move sm ON sm.id = a.stock_move_id
|
||||
INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id
|
||||
WHERE sol.sales_order_id = :order_id
|
||||
ORDER BY a.id DESC"
|
||||
);
|
||||
$stmt->execute([':order_id' => $orderId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$reverseCount = 0;
|
||||
$reverseQty = 0.0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$productId = (int) $row['product_id'];
|
||||
$lotId = (int) $row['lot_id'];
|
||||
$qty = (float) $row['qty'];
|
||||
$fromLocationId = (int) ($row['from_location_id'] ?? $fallbackStorageLocationId);
|
||||
$toLocationId = (int) ($row['to_location_id'] ?? $fallbackStorageLocationId);
|
||||
|
||||
insert_stock_move_in(
|
||||
$pdo,
|
||||
$productId,
|
||||
$lotId,
|
||||
$qty,
|
||||
$toLocationId,
|
||||
$fromLocationId,
|
||||
"reverse-order={$orderId}"
|
||||
);
|
||||
|
||||
$reverseCount++;
|
||||
$reverseQty += $qty;
|
||||
}
|
||||
|
||||
return [
|
||||
'reversedMoves' => $reverseCount,
|
||||
'reversedQty' => $reverseQty,
|
||||
];
|
||||
}
|
||||
|
||||
function has_available_stock_for_product(PDO $pdo, int $productId, float $epsilon = 0.0000001): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COALESCE(SUM(v.qty_net), 0) AS qty_net
|
||||
FROM v_stock_lot_balance v
|
||||
INNER JOIN stock_lot sl ON sl.id = v.stock_lot_id
|
||||
WHERE sl.product_id = :product_id
|
||||
AND sl.status = 'current'"
|
||||
);
|
||||
$stmt->execute([':product_id' => $productId]);
|
||||
$qty = (float) ($stmt->fetchColumn() ?: 0);
|
||||
return $qty > $epsilon;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
$allocationCount = 0;
|
||||
$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,
|
||||
]);
|
||||
$allocationCount++;
|
||||
|
||||
$remaining -= $take;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'allocated' => true,
|
||||
'allocationCount' => $allocationCount,
|
||||
];
|
||||
}
|
||||
|
||||
function allocate_line_inventory(
|
||||
PDO $pdo,
|
||||
int $orderId,
|
||||
int $lineId,
|
||||
int $lineNo,
|
||||
float $lineQty,
|
||||
int $sellableItemId,
|
||||
array $locations
|
||||
): array {
|
||||
$components = get_item_components($pdo, $sellableItemId);
|
||||
return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations);
|
||||
}
|
||||
|
||||
function allocate_line_inventory_fallback_product(
|
||||
PDO $pdo,
|
||||
int $orderId,
|
||||
int $lineId,
|
||||
int $lineNo,
|
||||
float $lineQty,
|
||||
int $productId,
|
||||
array $locations
|
||||
): array {
|
||||
$components = [[
|
||||
'product_id' => $productId,
|
||||
'qty_per_item' => 1.0,
|
||||
]];
|
||||
return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations);
|
||||
}
|
||||
Reference in New Issue
Block a user