feat(112): re-import data protection — delta-only re-import + project_generated preservation

Phase 112 / Plan 112-01 complete (v3.6):
- OrderImportRepository::upsertOrderAggregate split into create vs re-import paths
- replaceAddresses/Items/Notes/Shipments/StatusHistory invoked only on first import
- new updateOrderDelta() narrows UPDATE to status_code (cond.), payment_status,
  total_paid, is_canceled_by_buyer, source_updated_at, payload_json, fetched_at
- source-side cancellation override (is_canceled_by_buyer=1 OR pull status_code='anulowane')
- identical-payload no-op guard via normalizePayloadJson()
- fixes case #882: order_items.id stable, project_generated (Phase 97) preserved
- Phase 111 payment.status_changed emit retained without regression

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 23:22:37 +02:00
parent 0e457aed38
commit 782a291210
10 changed files with 638 additions and 73 deletions

View File

@@ -41,10 +41,20 @@ final class OrderImportRepository
$paymentTransition = false;
$statusOverwriteAllowed = false;
if (!$created) {
$existing = $this->getCurrentStatusAndPaymentStatus($existingOrderId);
if ($created) {
$orderId = $this->insertOrder($orderData);
$this->replaceAddresses($orderId, $addresses);
$this->replaceItems($orderId, $items);
$this->replaceNotes($orderId, $notes);
$this->replacePayments($orderId, $payments);
$this->replaceShipments($orderId, $shipments);
$this->replaceStatusHistory($orderId, $statusHistory);
} else {
$orderId = $existingOrderId;
$existing = $this->getCurrentOrderState($orderId);
$currentStatus = $existing['status_code'];
$oldPaymentStatus = $existing['payment_status'];
$existingPayloadJson = $existing['payload_json'];
$newPaymentStatus = (int) ($orderData['payment_status'] ?? 0);
$paymentTransition = in_array($oldPaymentStatus, [0, 1], true) && $newPaymentStatus === 2;
@@ -53,22 +63,34 @@ final class OrderImportRepository
if (!$statusOverwriteAllowed) {
$orderData['status_code'] = $currentStatus;
}
}
$orderId = $created
? $this->insertOrder($orderData)
: $this->updateOrder($existingOrderId, $orderData);
// Phase 112-01: Propagate source-side cancellation as override (after status preservation block)
$sourceStatus = strtolower(trim((string) ($orderData['status_code'] ?? '')));
$cancelledBySource = !empty($orderData['is_canceled_by_buyer']) || $sourceStatus === 'anulowane';
if ($cancelledBySource) {
$orderData['status_code'] = 'anulowane';
}
$this->replaceAddresses($orderId, $addresses);
$this->replaceItems($orderId, $items);
$this->replaceNotes($orderId, $notes);
// Phase 112-01: Identical payload guard — skip UPDATE when nothing changed
$newPayloadNormalized = $this->normalizePayloadJson($orderData['payload_json'] ?? null);
$existingPayloadNormalized = $this->normalizePayloadJson($existingPayloadJson);
$payloadIdentical = $newPayloadNormalized !== null
&& $existingPayloadNormalized !== null
&& $newPayloadNormalized === $existingPayloadNormalized;
if ($payloadIdentical && !$paymentTransition && !$statusOverwriteAllowed && !$cancelledBySource) {
$this->pdo->commit();
return [
'order_id' => $orderId,
'created' => false,
'payment_transition' => false,
];
}
if ($created) {
$this->replacePayments($orderId, $payments);
$this->replaceShipments($orderId, $shipments);
$this->replaceStatusHistory($orderId, $statusHistory);
} elseif ($paymentTransition || $statusOverwriteAllowed) {
$this->replacePayments($orderId, $payments);
$this->updateOrderDelta($orderId, $orderData);
if ($paymentTransition || $statusOverwriteAllowed) {
$this->replacePayments($orderId, $payments);
}
}
$this->pdo->commit();
@@ -108,23 +130,25 @@ final class OrderImportRepository
}
/**
* @return array{status_code:string, payment_status:int}
* @return array{status_code:string, payment_status:int, payload_json:?string}
*/
private function getCurrentStatusAndPaymentStatus(int $orderId): array
private function getCurrentOrderState(int $orderId): array
{
$statement = $this->pdo->prepare(
'SELECT status_code, payment_status FROM orders WHERE id = :id LIMIT 1'
'SELECT status_code, payment_status, payload_json FROM orders WHERE id = :id LIMIT 1'
);
$statement->execute(['id' => $orderId]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return ['status_code' => '', 'payment_status' => 0];
return ['status_code' => '', 'payment_status' => 0, 'payload_json' => null];
}
$payload = $row['payload_json'] ?? null;
return [
'status_code' => strtolower(trim((string) ($row['status_code'] ?? ''))),
'payment_status' => (int) ($row['payment_status'] ?? 0),
'payload_json' => is_string($payload) && $payload !== '' ? $payload : null,
];
}
@@ -159,47 +183,39 @@ final class OrderImportRepository
}
/**
* Phase 112-01: Delta-only update for re-import. Touches only fields that legitimately
* change at the source between syncs. All other order columns (integration_id, source,
* external_*, customer_login, currency, totals other than total_paid, delivery_price,
* send_date_*, ordered_at, source_created_at, preferences_json, is_invoice, is_encrypted,
* external_carrier_*, external_payment_type_id) are NOT overwritten on re-import.
*
* @param array<string, mixed> $orderData
*/
private function updateOrder(int $orderId, array $orderData): int
private function updateOrderDelta(int $orderId, array $orderData): int
{
$statement = $this->pdo->prepare(
'UPDATE orders
SET integration_id = :integration_id,
source = :source,
source_order_id = :source_order_id,
external_order_id = :external_order_id,
external_platform_id = :external_platform_id,
external_platform_account_id = :external_platform_account_id,
status_code = :status_code,
external_payment_type_id = :external_payment_type_id,
SET status_code = :status_code,
payment_status = :payment_status,
external_carrier_id = :external_carrier_id,
external_carrier_account_id = :external_carrier_account_id,
customer_login = :customer_login,
is_invoice = :is_invoice,
is_encrypted = :is_encrypted,
is_canceled_by_buyer = :is_canceled_by_buyer,
currency = :currency,
total_without_tax = :total_without_tax,
total_with_tax = :total_with_tax,
total_paid = :total_paid,
delivery_price = :delivery_price,
send_date_min = :send_date_min,
send_date_max = :send_date_max,
ordered_at = :ordered_at,
source_created_at = :source_created_at,
is_canceled_by_buyer = :is_canceled_by_buyer,
source_updated_at = :source_updated_at,
preferences_json = :preferences_json,
payload_json = :payload_json,
fetched_at = :fetched_at,
updated_at = NOW()
WHERE id = :id'
);
$params = $this->orderParams($orderData);
$params['id'] = $orderId;
$statement->execute($params);
$statement->execute([
'id' => $orderId,
'status_code' => $orderData['status_code'] ?? null,
'payment_status' => $orderData['payment_status'] ?? null,
'total_paid' => $orderData['total_paid'] ?? null,
'is_canceled_by_buyer' => !empty($orderData['is_canceled_by_buyer']) ? 1 : 0,
'source_updated_at' => $orderData['source_updated_at'] ?? null,
'payload_json' => $this->encodeJson($orderData['payload_json'] ?? null),
'fetched_at' => $orderData['fetched_at'] ?? date('Y-m-d H:i:s'),
]);
return $orderId;
}
@@ -470,4 +486,28 @@ final class OrderImportRepository
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
}
/**
* Phase 112-01: Normalize payload (array or stored JSON string) to a comparable JSON string.
* Used by the identical-payload guard in upsertOrderAggregate(). Returns null when input is
* empty or not parseable as an array.
*/
private function normalizePayloadJson(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (!is_array($decoded)) {
return null;
}
$value = $decoded;
}
if (!is_array($value)) {
return null;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
}
}