@porulle/plugin-channel-connector 0.69.0 → 0.70.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/service.d.ts +9 -0
- package/dist/service.js +84 -5
- package/package.json +2 -2
- package/src/service.ts +84 -4
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,6 +2283,7 @@ export class ChannelConnectorService {
|
|
|
2274
2283
|
"products/delete",
|
|
2275
2284
|
"inventory_levels/update",
|
|
2276
2285
|
"orders/fulfilled",
|
|
2286
|
+
"orders/partially_fulfilled",
|
|
2277
2287
|
"orders/cancelled",
|
|
2278
2288
|
"app/uninstalled",
|
|
2279
2289
|
], callbackUrl);
|
|
@@ -3880,7 +3890,7 @@ export class ChannelConnectorService {
|
|
|
3880
3890
|
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
3881
3891
|
}
|
|
3882
3892
|
}
|
|
3883
|
-
else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
|
|
3893
|
+
else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
|
|
3884
3894
|
const orderId = await this.resolveOrderId(orgId, storeId, data);
|
|
3885
3895
|
if (orderId) {
|
|
3886
3896
|
const ordersService = this.services.orders;
|
|
@@ -3896,13 +3906,19 @@ export class ChannelConnectorService {
|
|
|
3896
3906
|
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
3897
3907
|
}
|
|
3898
3908
|
}
|
|
3899
|
-
if (event.type === "orders/fulfilled") {
|
|
3909
|
+
if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
|
|
3910
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
3911
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
|
|
3912
|
+
if (!recorded.ok)
|
|
3913
|
+
return recorded;
|
|
3914
|
+
const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
|
|
3900
3915
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3901
3916
|
if (order?.status === "confirmed")
|
|
3902
3917
|
await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
3903
3918
|
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:
|
|
3919
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
3920
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
3921
|
+
}
|
|
3906
3922
|
}
|
|
3907
3923
|
}
|
|
3908
3924
|
}
|
|
@@ -4325,6 +4341,69 @@ export class ChannelConnectorService {
|
|
|
4325
4341
|
}
|
|
4326
4342
|
return Ok(cancelled);
|
|
4327
4343
|
}
|
|
4344
|
+
/**
|
|
4345
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
4346
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
4347
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
4348
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
4349
|
+
*/
|
|
4350
|
+
async recordChannelFulfillments(orgId, storeId, orderId, data, actor) {
|
|
4351
|
+
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
4352
|
+
if (!parsed.success)
|
|
4353
|
+
return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
4354
|
+
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
4355
|
+
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
4356
|
+
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4357
|
+
const fulfillment = this.services.fulfillment;
|
|
4358
|
+
let created = 0;
|
|
4359
|
+
for (const parcel of parsed.data) {
|
|
4360
|
+
const parcelId = String(parcel.id);
|
|
4361
|
+
if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure")
|
|
4362
|
+
continue;
|
|
4363
|
+
const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
|
|
4364
|
+
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
4365
|
+
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
4366
|
+
.from(channelEntityMap)
|
|
4367
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
4368
|
+
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
4369
|
+
const matched = parcel.line_items.flatMap((line) => {
|
|
4370
|
+
const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
|
|
4371
|
+
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
4372
|
+
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
4373
|
+
});
|
|
4374
|
+
const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
|
|
4375
|
+
if (lineItems.length === 0)
|
|
4376
|
+
continue;
|
|
4377
|
+
const result = await fulfillment.createFulfillment({
|
|
4378
|
+
orderId,
|
|
4379
|
+
lineItems,
|
|
4380
|
+
...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
|
|
4381
|
+
...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
|
|
4382
|
+
...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
|
|
4383
|
+
status: "shipped",
|
|
4384
|
+
metadata: { channelFulfillmentId: parcelId, storeId },
|
|
4385
|
+
}, actor);
|
|
4386
|
+
if (!result.ok)
|
|
4387
|
+
return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
4388
|
+
recorded.add(parcelId);
|
|
4389
|
+
created += 1;
|
|
4390
|
+
}
|
|
4391
|
+
return Ok(created);
|
|
4392
|
+
}
|
|
4393
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
4394
|
+
async unfulfilledLines(lines) {
|
|
4395
|
+
const ids = lines.map((line) => line.id);
|
|
4396
|
+
const shipped = ids.length === 0 ? [] : await this.db
|
|
4397
|
+
.select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql `coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
|
|
4398
|
+
.from(fulfillmentLineItems)
|
|
4399
|
+
.where(inArray(fulfillmentLineItems.orderLineItemId, ids))
|
|
4400
|
+
.groupBy(fulfillmentLineItems.orderLineItemId);
|
|
4401
|
+
const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
|
|
4402
|
+
return lines.flatMap((line) => {
|
|
4403
|
+
const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
|
|
4404
|
+
return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
|
|
4405
|
+
});
|
|
4406
|
+
}
|
|
4328
4407
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
4329
4408
|
async isOrderClosed(orgId, orderId) {
|
|
4330
4409
|
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.0",
|
|
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.0"
|
|
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,6 +3201,7 @@ export class ChannelConnectorService {
|
|
|
3189
3201
|
"products/delete",
|
|
3190
3202
|
"inventory_levels/update",
|
|
3191
3203
|
"orders/fulfilled",
|
|
3204
|
+
"orders/partially_fulfilled",
|
|
3192
3205
|
"orders/cancelled",
|
|
3193
3206
|
"app/uninstalled",
|
|
3194
3207
|
], callbackUrl);
|
|
@@ -4985,10 +4998,10 @@ export class ChannelConnectorService {
|
|
|
4985
4998
|
const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
|
|
4986
4999
|
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
4987
5000
|
}
|
|
4988
|
-
} else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
|
|
5001
|
+
} else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
|
|
4989
5002
|
const orderId = await this.resolveOrderId(orgId, storeId, data);
|
|
4990
5003
|
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 }> };
|
|
5004
|
+
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
5005
|
const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
|
|
4993
5006
|
if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
4994
5007
|
if (event.type === "orders/cancelled") {
|
|
@@ -5000,11 +5013,17 @@ export class ChannelConnectorService {
|
|
|
5000
5013
|
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
5001
5014
|
}
|
|
5002
5015
|
}
|
|
5003
|
-
if (event.type === "orders/fulfilled") {
|
|
5016
|
+
if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
|
|
5017
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
5018
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
|
|
5019
|
+
if (!recorded.ok) return recorded;
|
|
5020
|
+
const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
|
|
5004
5021
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5005
5022
|
if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
5006
5023
|
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"
|
|
5024
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
5025
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
5026
|
+
}
|
|
5008
5027
|
}
|
|
5009
5028
|
}
|
|
5010
5029
|
} else if (event.type === "refunds/create") {
|
|
@@ -5470,6 +5489,67 @@ export class ChannelConnectorService {
|
|
|
5470
5489
|
return Ok(cancelled);
|
|
5471
5490
|
}
|
|
5472
5491
|
|
|
5492
|
+
/**
|
|
5493
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
5494
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
5495
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
5496
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
5497
|
+
*/
|
|
5498
|
+
private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<number>> {
|
|
5499
|
+
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
5500
|
+
if (!parsed.success) return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
5501
|
+
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
5502
|
+
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
5503
|
+
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5504
|
+
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 } }> };
|
|
5505
|
+
let created = 0;
|
|
5506
|
+
for (const parcel of parsed.data) {
|
|
5507
|
+
const parcelId = String(parcel.id);
|
|
5508
|
+
if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure") continue;
|
|
5509
|
+
const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
|
|
5510
|
+
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
5511
|
+
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
5512
|
+
.from(channelEntityMap)
|
|
5513
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
5514
|
+
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
5515
|
+
const matched = parcel.line_items.flatMap((line) => {
|
|
5516
|
+
const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
|
|
5517
|
+
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
5518
|
+
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
5519
|
+
});
|
|
5520
|
+
const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
|
|
5521
|
+
if (lineItems.length === 0) continue;
|
|
5522
|
+
const result = await fulfillment.createFulfillment({
|
|
5523
|
+
orderId,
|
|
5524
|
+
lineItems,
|
|
5525
|
+
...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
|
|
5526
|
+
...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
|
|
5527
|
+
...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
|
|
5528
|
+
status: "shipped",
|
|
5529
|
+
metadata: { channelFulfillmentId: parcelId, storeId },
|
|
5530
|
+
}, actor);
|
|
5531
|
+
if (!result.ok) return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
5532
|
+
recorded.add(parcelId);
|
|
5533
|
+
created += 1;
|
|
5534
|
+
}
|
|
5535
|
+
return Ok(created);
|
|
5536
|
+
}
|
|
5537
|
+
|
|
5538
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
5539
|
+
private async unfulfilledLines(lines: Array<{ id: string; quantity: number }>): Promise<Array<{ orderLineItemId: string; quantity: number }>> {
|
|
5540
|
+
const ids = lines.map((line) => line.id);
|
|
5541
|
+
const shipped = ids.length === 0 ? [] : await this.db
|
|
5542
|
+
.select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql<number>`coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
|
|
5543
|
+
.from(fulfillmentLineItems)
|
|
5544
|
+
.where(inArray(fulfillmentLineItems.orderLineItemId, ids))
|
|
5545
|
+
.groupBy(fulfillmentLineItems.orderLineItemId);
|
|
5546
|
+
const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
|
|
5547
|
+
return lines.flatMap((line) => {
|
|
5548
|
+
const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
|
|
5549
|
+
return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
|
|
5550
|
+
});
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5473
5553
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
5474
5554
|
async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
|
|
5475
5555
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|