Add ERP order import endpoint and n8n order-ingest flow export
This commit is contained in:
442
order-import.php
Normal file
442
order-import.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function json_response(int $status, array $payload): void
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
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 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 lookup_method_id(PDO $pdo, string $table, ?string $code): ?int
|
||||
{
|
||||
if ($code === null || $code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id FROM {$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 connect_database(array $localEnv): PDO
|
||||
{
|
||||
$databaseUrl = env_value('DATABASE_URL', $localEnv);
|
||||
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', $localEnv);
|
||||
$port = env_value('DB_PORT', $localEnv, '5432');
|
||||
$dbName = env_value('DB_NAME', $localEnv);
|
||||
$user = env_value('DB_USER', $localEnv);
|
||||
$pass = env_value('DB_PASSWORD', $localEnv);
|
||||
|
||||
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 find_or_create_party(PDO $pdo, array $data): int
|
||||
{
|
||||
$email = trim((string) ($data['EmailKunde'] ?? ''));
|
||||
$firstName = trim((string) ($data['Vorname_RgAdr'] ?? ''));
|
||||
$lastName = trim((string) ($data['Nachname_RgAdr'] ?? ''));
|
||||
$name = trim($firstName . ' ' . $lastName);
|
||||
if ($name === '') {
|
||||
$name = 'Online-Shop Kunde';
|
||||
}
|
||||
|
||||
if ($email !== '') {
|
||||
$findStmt = $pdo->prepare('SELECT id FROM party WHERE lower(email) = lower(:email) ORDER BY id ASC LIMIT 1');
|
||||
$findStmt->execute([':email' => $email]);
|
||||
$existing = $findStmt->fetchColumn();
|
||||
if ($existing !== false) {
|
||||
$partyId = (int) $existing;
|
||||
$updateStmt = $pdo->prepare('UPDATE party SET name = :name, updated_at = NOW() WHERE id = :id');
|
||||
$updateStmt->execute([':id' => $partyId, ':name' => $name]);
|
||||
return $partyId;
|
||||
}
|
||||
}
|
||||
|
||||
$insertStmt = $pdo->prepare(
|
||||
'INSERT INTO party (type, name, email, status, created_at, updated_at)
|
||||
VALUES (\'customer\', :name, :email, \'active\', NOW(), NOW())
|
||||
RETURNING id'
|
||||
);
|
||||
$insertStmt->execute([
|
||||
':name' => $name,
|
||||
':email' => $email !== '' ? $email : null,
|
||||
]);
|
||||
|
||||
$id = $insertStmt->fetchColumn();
|
||||
if ($id === false) {
|
||||
throw new RuntimeException('Could not create party');
|
||||
}
|
||||
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
function upsert_addresses(PDO $pdo, int $partyId, array $data): void
|
||||
{
|
||||
$delete = $pdo->prepare('DELETE FROM address WHERE party_id = :party_id AND type IN (\'billing\', \'shipping\')');
|
||||
$delete->execute([':party_id' => $partyId]);
|
||||
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO address (
|
||||
party_id, type, first_name, last_name, street, house_number, zip, city, state_code, country_name, raw_payload, created_at, updated_at
|
||||
) VALUES (
|
||||
:party_id, :type, :first_name, :last_name, :street, :house_number, :zip, :city, :state_code, :country_name, :raw_payload::jsonb, NOW(), NOW()
|
||||
)'
|
||||
);
|
||||
|
||||
$insert->execute([
|
||||
':party_id' => $partyId,
|
||||
':type' => 'billing',
|
||||
':first_name' => trim((string) ($data['Vorname_RgAdr'] ?? '')),
|
||||
':last_name' => trim((string) ($data['Nachname_RgAdr'] ?? '')),
|
||||
':street' => trim((string) ($data['Strasse_RgAdr'] ?? '')),
|
||||
':house_number' => trim((string) ($data['Hausnummer_RgAdr'] ?? '')),
|
||||
':zip' => trim((string) ($data['PLZ_RgAdr'] ?? '')),
|
||||
':city' => trim((string) ($data['Stadt_RgAdr'] ?? '')),
|
||||
':state_code' => trim((string) ($data['Bundesland_RgAdr'] ?? '')),
|
||||
':country_name' => trim((string) ($data['Land_RgAdr'] ?? '')),
|
||||
':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
|
||||
$insert->execute([
|
||||
':party_id' => $partyId,
|
||||
':type' => 'shipping',
|
||||
':first_name' => trim((string) ($data['Vorname_LfAdr'] ?? '')),
|
||||
':last_name' => trim((string) ($data['Nachname_LfAdr'] ?? '')),
|
||||
':street' => trim((string) ($data['Strasse_LfAdr'] ?? '')),
|
||||
':house_number' => trim((string) ($data['Hausnummer_LfAdr'] ?? '')),
|
||||
':zip' => trim((string) ($data['PLZ_LfAdr'] ?? '')),
|
||||
':city' => trim((string) ($data['Stadt_LfAdr'] ?? '')),
|
||||
':state_code' => trim((string) ($data['Bundesland_LfAdr'] ?? '')),
|
||||
':country_name' => trim((string) ($data['Land_LfAdr'] ?? '')),
|
||||
':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
|
||||
}
|
||||
|
||||
$env = parse_env_file(__DIR__ . '/.env');
|
||||
$env = array_merge($env, parse_env_file(dirname(__DIR__) . '/.env'));
|
||||
$env = expand_env_values($env);
|
||||
|
||||
$expectedSecret = env_value('N8N_WEBHOOK_SECRET', $env);
|
||||
$providedSecret = (string) ($_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '');
|
||||
|
||||
if ($expectedSecret === '') {
|
||||
json_response(500, ['ok' => false, 'error' => 'N8N_WEBHOOK_SECRET not configured']);
|
||||
}
|
||||
|
||||
if ($providedSecret === '' || !hash_equals($expectedSecret, $providedSecret)) {
|
||||
json_response(401, ['ok' => false, 'error' => 'Unauthorized']);
|
||||
}
|
||||
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
if ($rawPayload === false || trim($rawPayload) === '') {
|
||||
json_response(400, ['ok' => false, 'error' => 'Empty payload']);
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($rawPayload, 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']);
|
||||
}
|
||||
|
||||
$externalRef = trim((string) ($data['BestellungNr'] ?? ''));
|
||||
if ($externalRef === '') {
|
||||
json_response(422, ['ok' => false, 'error' => 'BestellungNr is required']);
|
||||
}
|
||||
|
||||
$lineItems = $data['lineItems'] ?? [];
|
||||
if (!is_array($lineItems)) {
|
||||
$lineItems = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = connect_database($env);
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$partyId = find_or_create_party($pdo, $data);
|
||||
upsert_addresses($pdo, $partyId, $data);
|
||||
|
||||
$paymentMethodId = lookup_method_id($pdo, 'payment_method', map_payment_code((string) ($data['Zahlungsmethode'] ?? '')));
|
||||
$shippingMethodId = lookup_method_id($pdo, 'shipping_method', map_shipping_code((string) ($data['Liefermethode'] ?? '')));
|
||||
|
||||
$orderStmt = $pdo->prepare(
|
||||
'INSERT INTO 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, \'wix\', \'imported\', \'paid\', :payment_method_id, :shipping_method_id,
|
||||
:amount_net, :amount_shipping, :amount_tax, :amount_discount, :total_amount, \'CHF\', :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'
|
||||
);
|
||||
|
||||
$orderStmt->execute([
|
||||
':external_ref' => $externalRef,
|
||||
':party_id' => $partyId,
|
||||
':payment_method_id' => $paymentMethodId,
|
||||
':shipping_method_id' => $shippingMethodId,
|
||||
':amount_net' => parse_number($data['Netto'] ?? null),
|
||||
':amount_shipping' => parse_number($data['Versandkosten'] ?? null),
|
||||
':amount_tax' => parse_number($data['Mehrwertsteuer'] ?? null),
|
||||
':amount_discount' => parse_number($data['Rabatt'] ?? null),
|
||||
':total_amount' => parse_number($data['Gesamtsumme'] ?? null),
|
||||
':webhook_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
|
||||
$orderId = $orderStmt->fetchColumn();
|
||||
if ($orderId === false) {
|
||||
throw new RuntimeException('Could not upsert order');
|
||||
}
|
||||
$orderId = (int) $orderId;
|
||||
|
||||
$deleteLines = $pdo->prepare('DELETE FROM sales_order_line WHERE sales_order_id = :sales_order_id');
|
||||
$deleteLines->execute([':sales_order_id' => $orderId]);
|
||||
|
||||
$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, NULL, :article_number, :title,
|
||||
:qty, :unit_price, :line_total, NOW(), NOW()
|
||||
)'
|
||||
);
|
||||
|
||||
$insertedLines = 0;
|
||||
foreach ($lineItems as $index => $lineItem) {
|
||||
if (!is_array($lineItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$qty = parse_number($lineItem['artikelanzahl'] ?? null);
|
||||
if ($qty === null || $qty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unitPrice = parse_number($lineItem['preisEinheit'] ?? null);
|
||||
$lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null;
|
||||
|
||||
$lineInsert->execute([
|
||||
':sales_order_id' => $orderId,
|
||||
':line_no' => $index + 1,
|
||||
':article_number' => trim((string) ($lineItem['artikelnummer'] ?? '')),
|
||||
':title' => trim((string) ($lineItem['titel'] ?? '')),
|
||||
':qty' => $qty,
|
||||
':unit_price' => $unitPrice,
|
||||
':line_total' => $lineTotal,
|
||||
]);
|
||||
$insertedLines++;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
json_response(200, [
|
||||
'ok' => true,
|
||||
'orderId' => $orderId,
|
||||
'externalRef' => $externalRef,
|
||||
'lineItemsImported' => $insertedLines,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
json_response(500, [
|
||||
'ok' => false,
|
||||
'error' => 'Order import failed',
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user