@porulle/plugin-channel-connector 0.69.0 → 0.70.1
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/service.d.ts +9 -0
- package/dist/service.js +94 -6
- package/package.json +2 -2
- package/src/service.ts +94 -5
package/dist/service.d.ts
CHANGED
|
@@ -664,6 +664,15 @@ export declare class ChannelConnectorService {
|
|
|
664
664
|
* goods already on their way. A store with no connector able to cancel is left to its merchant.
|
|
665
665
|
*/
|
|
666
666
|
cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>>;
|
|
667
|
+
/**
|
|
668
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
669
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
670
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
671
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
672
|
+
*/
|
|
673
|
+
private recordChannelFulfillments;
|
|
674
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
675
|
+
private unfulfilledLines;
|
|
667
676
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
668
677
|
isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
|
|
669
678
|
exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
|
package/dist/service.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
|
5
5
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
6
6
|
import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
7
7
|
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
8
|
-
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
8
|
+
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
9
9
|
import { planAbsentArchives } from "./deletion-policy.js";
|
|
10
10
|
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
11
11
|
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
@@ -34,6 +34,15 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
|
34
34
|
* cancelling at the store for exactly this reason, so the two directions cannot loop.
|
|
35
35
|
*/
|
|
36
36
|
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
37
|
+
/** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
|
|
38
|
+
const channelFulfillmentsSchema = z.array(z.object({
|
|
39
|
+
id: z.union([z.string(), z.number()]),
|
|
40
|
+
status: z.string().nullish(),
|
|
41
|
+
tracking_company: z.string().nullish(),
|
|
42
|
+
tracking_number: z.string().nullish(),
|
|
43
|
+
tracking_url: z.string().nullish(),
|
|
44
|
+
line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
|
|
45
|
+
}));
|
|
37
46
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
38
47
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
39
48
|
export function catalogPushRetryDelayMs(attempts) {
|
|
@@ -2274,7 +2283,9 @@ export class ChannelConnectorService {
|
|
|
2274
2283
|
"products/delete",
|
|
2275
2284
|
"inventory_levels/update",
|
|
2276
2285
|
"orders/fulfilled",
|
|
2286
|
+
"orders/partially_fulfilled",
|
|
2277
2287
|
"orders/cancelled",
|
|
2288
|
+
"refunds/create",
|
|
2278
2289
|
"app/uninstalled",
|
|
2279
2290
|
], callbackUrl);
|
|
2280
2291
|
if (!registration.ok) {
|
|
@@ -3880,7 +3891,7 @@ export class ChannelConnectorService {
|
|
|
3880
3891
|
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
3881
3892
|
}
|
|
3882
3893
|
}
|
|
3883
|
-
else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
|
|
3894
|
+
else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
|
|
3884
3895
|
const orderId = await this.resolveOrderId(orgId, storeId, data);
|
|
3885
3896
|
if (orderId) {
|
|
3886
3897
|
const ordersService = this.services.orders;
|
|
@@ -3896,13 +3907,19 @@ export class ChannelConnectorService {
|
|
|
3896
3907
|
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
3897
3908
|
}
|
|
3898
3909
|
}
|
|
3899
|
-
if (event.type === "orders/fulfilled") {
|
|
3910
|
+
if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
|
|
3911
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
3912
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
|
|
3913
|
+
if (!recorded.ok)
|
|
3914
|
+
return recorded;
|
|
3915
|
+
const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
|
|
3900
3916
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3901
3917
|
if (order?.status === "confirmed")
|
|
3902
3918
|
await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
3903
3919
|
const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3904
|
-
if (after?.status === "processing")
|
|
3905
|
-
await ordersService.changeStatus({ orderId, newStatus:
|
|
3920
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
3921
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
3922
|
+
}
|
|
3906
3923
|
}
|
|
3907
3924
|
}
|
|
3908
3925
|
}
|
|
@@ -4170,7 +4187,15 @@ export class ChannelConnectorService {
|
|
|
4170
4187
|
const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
|
|
4171
4188
|
if (existing[0])
|
|
4172
4189
|
return Ok(existing[0]);
|
|
4173
|
-
|
|
4190
|
+
// Shopify names the refunded lines in `refund_line_items`, each with its order line under
|
|
4191
|
+
// `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
|
|
4192
|
+
const lineData = Array.isArray(data.refund_line_items)
|
|
4193
|
+
? data.refund_line_items.map((raw) => {
|
|
4194
|
+
const entry = (raw ?? {});
|
|
4195
|
+
const orderLine = (entry.line_item ?? {});
|
|
4196
|
+
return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
|
|
4197
|
+
})
|
|
4198
|
+
: Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
|
|
4174
4199
|
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4175
4200
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
|
|
4176
4201
|
const refundLines = [];
|
|
@@ -4325,6 +4350,69 @@ export class ChannelConnectorService {
|
|
|
4325
4350
|
}
|
|
4326
4351
|
return Ok(cancelled);
|
|
4327
4352
|
}
|
|
4353
|
+
/**
|
|
4354
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
4355
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
4356
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
4357
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
4358
|
+
*/
|
|
4359
|
+
async recordChannelFulfillments(orgId, storeId, orderId, data, actor) {
|
|
4360
|
+
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
4361
|
+
if (!parsed.success)
|
|
4362
|
+
return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
4363
|
+
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
4364
|
+
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
4365
|
+
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4366
|
+
const fulfillment = this.services.fulfillment;
|
|
4367
|
+
let created = 0;
|
|
4368
|
+
for (const parcel of parsed.data) {
|
|
4369
|
+
const parcelId = String(parcel.id);
|
|
4370
|
+
if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure")
|
|
4371
|
+
continue;
|
|
4372
|
+
const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
|
|
4373
|
+
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
4374
|
+
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
4375
|
+
.from(channelEntityMap)
|
|
4376
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
4377
|
+
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
4378
|
+
const matched = parcel.line_items.flatMap((line) => {
|
|
4379
|
+
const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
|
|
4380
|
+
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
4381
|
+
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
4382
|
+
});
|
|
4383
|
+
const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
|
|
4384
|
+
if (lineItems.length === 0)
|
|
4385
|
+
continue;
|
|
4386
|
+
const result = await fulfillment.createFulfillment({
|
|
4387
|
+
orderId,
|
|
4388
|
+
lineItems,
|
|
4389
|
+
...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
|
|
4390
|
+
...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
|
|
4391
|
+
...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
|
|
4392
|
+
status: "shipped",
|
|
4393
|
+
metadata: { channelFulfillmentId: parcelId, storeId },
|
|
4394
|
+
}, actor);
|
|
4395
|
+
if (!result.ok)
|
|
4396
|
+
return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
4397
|
+
recorded.add(parcelId);
|
|
4398
|
+
created += 1;
|
|
4399
|
+
}
|
|
4400
|
+
return Ok(created);
|
|
4401
|
+
}
|
|
4402
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
4403
|
+
async unfulfilledLines(lines) {
|
|
4404
|
+
const ids = lines.map((line) => line.id);
|
|
4405
|
+
const shipped = ids.length === 0 ? [] : await this.db
|
|
4406
|
+
.select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql `coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
|
|
4407
|
+
.from(fulfillmentLineItems)
|
|
4408
|
+
.where(inArray(fulfillmentLineItems.orderLineItemId, ids))
|
|
4409
|
+
.groupBy(fulfillmentLineItems.orderLineItemId);
|
|
4410
|
+
const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
|
|
4411
|
+
return lines.flatMap((line) => {
|
|
4412
|
+
const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
|
|
4413
|
+
return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
|
|
4414
|
+
});
|
|
4415
|
+
}
|
|
4328
4416
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
4329
4417
|
async isOrderClosed(orgId, orderId) {
|
|
4330
4418
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@hono/zod-openapi": "^1.2.2",
|
|
24
24
|
"hono": "^4.12.5",
|
|
25
|
-
"@porulle/core": "0.
|
|
25
|
+
"@porulle/core": "0.70.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|
package/src/service.ts
CHANGED
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
mediaAssets,
|
|
57
57
|
optionTypes,
|
|
58
58
|
optionValues,
|
|
59
|
+
fulfillmentLineItems,
|
|
60
|
+
fulfillmentRecords,
|
|
59
61
|
orderLineItems,
|
|
60
62
|
orders,
|
|
61
63
|
prices,
|
|
@@ -130,6 +132,16 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
|
130
132
|
*/
|
|
131
133
|
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
132
134
|
|
|
135
|
+
/** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
|
|
136
|
+
const channelFulfillmentsSchema = z.array(z.object({
|
|
137
|
+
id: z.union([z.string(), z.number()]),
|
|
138
|
+
status: z.string().nullish(),
|
|
139
|
+
tracking_company: z.string().nullish(),
|
|
140
|
+
tracking_number: z.string().nullish(),
|
|
141
|
+
tracking_url: z.string().nullish(),
|
|
142
|
+
line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
|
|
143
|
+
}));
|
|
144
|
+
|
|
133
145
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
134
146
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
135
147
|
|
|
@@ -3189,7 +3201,9 @@ export class ChannelConnectorService {
|
|
|
3189
3201
|
"products/delete",
|
|
3190
3202
|
"inventory_levels/update",
|
|
3191
3203
|
"orders/fulfilled",
|
|
3204
|
+
"orders/partially_fulfilled",
|
|
3192
3205
|
"orders/cancelled",
|
|
3206
|
+
"refunds/create",
|
|
3193
3207
|
"app/uninstalled",
|
|
3194
3208
|
], callbackUrl);
|
|
3195
3209
|
if (!registration.ok) {
|
|
@@ -4985,10 +4999,10 @@ export class ChannelConnectorService {
|
|
|
4985
4999
|
const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
|
|
4986
5000
|
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
4987
5001
|
}
|
|
4988
|
-
} else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
|
|
5002
|
+
} else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
|
|
4989
5003
|
const orderId = await this.resolveOrderId(orgId, storeId, data);
|
|
4990
5004
|
if (orderId) {
|
|
4991
|
-
const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
|
|
5005
|
+
const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled" | "partially_fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
|
|
4992
5006
|
const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
|
|
4993
5007
|
if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
4994
5008
|
if (event.type === "orders/cancelled") {
|
|
@@ -5000,11 +5014,17 @@ export class ChannelConnectorService {
|
|
|
5000
5014
|
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
5001
5015
|
}
|
|
5002
5016
|
}
|
|
5003
|
-
if (event.type === "orders/fulfilled") {
|
|
5017
|
+
if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
|
|
5018
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
5019
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
|
|
5020
|
+
if (!recorded.ok) return recorded;
|
|
5021
|
+
const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
|
|
5004
5022
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5005
5023
|
if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
5006
5024
|
const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5007
|
-
if (after?.status === "processing"
|
|
5025
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
5026
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
5027
|
+
}
|
|
5008
5028
|
}
|
|
5009
5029
|
}
|
|
5010
5030
|
} else if (event.type === "refunds/create") {
|
|
@@ -5294,7 +5314,15 @@ export class ChannelConnectorService {
|
|
|
5294
5314
|
if (!remoteRefundId || !orderId) return PluginErr("Refund webhook is missing a mapped order or refund id.", "REFUND_MAPPING_MISSING");
|
|
5295
5315
|
const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
|
|
5296
5316
|
if (existing[0]) return Ok(existing[0] as ChannelRefundRequest);
|
|
5297
|
-
|
|
5317
|
+
// Shopify names the refunded lines in `refund_line_items`, each with its order line under
|
|
5318
|
+
// `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
|
|
5319
|
+
const lineData = Array.isArray(data.refund_line_items)
|
|
5320
|
+
? data.refund_line_items.map((raw) => {
|
|
5321
|
+
const entry = (raw ?? {}) as Record<string, unknown>;
|
|
5322
|
+
const orderLine = (entry.line_item ?? {}) as Record<string, unknown>;
|
|
5323
|
+
return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
|
|
5324
|
+
})
|
|
5325
|
+
: Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
|
|
5298
5326
|
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5299
5327
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
|
|
5300
5328
|
const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
|
|
@@ -5470,6 +5498,67 @@ export class ChannelConnectorService {
|
|
|
5470
5498
|
return Ok(cancelled);
|
|
5471
5499
|
}
|
|
5472
5500
|
|
|
5501
|
+
/**
|
|
5502
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
5503
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
5504
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
5505
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
5506
|
+
*/
|
|
5507
|
+
private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<number>> {
|
|
5508
|
+
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
5509
|
+
if (!parsed.success) return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
5510
|
+
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
5511
|
+
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
5512
|
+
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5513
|
+
const fulfillment = this.services.fulfillment as { createFulfillment(input: { orderId: string; lineItems: Array<{ orderLineItemId: string; quantity: number }>; carrier?: string; trackingNumber?: string; trackingUrl?: string; status?: string; metadata?: Record<string, unknown> }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
|
|
5514
|
+
let created = 0;
|
|
5515
|
+
for (const parcel of parsed.data) {
|
|
5516
|
+
const parcelId = String(parcel.id);
|
|
5517
|
+
if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure") continue;
|
|
5518
|
+
const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
|
|
5519
|
+
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
5520
|
+
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
5521
|
+
.from(channelEntityMap)
|
|
5522
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
5523
|
+
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
5524
|
+
const matched = parcel.line_items.flatMap((line) => {
|
|
5525
|
+
const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
|
|
5526
|
+
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
5527
|
+
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
5528
|
+
});
|
|
5529
|
+
const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
|
|
5530
|
+
if (lineItems.length === 0) continue;
|
|
5531
|
+
const result = await fulfillment.createFulfillment({
|
|
5532
|
+
orderId,
|
|
5533
|
+
lineItems,
|
|
5534
|
+
...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
|
|
5535
|
+
...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
|
|
5536
|
+
...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
|
|
5537
|
+
status: "shipped",
|
|
5538
|
+
metadata: { channelFulfillmentId: parcelId, storeId },
|
|
5539
|
+
}, actor);
|
|
5540
|
+
if (!result.ok) return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
5541
|
+
recorded.add(parcelId);
|
|
5542
|
+
created += 1;
|
|
5543
|
+
}
|
|
5544
|
+
return Ok(created);
|
|
5545
|
+
}
|
|
5546
|
+
|
|
5547
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
5548
|
+
private async unfulfilledLines(lines: Array<{ id: string; quantity: number }>): Promise<Array<{ orderLineItemId: string; quantity: number }>> {
|
|
5549
|
+
const ids = lines.map((line) => line.id);
|
|
5550
|
+
const shipped = ids.length === 0 ? [] : await this.db
|
|
5551
|
+
.select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql<number>`coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
|
|
5552
|
+
.from(fulfillmentLineItems)
|
|
5553
|
+
.where(inArray(fulfillmentLineItems.orderLineItemId, ids))
|
|
5554
|
+
.groupBy(fulfillmentLineItems.orderLineItemId);
|
|
5555
|
+
const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
|
|
5556
|
+
return lines.flatMap((line) => {
|
|
5557
|
+
const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
|
|
5558
|
+
return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
|
|
5559
|
+
});
|
|
5560
|
+
}
|
|
5561
|
+
|
|
5473
5562
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
5474
5563
|
async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
|
|
5475
5564
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|