From 51790accf9469529a2f8e3e4d2168b4a1d8dfd18 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 22 Apr 2026 07:04:49 +0000 Subject: [PATCH] fix(sync): dedup order_items by sku before insert to avoid UNIQUE crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production sync was failing every minute with: UNIQUE constraint failed: order_items.order_number, order_items.sku GoMag occasionally returns the same SKU on multiple lines within one order (configurable products, promo splits). The order_items PK is (order_number, sku), so the raw batch insert violates UNIQUE and aborts the entire sync — blocking partner-mismatch updates, address refresh, and items repopulation for already-imported orders. Added _dedup_items_by_sku() helper. Applied in save_orders_batch (cancelled/already/skipped paths) and add_order_items (retry/sync import paths). Keeps first price/vat/name, sums quantities on collision. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/services/sqlite_service.py | 38 ++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 22337e0..cac5317 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -193,12 +193,16 @@ async def save_orders_batch(orders_data: list[dict]): VALUES (?, ?, ?) """, [(d["sync_run_id"], d["order_number"], d["status_at_run"]) for d in orders_data]) - # 3. Order items — replace semantics (GoMag source of truth) + # 3. Order items — replace semantics (GoMag source of truth). + # Dedup per-order by SKU (GoMag sometimes returns same SKU twice). all_items = [] order_numbers_with_items = set() for d in orders_data: - for item in d.get("items", []): - order_numbers_with_items.add(d["order_number"]) + raw_items = d.get("items", []) + if not raw_items: + continue + order_numbers_with_items.add(d["order_number"]) + for item in _dedup_items_by_sku(raw_items): all_items.append(( d["order_number"], item.get("sku"), item.get("product_name"), @@ -535,14 +539,40 @@ async def get_web_products_batch(skus: list) -> dict: # ── order_items ────────────────────────────────── +def _dedup_items_by_sku(items: list) -> list: + """Deduplicate items by SKU within a single order. Sums quantities on collision. + GoMag occasionally returns the same SKU on multiple lines (configurable products, + promo splits). The order_items primary key is (order_number, sku) so the raw rows + would violate UNIQUE. Keeps first price/vat/name; sums quantity + baseprice*qty. + """ + if not items: + return items + merged: dict = {} + order: list = [] + for item in items: + sku = item.get("sku") + if sku is None: + order.append(item) + continue + if sku in merged: + prev = merged[sku] + prev["quantity"] = (prev.get("quantity") or 0) + (item.get("quantity") or 0) + else: + merged[sku] = dict(item) + order.append(merged[sku]) + return order + + async def add_order_items(order_number: str, items: list): """Replace order items — delete any existing rows, then insert fresh batch. GoMag is source of truth: re-import must reflect quantity changes. - Atomic (DELETE + INSERT in one transaction). + Atomic (DELETE + INSERT in one transaction). Items with the same SKU are + merged (quantities summed) to satisfy the (order_number, sku) PK. """ if not items: return + items = _dedup_items_by_sku(items) db = await get_sqlite() try: await db.execute("DELETE FROM order_items WHERE order_number = ?", (order_number,))