307 lines
13 KiB
PHP
307 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../kontakte/service.php';
|
|
require_once __DIR__ . '/../artikel-mapping/service.php';
|
|
require_once __DIR__ . '/../lager/service.php';
|
|
|
|
function shopify_projection_payment_status(string $financialStatus): string
|
|
{
|
|
return match (strtoupper(trim($financialStatus))) {
|
|
'PAID', 'PARTIALLY_PAID' => 'paid',
|
|
'AUTHORIZED' => 'authorized',
|
|
'PARTIALLY_REFUNDED' => 'partially_refunded',
|
|
'REFUNDED' => 'refunded',
|
|
'VOIDED' => 'voided',
|
|
default => 'unknown',
|
|
};
|
|
}
|
|
|
|
function shopify_projection_order_status(array $order): string
|
|
{
|
|
if (($order['cancelledAt'] ?? null) !== null) {
|
|
return 'cancelled';
|
|
}
|
|
|
|
return strtoupper((string) ($order['displayFulfillmentStatus'] ?? '')) === 'FULFILLED'
|
|
? 'fulfilled'
|
|
: 'imported';
|
|
}
|
|
|
|
function shopify_projection_money(array $order, string $field): ?float
|
|
{
|
|
$amount = $order[$field]['shopMoney']['amount'] ?? null;
|
|
return $amount === null ? null : parse_number($amount);
|
|
}
|
|
|
|
function shopify_projection_customer(PDO $pdo, array $customer, bool $dryRun): ?int
|
|
{
|
|
$customerGid = trim((string) ($customer['id'] ?? ''));
|
|
if ($customerGid === '') {
|
|
return null;
|
|
}
|
|
|
|
$identity = $pdo->prepare(
|
|
'SELECT party_id FROM party_external_identity
|
|
WHERE source_system = \'shopify\' AND external_id = :external_id LIMIT 1'
|
|
);
|
|
$identity->execute([':external_id' => $customerGid]);
|
|
$partyId = $identity->fetchColumn();
|
|
if ($partyId !== false) {
|
|
return (int) $partyId;
|
|
}
|
|
|
|
$email = trim((string) ($customer['email'] ?? ''));
|
|
if ($dryRun) {
|
|
return null;
|
|
}
|
|
|
|
$name = trim((string) ($customer['firstName'] ?? '') . ' ' . (string) ($customer['lastName'] ?? ''));
|
|
if ($name === '') {
|
|
$name = 'Shopify-Kunde';
|
|
}
|
|
|
|
$partyStmt = $pdo->prepare(
|
|
"INSERT INTO party (type, name, email, status, created_at, updated_at)
|
|
VALUES ('customer', :name, :email, 'active', NOW(), NOW())
|
|
RETURNING id"
|
|
);
|
|
$partyStmt->execute([':name' => $name, ':email' => $email !== '' ? $email : null]);
|
|
$partyId = $partyStmt->fetchColumn();
|
|
if ($partyId === false) {
|
|
throw new RuntimeException('Shopify-Kunde konnte nicht angelegt werden');
|
|
}
|
|
|
|
$identityStmt = $pdo->prepare(
|
|
"INSERT INTO party_external_identity (party_id, source_system, external_id, observed_at, created_at, updated_at)
|
|
VALUES (:party_id, 'shopify', :external_id, NOW(), NOW(), NOW())"
|
|
);
|
|
$identityStmt->execute([':party_id' => (int) $partyId, ':external_id' => $customerGid]);
|
|
|
|
return (int) $partyId;
|
|
}
|
|
|
|
function shopify_projection_address(PDO $pdo, ?int $partyId, array $address, string $type, bool $dryRun): void
|
|
{
|
|
if ($partyId === null || $dryRun || $address === []) {
|
|
return;
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO address (
|
|
party_id, type, first_name, last_name, company_name, street, house_number,
|
|
zip, city, state_code, country_name, country_iso2, created_at, updated_at
|
|
) VALUES (
|
|
:party_id, :type, :first_name, :last_name, :company_name, :street, :house_number,
|
|
:zip, :city, :state_code, :country_name, :country_iso2, NOW(), NOW()
|
|
)'
|
|
);
|
|
$street = trim((string) ($address['address1'] ?? ''));
|
|
$address2 = trim((string) ($address['address2'] ?? ''));
|
|
if ($address2 !== '') {
|
|
$street .= ($street !== '' ? ', ' : '') . $address2;
|
|
}
|
|
$stmt->execute([
|
|
':party_id' => $partyId,
|
|
':type' => $type,
|
|
':first_name' => trim((string) ($address['firstName'] ?? '')),
|
|
':last_name' => trim((string) ($address['lastName'] ?? '')),
|
|
':company_name' => trim((string) ($address['company'] ?? '')) ?: null,
|
|
':street' => $street,
|
|
':house_number' => null,
|
|
':zip' => trim((string) ($address['zip'] ?? '')),
|
|
':city' => trim((string) ($address['city'] ?? '')),
|
|
':state_code' => trim((string) ($address['provinceCode'] ?? '')) ?: null,
|
|
':country_name' => trim((string) ($address['country'] ?? '')) ?: null,
|
|
':country_iso2' => trim((string) ($address['countryCodeV2'] ?? '')) ?: null,
|
|
]);
|
|
}
|
|
|
|
function shopify_projection_line(PDO $pdo, array $line, bool $dryRun): array
|
|
{
|
|
$sku = trim((string) ($line['sku'] ?? ($line['variant']['sku'] ?? '')));
|
|
$variantGid = trim((string) ($line['variant']['id'] ?? ''));
|
|
$productGid = trim((string) ($line['variant']['product']['id'] ?? ''));
|
|
$sellableItemId = find_shopify_sellable_item_id($pdo, $sku, $variantGid);
|
|
if ($sellableItemId === null) {
|
|
throw new RuntimeException("Kein Shopify-Artikel-Mapping fuer SKU '{$sku}'");
|
|
}
|
|
|
|
if (!$dryRun) {
|
|
bind_shopify_item_identity($pdo, $sellableItemId, $sku, $productGid, $variantGid);
|
|
}
|
|
|
|
$qty = parse_number($line['quantity'] ?? null);
|
|
if ($qty === null || $qty <= 0) {
|
|
throw new RuntimeException("Ungueltige Shopify-Menge fuer SKU '{$sku}'");
|
|
}
|
|
|
|
return [
|
|
'line' => $line,
|
|
'sku' => $sku,
|
|
'variant_gid' => $variantGid,
|
|
'product_gid' => $productGid,
|
|
'sellable_item_id' => $sellableItemId,
|
|
'qty' => $qty,
|
|
'unit_price' => parse_number($line['originalUnitPriceSet']['shopMoney']['amount'] ?? null),
|
|
];
|
|
}
|
|
|
|
function shopify_projection_stock_check(PDO $pdo, array $resolvedLines): void
|
|
{
|
|
foreach ($resolvedLines as $resolvedLine) {
|
|
$components = get_item_components($pdo, (int) $resolvedLine['sellable_item_id']);
|
|
if ($components === []) {
|
|
throw new RuntimeException("Keine Komponenten fuer SKU '{$resolvedLine['sku']}'");
|
|
}
|
|
foreach ($components as $component) {
|
|
$required = (float) $resolvedLine['qty'] * (float) $component['qty_per_item'];
|
|
$stmt = $pdo->prepare(
|
|
"SELECT COALESCE(SUM(v.qty_net), 0)
|
|
FROM v_stock_lot_balance v
|
|
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' => (int) $component['product_id']]);
|
|
$available = (float) ($stmt->fetchColumn() ?: 0);
|
|
if ($available + 0.0000001 < $required) {
|
|
throw new RuntimeException("Nicht genuegend aktueller Bestand fuer SKU '{$resolvedLine['sku']}'");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function project_shopify_order(PDO $pdo, array $order, array $env, bool $dryRun = true): array
|
|
{
|
|
$orderGid = trim((string) ($order['id'] ?? ''));
|
|
if ($orderGid === '') {
|
|
throw new RuntimeException('Shopify Order-GID fehlt');
|
|
}
|
|
|
|
$existingStmt = $pdo->prepare('SELECT id FROM sales_order WHERE shopify_order_gid = :shopify_order_gid LIMIT 1');
|
|
$existingStmt->execute([':shopify_order_gid' => $orderGid]);
|
|
$existingId = $existingStmt->fetchColumn();
|
|
if ($existingId !== false) {
|
|
return ['status' => 'already_imported', 'shopify_order_gid' => $orderGid, 'sales_order_id' => (int) $existingId];
|
|
}
|
|
|
|
$resolvedLines = [];
|
|
foreach (($order['lineItems']['nodes'] ?? []) as $line) {
|
|
$resolvedLines[] = shopify_projection_line($pdo, $line, $dryRun);
|
|
}
|
|
if ($resolvedLines === []) {
|
|
throw new RuntimeException('Shopify-Bestellung enthaelt keine Positionen');
|
|
}
|
|
shopify_projection_stock_check($pdo, $resolvedLines);
|
|
|
|
if ($dryRun) {
|
|
$customer = is_array($order['customer'] ?? null) ? $order['customer'] : [];
|
|
shopify_projection_customer($pdo, $customer, true);
|
|
return [
|
|
'status' => 'dry_run_ready',
|
|
'shopify_order_gid' => $orderGid,
|
|
'sales_order_id' => null,
|
|
'line_count' => count($resolvedLines),
|
|
'allocation_count' => 0,
|
|
];
|
|
}
|
|
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$customer = is_array($order['customer'] ?? null) ? $order['customer'] : [];
|
|
$partyId = shopify_projection_customer($pdo, $customer, false);
|
|
$orderStatus = shopify_projection_order_status($order);
|
|
$paymentStatus = shopify_projection_payment_status((string) ($order['displayFinancialStatus'] ?? ''));
|
|
$orderDate = new DateTimeImmutable((string) $order['createdAt']);
|
|
$externalRef = (string) ($order['name'] ?? $orderGid);
|
|
$orderStmt = $pdo->prepare(
|
|
'INSERT INTO sales_order (
|
|
external_ref, party_id, order_date, order_status, payment_status, amount_net,
|
|
amount_shipping, amount_tax, total_amount, currency, webhook_payload, imported_at,
|
|
created_at, updated_at, order_source, shopify_order_gid, shopify_order_name,
|
|
shopify_created_at, shopify_updated_at, shopify_financial_status, shopify_fulfillment_status
|
|
) VALUES (
|
|
:external_ref, :party_id, :order_date, :order_status, :payment_status, :amount_net,
|
|
:amount_shipping, :amount_tax, :total_amount, :currency, :webhook_payload::jsonb, NOW(),
|
|
NOW(), NOW(), \'shopify\', :shopify_order_gid, :shopify_order_name,
|
|
:shopify_created_at, :shopify_updated_at, :shopify_financial_status, :shopify_fulfillment_status
|
|
) RETURNING id'
|
|
);
|
|
$orderStmt->execute([
|
|
':external_ref' => $externalRef,
|
|
':party_id' => $partyId,
|
|
':order_date' => $orderDate->format('Y-m-d H:i:s'),
|
|
':order_status' => $orderStatus,
|
|
':payment_status' => $paymentStatus,
|
|
':amount_net' => shopify_projection_money($order, 'currentSubtotalPriceSet'),
|
|
':amount_shipping' => shopify_projection_money($order, 'totalShippingPriceSet'),
|
|
':amount_tax' => shopify_projection_money($order, 'totalTaxSet'),
|
|
':total_amount' => shopify_projection_money($order, 'totalPriceSet'),
|
|
':currency' => (string) (($order['totalPriceSet']['shopMoney']['currencyCode'] ?? 'CHF')),
|
|
':webhook_payload' => json_encode($order, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
':shopify_order_gid' => $orderGid,
|
|
':shopify_order_name' => (string) ($order['name'] ?? ''),
|
|
':shopify_created_at' => $orderDate->format('Y-m-d H:i:s'),
|
|
':shopify_updated_at' => (new DateTimeImmutable((string) $order['updatedAt']))->format('Y-m-d H:i:s'),
|
|
':shopify_financial_status' => (string) ($order['displayFinancialStatus'] ?? ''),
|
|
':shopify_fulfillment_status' => (string) ($order['displayFulfillmentStatus'] ?? ''),
|
|
]);
|
|
$orderId = (int) $orderStmt->fetchColumn();
|
|
|
|
$locations = get_default_location_ids($pdo);
|
|
$lineNo = 0;
|
|
$allocationCount = 0;
|
|
foreach ($resolvedLines as $resolvedLine) {
|
|
$lineNo++;
|
|
$line = $resolvedLine['line'];
|
|
$lineStmt = $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, shopify_line_item_gid,
|
|
shopify_variant_gid, shopify_product_gid, sku, created_at, updated_at
|
|
) VALUES (
|
|
:sales_order_id, :line_no, :sellable_item_id, :article_number,
|
|
:title, :qty, :unit_price, :line_total, :line_gid,
|
|
:variant_gid, :product_gid, :sku, NOW(), NOW()
|
|
) RETURNING id'
|
|
);
|
|
$qty = (float) $resolvedLine['qty'];
|
|
$unitPrice = $resolvedLine['unit_price'];
|
|
$lineStmt->execute([
|
|
':sales_order_id' => $orderId,
|
|
':line_no' => $lineNo,
|
|
':sellable_item_id' => $resolvedLine['sellable_item_id'],
|
|
':article_number' => $resolvedLine['sku'],
|
|
':title' => (string) ($line['name'] ?? $resolvedLine['sku']),
|
|
':qty' => $qty,
|
|
':unit_price' => $unitPrice,
|
|
':line_total' => $unitPrice === null ? null : round($qty * $unitPrice, 2),
|
|
':line_gid' => $resolvedLine['line']['id'],
|
|
':variant_gid' => $resolvedLine['variant_gid'],
|
|
':product_gid' => $resolvedLine['product_gid'],
|
|
':sku' => $resolvedLine['sku'],
|
|
]);
|
|
$lineId = (int) $lineStmt->fetchColumn();
|
|
$allocation = allocate_line_inventory($pdo, $orderId, $lineId, $lineNo, $qty, (int) $resolvedLine['sellable_item_id'], $locations, 'shopify-order');
|
|
$allocationCount += (int) ($allocation['allocationCount'] ?? 0);
|
|
}
|
|
|
|
shopify_projection_address($pdo, $partyId, (array) ($order['billingAddress'] ?? []), 'billing', false);
|
|
shopify_projection_address($pdo, $partyId, (array) ($order['shippingAddress'] ?? []), 'shipping', false);
|
|
$pdo->commit();
|
|
} catch (Throwable $exception) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $exception;
|
|
}
|
|
|
|
return [
|
|
'status' => 'imported',
|
|
'shopify_order_gid' => $orderGid,
|
|
'sales_order_id' => $orderId,
|
|
'line_count' => count($resolvedLines),
|
|
'allocation_count' => $allocationCount,
|
|
];
|
|
}
|