@porulle/plugin-channel-connector 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -63
- package/dist/service.d.ts +14 -1
- package/dist/service.js +187 -15
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +0 -67
- package/src/service.ts +213 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@hono/zod-openapi": "^1.2.2",
|
|
21
21
|
"hono": "^4.12.5",
|
|
22
|
-
"@porulle/core": "0.
|
|
22
|
+
"@porulle/core": "0.53.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.5.2",
|
|
26
26
|
"eslint": "^9.39.1",
|
|
27
27
|
"typescript": "5.9.2",
|
|
28
28
|
"vitest": "^3.2.4",
|
|
29
|
-
"@porulle/
|
|
30
|
-
"@porulle/
|
|
29
|
+
"@porulle/typescript-config": "0.1.0",
|
|
30
|
+
"@porulle/eslint-config": "0.1.0"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -92,8 +92,6 @@ export type {
|
|
|
92
92
|
CatalogFieldMappingRow,
|
|
93
93
|
CatalogFieldTarget,
|
|
94
94
|
} from "./catalog-field-mapping.js";
|
|
95
|
-
/** ~320 Neon HTTP subrequests per product against a 10,000 per-invocation cap → hard ceiling near 31; 20 leaves margin for heavier products. */
|
|
96
|
-
export const CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION = 20;
|
|
97
95
|
|
|
98
96
|
/**
|
|
99
97
|
* How many bounded batches one sweep may walk before it refuses rather than loops.
|
|
@@ -326,71 +324,6 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
326
324
|
return { output: { enqueued: stores.length } };
|
|
327
325
|
},
|
|
328
326
|
},
|
|
329
|
-
{
|
|
330
|
-
slug: "channel/import-catalog",
|
|
331
|
-
concurrency: { key: (input: Record<string, unknown>) => String(input.storeId), supersedes: true },
|
|
332
|
-
durableSteps: true,
|
|
333
|
-
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
334
|
-
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
335
|
-
const orgId = String(input.orgId);
|
|
336
|
-
const storeId = String(input.storeId);
|
|
337
|
-
const result = await walkBatches(
|
|
338
|
-
ctx,
|
|
339
|
-
"import-catalog",
|
|
340
|
-
storeId,
|
|
341
|
-
async () => {
|
|
342
|
-
const page = await service.importCatalog(orgId, storeId, createSystemActor(orgId), {
|
|
343
|
-
maxItems: CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION,
|
|
344
|
-
});
|
|
345
|
-
if (!page.ok) throw new Error(page.error);
|
|
346
|
-
return {
|
|
347
|
-
exhausted: page.value.exhausted,
|
|
348
|
-
counted: page.value.imported,
|
|
349
|
-
cursor: page.value.cursor ?? null,
|
|
350
|
-
...(page.value.warnings ? { warnings: page.value.warnings } : {}),
|
|
351
|
-
// UNCONDITIONAL, unlike `warnings` and `failures` beside it. The step's return value is
|
|
352
|
-
// the only per-batch seam a host application has — `walkBatches` keeps just `last` — so
|
|
353
|
-
// this is where a page gets its identity. Omitting it when empty would make "this batch
|
|
354
|
-
// committed nothing" and "this plugin build does not report entities" the same
|
|
355
|
-
// `undefined` at the seam, and a caller that collapses those enqueues nothing and
|
|
356
|
-
// reports success. The service's own return declares it unconditional for the same
|
|
357
|
-
// reason; dropping it here would have undone that one line later.
|
|
358
|
-
entityIds: page.value.entityIds,
|
|
359
|
-
...(page.value.failures ? { failures: page.value.failures } : {}),
|
|
360
|
-
};
|
|
361
|
-
},
|
|
362
|
-
);
|
|
363
|
-
const jobs = ctx.services.jobs as JobsAdapter;
|
|
364
|
-
// The catalog is always exhausted by the time the walk above returns, so this hand-off is
|
|
365
|
-
// unconditional. It is the ONE enqueue this task is allowed: it starts a DIFFERENT task
|
|
366
|
-
// once, spending a single level of the request chain's 32, rather than one per page the
|
|
367
|
-
// way the continuation it replaced did.
|
|
368
|
-
//
|
|
369
|
-
// A finished catalog is not a usable one. `importCatalog` writes entities, variants and
|
|
370
|
-
// prices and never touches `inventory_levels`, so a store whose sweep ends here has a
|
|
371
|
-
// catalog in which every variant rolls up as out of stock — which is what a consumer
|
|
372
|
-
// projection publishes and what a shopper is shown.
|
|
373
|
-
//
|
|
374
|
-
// Inventory used to arrive from `reconcile`, reachable only through the
|
|
375
|
-
// `channel/reconcile-sweep` cron. A deployment that removes its crons therefore loses a
|
|
376
|
-
// data-plane write silently, with every suite still green. Levelling inventory here keeps
|
|
377
|
-
// it inside the one operator action — "import this store" — instead of behind a second
|
|
378
|
-
// one somebody has to remember.
|
|
379
|
-
await jobs.enqueue("channel/sync-inventory", { orgId, storeId }, {
|
|
380
|
-
organizationId: orgId,
|
|
381
|
-
concurrencyKey: storeId,
|
|
382
|
-
});
|
|
383
|
-
return {
|
|
384
|
-
output: {
|
|
385
|
-
imported: result.counted,
|
|
386
|
-
cursor: result.last.cursor ?? null,
|
|
387
|
-
exhausted: true,
|
|
388
|
-
batches: result.batches,
|
|
389
|
-
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
390
|
-
},
|
|
391
|
-
};
|
|
392
|
-
},
|
|
393
|
-
},
|
|
394
327
|
{
|
|
395
328
|
slug: "channel/backfill-catalog",
|
|
396
329
|
concurrency: { key: (input: Record<string, unknown>) => String(input.storeId) },
|
package/src/service.ts
CHANGED
|
@@ -668,6 +668,34 @@ export function isSlugConflict(error: unknown): boolean {
|
|
|
668
668
|
return false;
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
+
/** Postgres 23505 anywhere in a driver error's `cause` chain. */
|
|
672
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
673
|
+
let cursor: unknown = error;
|
|
674
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
675
|
+
if (typeof cursor !== "object" || cursor === null) return false;
|
|
676
|
+
if ("code" in cursor && cursor.code === "23505") return true;
|
|
677
|
+
cursor = "cause" in cursor ? cursor.cause : undefined;
|
|
678
|
+
}
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** An upstream variant SKU the store could not take because another of its variants holds it. */
|
|
683
|
+
interface VariantSkuClash {
|
|
684
|
+
variantExternalId: string;
|
|
685
|
+
fromSku: string | null;
|
|
686
|
+
toSku: string;
|
|
687
|
+
heldByVariantId: string | null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
interface VariantIdentityOutcome {
|
|
691
|
+
/** Items (by externalId) at least one of whose variants had its sku or barcode written. */
|
|
692
|
+
written: Set<string>;
|
|
693
|
+
/** Items (by externalId) with an upstream SKU that could not be taken, per variant. */
|
|
694
|
+
clashes: Map<string, VariantSkuClash[]>;
|
|
695
|
+
/** Entities that had an OPEN `variants.sku` conflict before this batch. */
|
|
696
|
+
openSkuConflicts: Set<string>;
|
|
697
|
+
}
|
|
698
|
+
|
|
671
699
|
function errorMessage(error: unknown): string {
|
|
672
700
|
if (typeof error === "string") return error;
|
|
673
701
|
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
|
|
@@ -1406,10 +1434,161 @@ export class ChannelConnectorService {
|
|
|
1406
1434
|
return { paths: changed, conflicts };
|
|
1407
1435
|
}
|
|
1408
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* Apply upstream SKU and barcode changes to variants this store already maps, for a whole batch.
|
|
1439
|
+
*
|
|
1440
|
+
* SKU is unique per source store, so variants EXCHANGING SKUs cannot be updated one at a time.
|
|
1441
|
+
* One transaction first releases every SKU that is changing (NULL is outside the unique index),
|
|
1442
|
+
* then sets the new values, so any swap inside the batch lands atomically. If that fails on a
|
|
1443
|
+
* unique violation — a new SKU is held by a variant whose upstream did not change, or by a
|
|
1444
|
+
* variant in a batch not converged yet — each entity is retried alone, then each variant alone,
|
|
1445
|
+
* and the SKU that still cannot be taken is returned as a clash. A clash is the caller's to
|
|
1446
|
+
* record as a conflict; it never fails the item, which still converges every other field.
|
|
1447
|
+
*/
|
|
1448
|
+
private async applyUpstreamVariantIdentity(
|
|
1449
|
+
orgId: string,
|
|
1450
|
+
storeId: string,
|
|
1451
|
+
items: readonly ChannelCatalogItem[],
|
|
1452
|
+
): Promise<VariantIdentityOutcome> {
|
|
1453
|
+
const outcome: VariantIdentityOutcome = { written: new Set(), clashes: new Map(), openSkuConflicts: new Set() };
|
|
1454
|
+
const externalIds = [...new Set(items.flatMap((item) => item.variants.map((variant) => variant.externalId)))];
|
|
1455
|
+
if (externalIds.length === 0) return outcome;
|
|
1456
|
+
const mapped = await this.db.select({
|
|
1457
|
+
externalId: channelEntityMap.externalId,
|
|
1458
|
+
variantId: variants.id,
|
|
1459
|
+
entityId: variants.entityId,
|
|
1460
|
+
sku: variants.sku,
|
|
1461
|
+
barcode: variants.barcode,
|
|
1462
|
+
}).from(channelEntityMap)
|
|
1463
|
+
.innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
|
|
1464
|
+
.where(and(
|
|
1465
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1466
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1467
|
+
eq(channelEntityMap.kind, "variant"),
|
|
1468
|
+
inArray(channelEntityMap.externalId, externalIds),
|
|
1469
|
+
));
|
|
1470
|
+
if (mapped.length === 0) return outcome;
|
|
1471
|
+
const byExternalId = new Map(mapped.map((row) => [row.externalId, row]));
|
|
1472
|
+
const entityIds = [...new Set(mapped.map((row) => row.entityId))];
|
|
1473
|
+
const openRows = await this.db.select({ entityId: channelCatalogConflicts.entityId }).from(channelCatalogConflicts).where(and(
|
|
1474
|
+
eq(channelCatalogConflicts.storeId, storeId),
|
|
1475
|
+
eq(channelCatalogConflicts.fieldPath, "variants.sku"),
|
|
1476
|
+
eq(channelCatalogConflicts.state, "open"),
|
|
1477
|
+
inArray(channelCatalogConflicts.entityId, entityIds),
|
|
1478
|
+
));
|
|
1479
|
+
for (const row of openRows) outcome.openSkuConflicts.add(row.entityId);
|
|
1480
|
+
|
|
1481
|
+
type Write = { itemExternalId: string; entityId: string; variantId: string; variantExternalId: string; fromSku: string | null; toSku?: string; toBarcode?: string };
|
|
1482
|
+
const candidates: Write[] = [];
|
|
1483
|
+
for (const item of items) {
|
|
1484
|
+
for (const variant of item.variants) {
|
|
1485
|
+
const row = byExternalId.get(variant.externalId);
|
|
1486
|
+
if (!row) continue;
|
|
1487
|
+
const toSku = variant.sku !== undefined && variant.sku !== row.sku ? variant.sku : undefined;
|
|
1488
|
+
const toBarcode = variant.barcode !== undefined && variant.barcode !== row.barcode ? variant.barcode : undefined;
|
|
1489
|
+
if (toSku === undefined && toBarcode === undefined) continue;
|
|
1490
|
+
candidates.push({
|
|
1491
|
+
itemExternalId: item.externalId, entityId: row.entityId, variantId: row.variantId, variantExternalId: variant.externalId, fromSku: row.sku,
|
|
1492
|
+
...(toSku !== undefined ? { toSku } : {}), ...(toBarcode !== undefined ? { toBarcode } : {}),
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (candidates.length === 0) return outcome;
|
|
1497
|
+
|
|
1498
|
+
// Ownership, per entity that has a change: a platform-owned or held path stays local.
|
|
1499
|
+
const candidateEntities = [...new Set(candidates.map((write) => write.entityId))];
|
|
1500
|
+
const held = new Map((await this.db.select({ entityId: channelEntityMap.entityId, held: channelEntityMap.heldFieldPaths }).from(channelEntityMap).where(and(
|
|
1501
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1502
|
+
eq(channelEntityMap.kind, "entity"),
|
|
1503
|
+
inArray(channelEntityMap.entityId, candidateEntities),
|
|
1504
|
+
))).map((row) => [row.entityId, new Set(row.held ?? [])]));
|
|
1505
|
+
const allowed = new Map<string, { sku: boolean; barcode: boolean }>();
|
|
1506
|
+
for (const entityId of candidateEntities) {
|
|
1507
|
+
const owners = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
1508
|
+
const entityHeld = held.get(entityId) ?? new Set<string>();
|
|
1509
|
+
allowed.set(entityId, {
|
|
1510
|
+
sku: ownerAllows(owners, "variants.sku") && !entityHeld.has("variants.sku"),
|
|
1511
|
+
barcode: ownerAllows(owners, "variants.barcode") && !entityHeld.has("variants.barcode"),
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
const writes: Write[] = candidates.flatMap((write) => {
|
|
1515
|
+
const permitted = allowed.get(write.entityId) ?? { sku: false, barcode: false };
|
|
1516
|
+
const next: Write = {
|
|
1517
|
+
itemExternalId: write.itemExternalId, entityId: write.entityId, variantId: write.variantId, variantExternalId: write.variantExternalId, fromSku: write.fromSku,
|
|
1518
|
+
...(permitted.sku && write.toSku !== undefined ? { toSku: write.toSku } : {}),
|
|
1519
|
+
...(permitted.barcode && write.toBarcode !== undefined ? { toBarcode: write.toBarcode } : {}),
|
|
1520
|
+
};
|
|
1521
|
+
return next.toSku === undefined && next.toBarcode === undefined ? [] : [next];
|
|
1522
|
+
});
|
|
1523
|
+
if (writes.length === 0) return outcome;
|
|
1524
|
+
|
|
1525
|
+
const apply = (group: readonly Write[]) => this.transact(async (tx) => {
|
|
1526
|
+
const releasing = group.filter((write) => write.toSku !== undefined).map((write) => write.variantId);
|
|
1527
|
+
if (releasing.length > 0) await tx.update(variants).set({ sku: null }).where(inArray(variants.id, releasing));
|
|
1528
|
+
for (const write of group) {
|
|
1529
|
+
await tx.update(variants).set({
|
|
1530
|
+
...(write.toSku !== undefined ? { sku: write.toSku } : {}),
|
|
1531
|
+
...(write.toBarcode !== undefined ? { barcode: write.toBarcode } : {}),
|
|
1532
|
+
updatedAt: sql`now()`,
|
|
1533
|
+
}).where(eq(variants.id, write.variantId));
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
const landed = (group: readonly Write[]) => { for (const write of group) outcome.written.add(write.itemExternalId); };
|
|
1537
|
+
const attempt = async (group: readonly Write[]): Promise<boolean> => {
|
|
1538
|
+
try {
|
|
1539
|
+
await apply(group);
|
|
1540
|
+
landed(group);
|
|
1541
|
+
return true;
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
if (!isUniqueViolation(error)) throw error;
|
|
1544
|
+
return false;
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
if (await attempt(writes)) return outcome;
|
|
1549
|
+
for (const entityId of [...new Set(writes.map((write) => write.entityId))]) {
|
|
1550
|
+
const group = writes.filter((write) => write.entityId === entityId);
|
|
1551
|
+
if (await attempt(group)) continue;
|
|
1552
|
+
for (const write of group) {
|
|
1553
|
+
if (await attempt([write])) continue;
|
|
1554
|
+
// The SKU cannot be taken this batch. The barcode, which nothing else can hold, still lands.
|
|
1555
|
+
if (write.toBarcode !== undefined) {
|
|
1556
|
+
await attempt([{ itemExternalId: write.itemExternalId, entityId: write.entityId, variantId: write.variantId, variantExternalId: write.variantExternalId, fromSku: write.fromSku, toBarcode: write.toBarcode }]);
|
|
1557
|
+
}
|
|
1558
|
+
if (write.toSku === undefined) continue;
|
|
1559
|
+
const [holder] = await this.db.select({ id: variants.id }).from(variants).where(and(eq(variants.sourceStoreId, storeId), eq(variants.sku, write.toSku)));
|
|
1560
|
+
const clashes = outcome.clashes.get(write.itemExternalId) ?? [];
|
|
1561
|
+
clashes.push({ variantExternalId: write.variantExternalId, fromSku: write.fromSku, toSku: write.toSku, heldByVariantId: holder?.id ?? null });
|
|
1562
|
+
outcome.clashes.set(write.itemExternalId, clashes);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return outcome;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** Close this entity's open `variants.sku` conflict: the SKUs it was waiting on have now landed. */
|
|
1569
|
+
private async resolveSkuConflict(orgId: string, storeId: string, entityId: string, changedBy: string): Promise<void> {
|
|
1570
|
+
const resolved = await this.db.update(channelCatalogConflicts).set({ state: "resolved", resolvedBy: changedBy, updatedAt: new Date() }).where(and(
|
|
1571
|
+
eq(channelCatalogConflicts.storeId, storeId),
|
|
1572
|
+
eq(channelCatalogConflicts.entityId, entityId),
|
|
1573
|
+
eq(channelCatalogConflicts.fieldPath, "variants.sku"),
|
|
1574
|
+
eq(channelCatalogConflicts.state, "open"),
|
|
1575
|
+
)).returning({ id: channelCatalogConflicts.id });
|
|
1576
|
+
if (resolved.length === 0) return;
|
|
1577
|
+
await this.db.insert(channelCatalogConflictEvents).values(resolved.map((row) => ({
|
|
1578
|
+
organizationId: orgId,
|
|
1579
|
+
conflictId: row.id,
|
|
1580
|
+
fromState: "open",
|
|
1581
|
+
toState: "resolved",
|
|
1582
|
+
reason: "The upstream SKUs this product was waiting on have converged.",
|
|
1583
|
+
changedBy,
|
|
1584
|
+
})));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1409
1587
|
private async persistCatalogConflicts(
|
|
1410
1588
|
orgId: string,
|
|
1411
1589
|
conflicts: DetectedCatalogFieldConflict[],
|
|
1412
1590
|
changedBy: string,
|
|
1591
|
+
reason = "Shared catalog field changed on both sides.",
|
|
1413
1592
|
): Promise<PluginResult<void>> {
|
|
1414
1593
|
for (const conflict of conflicts) {
|
|
1415
1594
|
const [inserted] = await this.db.insert(channelCatalogConflicts).values({
|
|
@@ -1448,7 +1627,7 @@ export class ChannelConnectorService {
|
|
|
1448
1627
|
conflictId: inserted.id,
|
|
1449
1628
|
fromState: null,
|
|
1450
1629
|
toState: "open",
|
|
1451
|
-
reason
|
|
1630
|
+
reason,
|
|
1452
1631
|
changedBy,
|
|
1453
1632
|
});
|
|
1454
1633
|
}
|
|
@@ -2784,21 +2963,11 @@ export class ChannelConnectorService {
|
|
|
2784
2963
|
return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
|
|
2785
2964
|
}
|
|
2786
2965
|
}
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
await jobs.enqueue("channel/import-catalog", { orgId, storeId: (rows[0] as ConnectedStore).id }, {
|
|
2790
|
-
organizationId: orgId,
|
|
2791
|
-
concurrencyKey: (rows[0] as ConnectedStore).id,
|
|
2792
|
-
supersedes: true,
|
|
2793
|
-
});
|
|
2794
|
-
}
|
|
2966
|
+
// Connecting starts no import. The host's operator route starts one (and levels inventory after
|
|
2967
|
+
// it); connect used to enqueue a second, sequential walk that ran beside the host's own.
|
|
2795
2968
|
return Ok(redactStore(store));
|
|
2796
2969
|
}
|
|
2797
2970
|
|
|
2798
|
-
private get optionsJobs(): JobsAdapter | undefined {
|
|
2799
|
-
return this.jobs;
|
|
2800
|
-
}
|
|
2801
|
-
|
|
2802
2971
|
async disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
|
|
2803
2972
|
return this.disconnectStoreSystem(orgId, id);
|
|
2804
2973
|
}
|
|
@@ -3688,6 +3857,9 @@ export class ChannelConnectorService {
|
|
|
3688
3857
|
const committed = new Set<string>();
|
|
3689
3858
|
// One resolution for the whole batch, not two round trips per changed product.
|
|
3690
3859
|
const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
|
|
3860
|
+
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
3861
|
+
// variants exchanging SKUs across products in this batch land together.
|
|
3862
|
+
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
3691
3863
|
for (const item of items) {
|
|
3692
3864
|
consumed += 1;
|
|
3693
3865
|
try {
|
|
@@ -3798,7 +3970,11 @@ export class ChannelConnectorService {
|
|
|
3798
3970
|
// An unchanged remote item writes nothing and advances no baseline:
|
|
3799
3971
|
// converging a stale replay would revert local edits to shared and
|
|
3800
3972
|
// unowned fields that the store never actually changed.
|
|
3801
|
-
|
|
3973
|
+
// ...unless its SKUs moved this batch, clashed, or were waiting on an open conflict: a swap that
|
|
3974
|
+
// spanned two import pages leaves both map hashes current, and only this pass can close it.
|
|
3975
|
+
const identityPending = identity.written.has(item.externalId) || identity.clashes.has(item.externalId)
|
|
3976
|
+
|| identity.openSkuConflicts.has(entityId);
|
|
3977
|
+
if (!force && !remoteChanged && !identityPending && existingEntity && existingEntity.status !== "archived") {
|
|
3802
3978
|
continue;
|
|
3803
3979
|
}
|
|
3804
3980
|
const shared = existingEntity
|
|
@@ -3901,7 +4077,29 @@ export class ChannelConnectorService {
|
|
|
3901
4077
|
mediaImported += media.value.imported;
|
|
3902
4078
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
3903
4079
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
3904
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
|
|
4080
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
|
|
4081
|
+
|| identity.written.has(item.externalId);
|
|
4082
|
+
const skuClashes = identity.clashes.get(item.externalId);
|
|
4083
|
+
if (skuClashes) {
|
|
4084
|
+
// Loud, not fatal: the product converges everything else, its SKU keeps the old value, and
|
|
4085
|
+
// the next reconcile — which sees the whole catalogue in one batch — resolves a swap that
|
|
4086
|
+
// spanned two import pages and closes this conflict.
|
|
4087
|
+
const conflict: DetectedCatalogFieldConflict = {
|
|
4088
|
+
entityId,
|
|
4089
|
+
storeId,
|
|
4090
|
+
fieldPath: "variants.sku",
|
|
4091
|
+
localValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.fromSku ?? "(none)"}`).join(", "),
|
|
4092
|
+
remoteValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.toSku} (held by ${clash.heldByVariantId ?? "unknown"})`).join(", "),
|
|
4093
|
+
platformValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, { sku: clash.fromSku, heldByVariantId: clash.heldByVariantId }])),
|
|
4094
|
+
storeValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, clash.toSku])),
|
|
4095
|
+
};
|
|
4096
|
+
const recorded = await this.persistCatalogConflicts(orgId, [conflict], requireUserId(actor), "Upstream SKU is held by another variant of this store.");
|
|
4097
|
+
if (!recorded.ok) { failures.push({ externalId: item.externalId, error: recorded.error }); continue; }
|
|
4098
|
+
conflicts.push({ entityId, storeId, fieldPath: "variants.sku", localValueSummary: conflict.localValueSummary, remoteValueSummary: conflict.remoteValueSummary });
|
|
4099
|
+
warnings.push(`Kept the local SKU for entity "${entityId}": ${conflict.remoteValueSummary}.`);
|
|
4100
|
+
} else if (identity.openSkuConflicts.has(entityId)) {
|
|
4101
|
+
await this.resolveSkuConflict(orgId, storeId, entityId, requireUserId(actor));
|
|
4102
|
+
}
|
|
3905
4103
|
if (entityTouched) entitiesTouched += 1;
|
|
3906
4104
|
// Counted from what was written, not from the stored hash: a hash that moved while the
|
|
3907
4105
|
// product did not (a blanked map row, a change in how an item serialises) is not drift.
|