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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user