Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 84 additions & 16 deletions app/code/core/Maho/DataSync/Model/Entity/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -1170,36 +1170,97 @@ protected function _hasBillingAddress(array $data, bool $isVirtual = false): boo
* Results are cached for performance during bulk imports.
*/
/**
* SKU-to-product-ID cache for order item import
* Resolved product-ID cache for order item import, keyed by "sourceId|sku"
*/
protected static array $_skuProductIdCache = [];

/**
* Resolve local product_id by SKU, falling back to source product_id
* Resolve the local product_id for an order item.
*
* Source order items carry the source system's product_id which won't match
* the target catalog. Look up by SKU to get the correct local ID.
* The source system's product_id does not match the target catalog, so it has
* to be translated. `datasync_source_id` records exactly that translation and
* is the only key that identifies a product unambiguously, so it is tried
* first.
*
* SKU is a fallback, not the primary key, because it does not identify an
* order item's product. On a configurable order the PARENT row stores the
* CHILD's sku while pointing at the parent product:
*
* parent_item_id NULL product_type configurable product_id 30347 sku 232026-4 3/8
* parent_item_id 566539 product_type simple product_id 30355 sku 232026-4 3/8
*
* Resolving that by sku lands both rows on the child, so the configurable
* parent -- the product customers actually search and browse -- is credited
* with none of its own sales. Ordered-quantity ranking, bestseller reports,
* recommendations and cross-sells all read these rows.
*
* Returns null when neither key resolves. Returning the unmapped source id
* would point the row at whichever local product happens to occupy that id.
*/
protected function _resolveLocalProductId(string $sku, ?int $sourceProductId): ?int
{
if ($sku === '') {
return $sourceProductId;
$cacheKey = ($sourceProductId ?? '-') . '|' . $sku;
if (array_key_exists($cacheKey, self::$_skuProductIdCache)) {
return self::$_skuProductIdCache[$cacheKey];
}

if (isset(self::$_skuProductIdCache[$sku])) {
return self::$_skuProductIdCache[$sku];
$localId = $sourceProductId === null ? null : $this->_findProductBySourceId($sourceProductId);

if ($localId === null && $sku !== '') {
$resource = Mage::getSingleton('core/resource');
$found = $resource->getConnection('core_read')->fetchOne(
"SELECT entity_id FROM {$resource->getTableName('catalog/product')} WHERE sku = ?",
[$sku],
);
$localId = $found ? (int) $found : null;
}

if ($localId === null) {
Mage::log(
sprintf(
'DataSync: order item product unresolved (source product_id %s, sku %s) - product_id left null',
$sourceProductId ?? 'null',
$sku === '' ? '(none)' : $sku,
),
Mage::LOG_NOTICE,
'datasync.log',
);
}

self::$_skuProductIdCache[$cacheKey] = $localId;
return $localId;
}

/**
* Local entity_id of the product imported from $sourceProductId, or null.
*/
protected function _findProductBySourceId(int $sourceProductId): ?int
{
$resource = Mage::getSingleton('core/resource');
$read = $resource->getConnection('core_read');
$localId = $read->fetchOne(
"SELECT entity_id FROM {$resource->getTableName('catalog/product')} WHERE sku = ?",
[$sku],

$entityTypeId = (int) Mage::getSingleton('eav/config')
->getEntityType(Mage_Catalog_Model_Product::ENTITY)
->getId();

$attributeId = (int) $read->fetchOne(
"SELECT attribute_id FROM {$resource->getTableName('eav/attribute')}
WHERE attribute_code = ? AND entity_type_id = ?",
['datasync_source_id', $entityTypeId],
);
if ($attributeId === 0) {
return null;
}

$result = $localId ? (int) $localId : $sourceProductId;
self::$_skuProductIdCache[$sku] = $result;
return $result;
$ids = $read->fetchCol(
"SELECT entity_id FROM {$resource->getTableName('catalog/product')}_int
WHERE attribute_id = ? AND store_id = 0 AND value = ?",
[$attributeId, $sourceProductId],
);

// Two products claiming the same source id makes the choice arbitrary.
// Fall through to sku rather than pick one.
return count($ids) === 1 ? (int) $ids[0] : null;
}

protected function _isValidPaymentMethod(string $method): bool
Expand Down Expand Up @@ -1446,7 +1507,11 @@ protected function _importInvoiceItems(
}

$invoiceSku = $itemData['sku'] ?? '';
$item->setProductId($this->_resolveLocalProductId($invoiceSku, $itemData['product_id'] ?? ($orderItem ? $orderItem->getProductId() : null)));
// The order item's product_id is already translated to this catalog.
// Never feed it back in as a source id.
$item->setProductId($orderItem
? (int) $orderItem->getProductId()
: $this->_resolveLocalProductId($invoiceSku, $itemData['product_id'] ?? null));
$item->setSku($invoiceSku);
$item->setName($itemData['name'] ?? ($orderItem ? $orderItem->getName() : ''));
$item->setQty((float) ($itemData['qty'] ?? 1));
Expand Down Expand Up @@ -1634,7 +1699,10 @@ protected function _importShipmentItems(
}

$shipSku = $itemData['sku'] ?? '';
$item->setProductId($this->_resolveLocalProductId($shipSku, $itemData['product_id'] ?? ($orderItem ? $orderItem->getProductId() : null)));
// As above: prefer the order item's already-translated product_id.
$item->setProductId($orderItem
? (int) $orderItem->getProductId()
: $this->_resolveLocalProductId($shipSku, $itemData['product_id'] ?? null));
$item->setSku($shipSku);
$item->setName($itemData['name'] ?? ($orderItem ? $orderItem->getName() : ''));
$item->setQty((float) ($itemData['qty'] ?? 1));
Expand Down
49 changes: 49 additions & 0 deletions app/code/core/Maho/DataSync/doc/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,3 +819,52 @@ php shell/indexer.php --reindex
```

**Note**: No emails are sent during import. Order confirmations, invoice emails, and shipment notifications are automatically suppressed.

---

## Incremental Sync: Delete Handling

When the source system deletes an entity, the source tracker writes a row with
`action='delete'` and a portable `source_identifier` (SKU for products, email
for customers, url_key for categories). The destination's `datasync:incremental`
command mirrors that delete to keep both systems in sync.

### Behaviour

| `datasync/delete_handling/...` | Default | Effect |
|---|---|---|
| `enabled` | `1` | Master switch. Set to `0` to ignore source deletes (legacy behaviour pre-1.2.0). |
| `mode` | `hard` | `hard` calls `Mage_*Model::delete()` on the destination — removes the row, EAV values, relations, URL rewrites, indexer entries. `soft` flips status/visibility/is_active to a disabled state but leaves the row in place. |
| `soft_for_customer` | `1` | Customers carry order history. When `1`, customer deletes are always soft regardless of `mode`. |
| `skip_entity_types` | `` | Comma-separated list of entity types to leave untouched, e.g. `customer,order`. |

### Setting from CLI

```bash
# Hard-delete everything (default — what you want when dev mirrors live)
./maho config:set datasync/delete_handling/enabled 1
./maho config:set datasync/delete_handling/mode hard

# Conservative — disable instead of delete
./maho config:set datasync/delete_handling/mode soft

# Revert to pre-1.2.0 behaviour (ignore source deletes entirely)
./maho config:set datasync/delete_handling/enabled 0

# Mirror everything except customers
./maho config:set datasync/delete_handling/skip_entity_types customer
```

### Why portable identifiers matter

Source-side entity_ids drift from destination-side entity_ids the moment DataSync
imports anything, and after a source row is deleted the entity_id alone is
useless for lookup (the row is gone, so the destination can't query live for
the SKU/email by id). The 1.2.0 schema added `source_identifier` to capture
that handle at delete time so the destination can resolve the matching row by
SKU/email/url_key.

Pre-1.2.0 tracker rows without `source_identifier` are best-effort: the
destination tries to read the live source row by entity_id; if it's already
gone, it logs and skips. Run a one-shot reconciliation sweep to catch up any
backlog after the upgrade.
37 changes: 37 additions & 0 deletions app/code/core/Maho/DataSync/etc/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,43 @@
<schedule>0 */4 * * *</schedule> <!-- Every 4 hours -->
<entity_types>customer,order</entity_types>
</cron>
<!--
Delete handling — controls what the destination does when a
tracker row arrives with action='delete'.

enabled:
0 = legacy behaviour, silently mark tracker row synced
and leave the destination entity in place. Use this
only during a migration cutover when you want the
destination to ignore source deletes.
1 = act on the delete (default).

mode (when enabled=1):
hard = call Mage_*Model::delete() on the destination
entity. Removes the row + EAV + relations + URL
rewrites + indexer entries. Mirrors source.
soft = leave the row in place but mark it removed from
customer view. For products/categories that means
status=disabled, visibility=Not Visible Individually,
is_active=0. For customers it means is_active=0.
Safer when downstream systems hold references.

soft_for_customer (regardless of mode):
Customers carry order/refund history that breaks if the
customer row is gone. Default-on so customer deletes are
always disabled-not-removed unless explicitly overridden.

Skip configuration:
Hard delete is final. Disable per-entity-type by
listing them in skip_entity_types (comma-separated).
Example: skip_entity_types=customer,order
-->
<delete_handling>
<enabled>1</enabled>
<mode>hard</mode>
<soft_for_customer>1</soft_for_customer>
<skip_entity_types></skip_entity_types>
</delete_handling>
</datasync>
</default>
</config>
Loading
Loading