@tallyui/connector-vendure 1.0.0 → 2.0.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/LICENSE +21 -0
- package/dist/index.d.ts +77 -25
- package/dist/index.js +558 -143
- package/package.json +12 -3
- package/src/auth.ts +74 -0
- package/src/global-settings.ts +18 -0
- package/src/index.ts +67 -44
- package/src/reconcile/ids.ts +38 -0
- package/src/reconcile/prices.ts +58 -0
- package/src/reconcile/stock.ts +48 -0
- package/src/replication/products.ts +136 -62
- package/src/replication/variant-feed.ts +108 -0
- package/src/schemas/products.ts +17 -1
- package/src/store-settings.ts +78 -0
- package/src/sync/products.ts +9 -8
- package/src/traits/product.ts +115 -23
- package/src/__tests__/product-traits.test.ts +0 -248
- package/src/replication/products.test.ts +0 -204
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { compareIds } from '@tallyui/core';
|
|
1
2
|
import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
|
|
3
|
+
import { vendureProductSchema } from '../schemas/products';
|
|
2
4
|
|
|
3
5
|
export type VendureProductCheckpoint = {
|
|
4
6
|
skip: number;
|
|
5
7
|
updatedAt: string;
|
|
8
|
+
passMax?: string;
|
|
9
|
+
passHighWater?: string;
|
|
10
|
+
passTotal?: number;
|
|
6
11
|
};
|
|
7
12
|
|
|
8
|
-
const PRODUCT_LIST_QUERY = `
|
|
13
|
+
export const PRODUCT_LIST_QUERY = (barcodeField?: string) => `
|
|
9
14
|
query GetProducts($options: ProductListOptions) {
|
|
10
15
|
products(options: $options) {
|
|
11
16
|
items {
|
|
@@ -27,12 +32,14 @@ const PRODUCT_LIST_QUERY = `
|
|
|
27
32
|
price
|
|
28
33
|
priceWithTax
|
|
29
34
|
currencyCode
|
|
30
|
-
|
|
31
|
-
stockOnHand
|
|
35
|
+
stockLevels { stockLocationId stockOnHand stockAllocated }
|
|
32
36
|
trackInventory
|
|
37
|
+
outOfStockThreshold
|
|
38
|
+
useGlobalOutOfStockThreshold
|
|
39
|
+
enabled
|
|
33
40
|
featuredAsset { id preview }
|
|
34
41
|
options { id name code }
|
|
35
|
-
customFields
|
|
42
|
+
${barcodeField ? `customFields { ${barcodeField} }` : ''}
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
45
|
totalItems
|
|
@@ -40,11 +47,12 @@ const PRODUCT_LIST_QUERY = `
|
|
|
40
47
|
}
|
|
41
48
|
`;
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
updatedAt
|
|
50
|
+
/** The high-water mark and the skew probes only compare ids and timestamps (backlog 29). */
|
|
51
|
+
const PRODUCT_MARK_QUERY = `
|
|
52
|
+
query GetProductsMark($options: ProductListOptions) {
|
|
53
|
+
products(options: $options) {
|
|
54
|
+
items { id updatedAt }
|
|
55
|
+
totalItems
|
|
48
56
|
}
|
|
49
57
|
}
|
|
50
58
|
`;
|
|
@@ -52,11 +60,12 @@ const UPDATE_PRODUCT_MUTATION = `
|
|
|
52
60
|
/**
|
|
53
61
|
* Helper to execute a GraphQL query against the Vendure Admin API.
|
|
54
62
|
*/
|
|
55
|
-
async function gql(
|
|
63
|
+
export async function gql(
|
|
56
64
|
context: SyncContext,
|
|
57
65
|
query: string,
|
|
58
66
|
variables?: Record<string, any>,
|
|
59
67
|
): Promise<any> {
|
|
68
|
+
type GqlBody = { data?: any; errors?: Array<{ message?: string }> };
|
|
60
69
|
const res = await fetch(`${context.baseUrl}/admin-api`, {
|
|
61
70
|
method: 'POST',
|
|
62
71
|
headers: {
|
|
@@ -67,82 +76,147 @@ async function gql(
|
|
|
67
76
|
signal: context.signal,
|
|
68
77
|
});
|
|
69
78
|
|
|
70
|
-
if (!res.ok)
|
|
71
|
-
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const body = await res.json().catch(() => undefined) as GqlBody | undefined;
|
|
81
|
+
const message = body?.errors?.[0]?.message;
|
|
82
|
+
throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`);
|
|
83
|
+
}
|
|
84
|
+
const body = await res.json() as GqlBody;
|
|
85
|
+
if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
|
|
86
|
+
return body;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Probes around a pass's high-water mark for the server's updatedAt skew
|
|
91
|
+
* (ADR-060). `fetchTotal(after)` runs the caller's own list query (products,
|
|
92
|
+
* variants) and returns its totalItems. Returns the mark actually probed
|
|
93
|
+
* (backlog 31: `remark`, given, may re-read a newer one; see below).
|
|
94
|
+
*/
|
|
95
|
+
export async function probeUpdatedAtSkew(
|
|
96
|
+
passHighWater: string,
|
|
97
|
+
updatedAtSkewMs: number,
|
|
98
|
+
fetchTotal: (after: string) => Promise<number>,
|
|
99
|
+
remark?: () => Promise<string | undefined>,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
for (const overlap of [1, 0]) {
|
|
102
|
+
if (overlap === 0 && updatedAtSkewMs > 0) continue;
|
|
103
|
+
const after = (mark: string) => new Date(Date.parse(mark) + (overlap === 0 ? 1 : -1) - updatedAtSkewMs).toISOString();
|
|
104
|
+
let total = await fetchTotal(after(passHighWater));
|
|
105
|
+
if (overlap === 1 && total === 0 && remark) {
|
|
106
|
+
// The mark's own product can be deleted between the mark read and this
|
|
107
|
+
// probe (harmless on its own: RxDB retries the resulting error and the
|
|
108
|
+
// next mark read picks a survivor). Re-reading the mark once here means
|
|
109
|
+
// that common case settles without throwing at all.
|
|
110
|
+
const remarked = await remark();
|
|
111
|
+
if (remarked) {
|
|
112
|
+
passHighWater = remarked;
|
|
113
|
+
total = await fetchTotal(after(passHighWater));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (overlap === 1 && total === 0) {
|
|
117
|
+
throw new Error('Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.');
|
|
118
|
+
}
|
|
119
|
+
if (overlap === 0 && total > 0) {
|
|
120
|
+
console.warn('Vendure updatedAt filters over-fetch because the server is not in UTC.');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return passHighWater;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Project an API product onto the schema's top-level fields (RxDB rejects undeclared ones). Sorts `variants` by id (a stable copy; the input is not mutated), since Vendure does not guarantee variant order. */
|
|
127
|
+
export function toProductDocument(p: any): Record<string, unknown> {
|
|
128
|
+
const doc: Record<string, unknown> = { _deleted: false };
|
|
129
|
+
for (const field of Object.keys(vendureProductSchema.properties)) {
|
|
130
|
+
if (p[field] !== undefined) doc[field] = p[field];
|
|
131
|
+
}
|
|
132
|
+
if (Array.isArray(doc.variants)) {
|
|
133
|
+
doc.variants = [...doc.variants].sort((a: any, b: any) => compareIds(a.id, b.id));
|
|
134
|
+
}
|
|
135
|
+
return doc;
|
|
72
136
|
}
|
|
73
137
|
|
|
74
138
|
/**
|
|
75
139
|
* Replication adapter for Vendure products.
|
|
76
140
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
141
|
+
* Pull-only (GraphQL query with offset pagination and updatedAt filtering)
|
|
142
|
+
* for RxDB's replicateRxCollection. Catalogue data is server-owned; the
|
|
143
|
+
* POS never writes products.
|
|
80
144
|
*/
|
|
81
|
-
export const
|
|
145
|
+
export const createVendureProductReplication = (barcodeField?: string, updatedAtSkewMs = 0): ReplicationAdapter<any, VendureProductCheckpoint> => ({
|
|
82
146
|
pull: {
|
|
83
147
|
async handler(lastCheckpoint, batchSize, context) {
|
|
148
|
+
if (batchSize > 1000) throw new Error('Vendure Admin API take must not exceed 1000');
|
|
149
|
+
// A checkpoint with skip > 0 but neither passHighWater nor passTotal is
|
|
150
|
+
// the shape saved mid-pass by the code before #45 gave a pass its own
|
|
151
|
+
// high-water mark and total (that code kept only skip and updatedAt).
|
|
152
|
+
// Its skip counts offset pages under a different sort and a moving
|
|
153
|
+
// updatedAt filter, which don't line up with this pull's fixed-window
|
|
154
|
+
// paging by id: resuming at that offset could skip rows. Restarting the
|
|
155
|
+
// pass from offset 0 cannot skip anything, only re-read rows this or an
|
|
156
|
+
// earlier pass already applied, which RxDB's upsert makes harmless.
|
|
157
|
+
if (lastCheckpoint?.skip && lastCheckpoint.passHighWater === undefined && lastCheckpoint.passTotal === undefined) {
|
|
158
|
+
lastCheckpoint = { skip: 0, updatedAt: lastCheckpoint.updatedAt ?? '' };
|
|
159
|
+
}
|
|
160
|
+
let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? '';
|
|
161
|
+
if (!lastCheckpoint?.skip) {
|
|
162
|
+
const fetchMark = async () => {
|
|
163
|
+
const head = await gql(context, PRODUCT_MARK_QUERY, { options: { take: 1, sort: { updatedAt: 'DESC' } } });
|
|
164
|
+
return head.data?.products?.items?.[0]?.updatedAt as string | undefined;
|
|
165
|
+
};
|
|
166
|
+
const mark = await fetchMark();
|
|
167
|
+
passHighWater = mark ?? lastCheckpoint?.updatedAt ?? '';
|
|
168
|
+
// Same-ms writes after this read wait for a newer mark or periodic reconcile (ADR-060, to come).
|
|
169
|
+
if (mark === undefined || (lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt)) {
|
|
170
|
+
return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: '' } };
|
|
171
|
+
}
|
|
172
|
+
passHighWater = await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
|
|
173
|
+
const probe = await gql(context, PRODUCT_MARK_QUERY, {
|
|
174
|
+
options: { take: 1, sort: { updatedAt: 'DESC' }, filter: { updatedAt: { after } } },
|
|
175
|
+
});
|
|
176
|
+
return probe.data.products.totalItems;
|
|
177
|
+
}, fetchMark);
|
|
178
|
+
}
|
|
84
179
|
const options: Record<string, any> = {
|
|
85
180
|
take: batchSize,
|
|
86
181
|
skip: lastCheckpoint?.skip ?? 0,
|
|
182
|
+
sort: { id: 'ASC' },
|
|
87
183
|
};
|
|
88
184
|
|
|
89
185
|
if (lastCheckpoint?.updatedAt) {
|
|
90
186
|
options.filter = {
|
|
91
|
-
|
|
187
|
+
// Vendure after is strict; overlap by 1 ms to include timestamp ties.
|
|
188
|
+
updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() },
|
|
92
189
|
};
|
|
93
190
|
}
|
|
94
191
|
|
|
95
|
-
const res = await gql(context, PRODUCT_LIST_QUERY, { options });
|
|
192
|
+
const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options });
|
|
96
193
|
|
|
97
|
-
|
|
98
|
-
|
|
194
|
+
let data = res.data?.products;
|
|
195
|
+
let passTotal = options.skip === 0 ? data.totalItems : lastCheckpoint?.passTotal ?? data.totalItems;
|
|
196
|
+
if (options.skip > 0 && data.totalItems < passTotal) {
|
|
197
|
+
options.skip = 0;
|
|
198
|
+
const restarted = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options });
|
|
199
|
+
data = restarted.data?.products;
|
|
200
|
+
passTotal = data.totalItems;
|
|
99
201
|
}
|
|
100
|
-
|
|
101
|
-
const data = res.data?.products;
|
|
102
202
|
const products: any[] = data?.items ?? [];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
203
|
+
if (!products.length && options.skip > 0) {
|
|
204
|
+
return createVendureProductReplication(barcodeField, updatedAtSkewMs).pull.handler(
|
|
205
|
+
{ skip: 0, updatedAt: lastCheckpoint?.updatedAt ?? '' }, batchSize, context,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const documents = products.map(toProductDocument);
|
|
109
209
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
210
|
+
// Keep the lower bound fixed while paging by id; advance it only at pass end.
|
|
211
|
+
// RxDB merges checkpoints, so explicitly clear pass state at completion.
|
|
212
|
+
const checkpoint: VendureProductCheckpoint = options.skip + products.length >= data.totalItems
|
|
213
|
+
? { skip: 0, updatedAt: passHighWater, passHighWater: undefined, passTotal: undefined }
|
|
214
|
+
: { skip: options.skip + products.length,
|
|
215
|
+
updatedAt: lastCheckpoint?.updatedAt ?? '', passHighWater, passTotal };
|
|
116
216
|
|
|
117
217
|
return { documents, checkpoint };
|
|
118
218
|
},
|
|
119
219
|
},
|
|
220
|
+
});
|
|
120
221
|
|
|
121
|
-
|
|
122
|
-
async handler(changeRows, context) {
|
|
123
|
-
const conflicts: any[] = [];
|
|
124
|
-
|
|
125
|
-
for (const row of changeRows) {
|
|
126
|
-
const doc = row.newDocumentState as any;
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
const res = await gql(context, UPDATE_PRODUCT_MUTATION, {
|
|
130
|
-
input: doc,
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
if (res.errors?.length) {
|
|
134
|
-
if (row.assumedMasterState) {
|
|
135
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
} catch {
|
|
139
|
-
if (row.assumedMasterState) {
|
|
140
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return conflicts;
|
|
146
|
-
},
|
|
147
|
-
},
|
|
148
|
-
};
|
|
222
|
+
export const vendureProductReplication = createVendureProductReplication();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
|
|
2
|
+
import { gql, PRODUCT_LIST_QUERY, probeUpdatedAtSkew, toProductDocument, type VendureProductCheckpoint } from './products';
|
|
3
|
+
|
|
4
|
+
/** Vendure Admin API maximum `take`; variant pages and parent re-fetch chunks use it. */
|
|
5
|
+
const MAX_TAKE = 1000;
|
|
6
|
+
|
|
7
|
+
const variants = (context: SyncContext, fields: string, options: Record<string, any>) => gql(context, `
|
|
8
|
+
query GetVariants($options: ProductVariantListOptions) {
|
|
9
|
+
productVariants(options: $options) { items { ${fields} } totalItems }
|
|
10
|
+
}
|
|
11
|
+
`, { options }).then((res) => res.data.productVariants as { items: any[]; totalItems: number });
|
|
12
|
+
/** The newest variant: each pass's high water, and a fresh install's seed. */
|
|
13
|
+
const newest = (context: SyncContext) => variants(context, 'id updatedAt', { take: 1, sort: { updatedAt: 'DESC' } });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Variant feed for Vendure products (ADR-060, decision 3).
|
|
17
|
+
*
|
|
18
|
+
* A variant price or stock edit bumps `ProductVariant.updatedAt` but not
|
|
19
|
+
* `Product.updatedAt`, so the product feed never sees it. This pull adapter
|
|
20
|
+
* pages changed variants with the same pass and high-water design as
|
|
21
|
+
* `createVendureProductReplication` and re-delivers their parent products,
|
|
22
|
+
* in the same document shape, into the `products` collection. It runs as a
|
|
23
|
+
* sub-adapter of the connector's combined `replication.products` adapter
|
|
24
|
+
* (`combinePullAdapters`), not as its own replication. Parents that no longer
|
|
25
|
+
* exist are skipped; deletions are the id reconcile's job.
|
|
26
|
+
*
|
|
27
|
+
* On a fresh install `seedCheckpoint` starts the cursor at the variant high
|
|
28
|
+
* water, read before the product feed's first pass, so the catalogue downloads
|
|
29
|
+
* once. An upgrade (a stored product checkpoint, none for this feed) is not
|
|
30
|
+
* seeded: its first pass re-delivers every product that has variants, once.
|
|
31
|
+
* That is deliberate: it heals any price or stock change the product feed
|
|
32
|
+
* missed before this feed existed.
|
|
33
|
+
*
|
|
34
|
+
* `variantPageSize` (the variant page `take`) is for tests and tuning.
|
|
35
|
+
*/
|
|
36
|
+
export const createVendureVariantFeedReplication = (barcodeField?: string, updatedAtSkewMs = 0, variantPageSize = MAX_TAKE): ReplicationAdapter<any, VendureProductCheckpoint> => ({
|
|
37
|
+
pull: {
|
|
38
|
+
// The next call's unchanged-high-water check then ends at one read, unless
|
|
39
|
+
// a variant changed since. The skew probe runs when a pass starts from it.
|
|
40
|
+
async seedCheckpoint(context) {
|
|
41
|
+
const head = await newest(context);
|
|
42
|
+
return { skip: 0, updatedAt: head.items[0]?.updatedAt ?? '', passHighWater: undefined, passTotal: undefined };
|
|
43
|
+
},
|
|
44
|
+
async handler(lastCheckpoint, batchSize, context) {
|
|
45
|
+
if (batchSize > MAX_TAKE || variantPageSize > MAX_TAKE) throw new Error('Vendure Admin API take must not exceed 1000');
|
|
46
|
+
let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? '';
|
|
47
|
+
let skip = lastCheckpoint?.skip ?? 0;
|
|
48
|
+
let passTotal = lastCheckpoint?.passTotal;
|
|
49
|
+
if (!skip) {
|
|
50
|
+
const head = await newest(context);
|
|
51
|
+
passHighWater = head.items[0]?.updatedAt ?? lastCheckpoint?.updatedAt ?? '';
|
|
52
|
+
if (!head.items.length || (lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt)) {
|
|
53
|
+
return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: '' } };
|
|
54
|
+
}
|
|
55
|
+
await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
|
|
56
|
+
const probe = await variants(context, 'id updatedAt', { take: 1, sort: { updatedAt: 'DESC' }, filter: { updatedAt: { after } } });
|
|
57
|
+
return probe.totalItems;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const filter = lastCheckpoint?.updatedAt
|
|
61
|
+
// Vendure after is strict; overlap by 1 ms to include timestamp ties.
|
|
62
|
+
? { updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() } }
|
|
63
|
+
: undefined;
|
|
64
|
+
const page = (from: number) => variants(context, 'id productId', { take: variantPageSize, skip: from, sort: { id: 'ASC' }, filter });
|
|
65
|
+
|
|
66
|
+
// RxDB stops pulling on a page shorter than batchSize, so keep reading
|
|
67
|
+
// variant pages until enough parents are delivered or the pass ends.
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const documents: Record<string, unknown>[] = [];
|
|
70
|
+
let complete = false;
|
|
71
|
+
while (!complete && documents.length < batchSize) {
|
|
72
|
+
let data = await page(skip);
|
|
73
|
+
if (skip > 0 && (!data.items.length || data.totalItems < (passTotal ?? data.totalItems))) {
|
|
74
|
+
skip = 0; // Rows vanished mid-pass: restart; re-reads are harmless, skips are not.
|
|
75
|
+
data = await page(0);
|
|
76
|
+
}
|
|
77
|
+
if (skip === 0) passTotal = data.totalItems;
|
|
78
|
+
const parentIds = [...new Set(data.items.map((v) => String(v.productId)))].filter((id) => !seen.has(id));
|
|
79
|
+
parentIds.forEach((id) => seen.add(id));
|
|
80
|
+
if (parentIds.length) { // At most variantPageSize (<= MAX_TAKE) ids: one variant page's worth.
|
|
81
|
+
const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), {
|
|
82
|
+
options: { take: parentIds.length, filter: { id: { in: parentIds } } },
|
|
83
|
+
});
|
|
84
|
+
documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
|
|
85
|
+
}
|
|
86
|
+
skip += data.items.length;
|
|
87
|
+
complete = skip >= data.totalItems || !data.items.length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Keep the lower bound fixed while paging by id; advance it only at pass end.
|
|
91
|
+
// RxDB merges checkpoints, so explicitly clear pass state at completion.
|
|
92
|
+
const checkpoint: VendureProductCheckpoint = complete
|
|
93
|
+
? { skip: 0, updatedAt: passHighWater, passHighWater: undefined, passTotal: undefined }
|
|
94
|
+
: { skip, updatedAt: lastCheckpoint?.updatedAt ?? '', passHighWater, passTotal };
|
|
95
|
+
// RxDB saves no checkpoint from an empty page. If this call's variant
|
|
96
|
+
// pages mapped to no live parents but the cursor moved, the cursor would
|
|
97
|
+
// stick mid-pass and a later change below it would be lost. Carry the
|
|
98
|
+
// checkpoint on one current product; re-delivering it is harmless.
|
|
99
|
+
const moved = (['skip', 'updatedAt', 'passHighWater', 'passTotal'] as const)
|
|
100
|
+
.some((key) => checkpoint[key] !== lastCheckpoint?.[key]);
|
|
101
|
+
if (!documents.length && moved) {
|
|
102
|
+
const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options: { take: 1 } });
|
|
103
|
+
documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
|
|
104
|
+
}
|
|
105
|
+
return { documents, checkpoint };
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
package/src/schemas/products.ts
CHANGED
|
@@ -12,7 +12,9 @@ import type { RxJsonSchema } from 'rxdb';
|
|
|
12
12
|
* - No native barcode field (custom fields only)
|
|
13
13
|
*/
|
|
14
14
|
export const vendureProductSchema: RxJsonSchema<any> = {
|
|
15
|
-
|
|
15
|
+
// 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
|
|
16
|
+
// (backlog 28). A bump drops and resyncs (ConnectorSchemas, ADR-060 amendment 9).
|
|
17
|
+
version: 1,
|
|
16
18
|
primaryKey: 'id',
|
|
17
19
|
type: 'object',
|
|
18
20
|
properties: {
|
|
@@ -80,7 +82,21 @@ export const vendureProductSchema: RxJsonSchema<any> = {
|
|
|
80
82
|
currencyCode: { type: 'string' },
|
|
81
83
|
stockLevel: { type: 'string' },
|
|
82
84
|
stockOnHand: { type: 'number' },
|
|
85
|
+
stockLevels: {
|
|
86
|
+
type: 'array',
|
|
87
|
+
items: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
stockLocationId: { type: 'string' },
|
|
91
|
+
stockOnHand: { type: 'number' },
|
|
92
|
+
stockAllocated: { type: 'number' },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
83
96
|
trackInventory: { type: 'string' },
|
|
97
|
+
outOfStockThreshold: { type: 'number' },
|
|
98
|
+
useGlobalOutOfStockThreshold: { type: 'boolean' },
|
|
99
|
+
enabled: { type: 'boolean' },
|
|
84
100
|
featuredAsset: {
|
|
85
101
|
type: ['object', 'null'],
|
|
86
102
|
properties: {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { StoreSettingsError } from '@tallyui/core';
|
|
2
|
+
import type { StoreSettings, StoreSettingsChoice, SyncContext } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
import { gql } from './replication/products';
|
|
5
|
+
|
|
6
|
+
const CHANNEL_AND_CATEGORIES_QUERY = `
|
|
7
|
+
query StoreSettingsChannel {
|
|
8
|
+
activeChannel { defaultCurrencyCode pricesIncludeTax defaultTaxZone { id } }
|
|
9
|
+
taxCategories { items { id isDefault } }
|
|
10
|
+
}
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
const TAX_RATES_QUERY = `
|
|
14
|
+
query StoreSettingsTaxRates($zoneId: String!) {
|
|
15
|
+
taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
|
|
16
|
+
items { enabled value category { id } customerGroup { id } }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
type ActiveChannel = { defaultCurrencyCode: string; pricesIncludeTax: boolean; defaultTaxZone: { id: string } | null };
|
|
22
|
+
type TaxCategory = { id: string; isDefault: boolean };
|
|
23
|
+
type TaxRateItem = { enabled: boolean; value: number; category: { id: string } | null; customerGroup: { id: string } | null };
|
|
24
|
+
|
|
25
|
+
/** `gql` throws a plain `Error` for a non-OK response or a GraphQL `errors` body; both become `StoreSettingsError('failed')` here. */
|
|
26
|
+
async function query(context: SyncContext, source: string, variables?: Record<string, unknown>): Promise<any> {
|
|
27
|
+
try {
|
|
28
|
+
return await gql(context, source, variables);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new StoreSettingsError('failed', error instanceof Error ? error.message : String(error));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the active channel's currency and tax-inclusivity (ADR-049), and the
|
|
36
|
+
* default tax zone's enabled, non-customer-group rates keyed by tax category
|
|
37
|
+
* id, in integer ppm rounded once. Read-only (ADR-048). Ignores `choice`:
|
|
38
|
+
* Vendure's channel is already chosen by the `vendure-token` header sent
|
|
39
|
+
* with every request, so there is no `pricingContext` either.
|
|
40
|
+
*/
|
|
41
|
+
export const vendureStoreSettings = async (context: SyncContext, _choice?: StoreSettingsChoice): Promise<StoreSettings> => {
|
|
42
|
+
const head = await query(context, CHANNEL_AND_CATEGORIES_QUERY);
|
|
43
|
+
const channel = head.data?.activeChannel as ActiveChannel | undefined;
|
|
44
|
+
if (!channel) throw new StoreSettingsError('failed', 'Vendure returned no active channel');
|
|
45
|
+
const categories: TaxCategory[] = head.data?.taxCategories?.items ?? [];
|
|
46
|
+
|
|
47
|
+
let rateItems: TaxRateItem[] = [];
|
|
48
|
+
if (channel.defaultTaxZone) {
|
|
49
|
+
const ratesBody = await query(context, TAX_RATES_QUERY, { zoneId: channel.defaultTaxZone.id });
|
|
50
|
+
rateItems = ratesBody.data?.taxRates?.items ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const byCategory = new Map<string, number>();
|
|
54
|
+
for (const rate of rateItems) {
|
|
55
|
+
if (!rate.enabled || rate.customerGroup || !rate.category) continue;
|
|
56
|
+
// Rounded once, here, at the connector's edge (a backend decimal percentage -> integer ppm).
|
|
57
|
+
byCategory.set(rate.category.id, Math.round(rate.value * 10_000));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Vendure's own fallback for a variant created without a category
|
|
61
|
+
// (getTaxCategoryForNewVariant, @vendure/core 3.7.3,
|
|
62
|
+
// dist/service/services/product-variant.service.js:875): the isDefault
|
|
63
|
+
// category, or, when none is flagged, taxCategories.items[0]. When that
|
|
64
|
+
// category has no enabled rate in the zone (or there are no categories
|
|
65
|
+
// at all), `default` is 0 — that is what Vendure itself charges in that
|
|
66
|
+
// case, never a silent guess.
|
|
67
|
+
const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
|
|
68
|
+
const taxRatesPpm: { default: number; [taxClass: string]: number } = {
|
|
69
|
+
default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0,
|
|
70
|
+
};
|
|
71
|
+
for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
currency: channel.defaultCurrencyCode,
|
|
75
|
+
pricesIncludeTax: channel.pricesIncludeTax,
|
|
76
|
+
taxRatesPpm,
|
|
77
|
+
};
|
|
78
|
+
};
|
package/src/sync/products.ts
CHANGED
|
@@ -15,7 +15,7 @@ const PRODUCT_LIST_QUERY = `
|
|
|
15
15
|
}
|
|
16
16
|
`;
|
|
17
17
|
|
|
18
|
-
const PRODUCT_DETAIL_QUERY = `
|
|
18
|
+
const PRODUCT_DETAIL_QUERY = (barcodeField?: string) => `
|
|
19
19
|
query GetProduct($id: ID!) {
|
|
20
20
|
product(id: $id) {
|
|
21
21
|
id
|
|
@@ -36,12 +36,11 @@ const PRODUCT_DETAIL_QUERY = `
|
|
|
36
36
|
price
|
|
37
37
|
priceWithTax
|
|
38
38
|
currencyCode
|
|
39
|
-
|
|
40
|
-
stockOnHand
|
|
39
|
+
stockLevels { stockLocationId stockOnHand stockAllocated }
|
|
41
40
|
trackInventory
|
|
42
41
|
featuredAsset { id preview }
|
|
43
42
|
options { id name code }
|
|
44
|
-
customFields
|
|
43
|
+
${barcodeField ? `customFields { ${barcodeField} }` : ''}
|
|
45
44
|
}
|
|
46
45
|
}
|
|
47
46
|
}
|
|
@@ -53,7 +52,7 @@ const PRODUCT_DETAIL_QUERY = `
|
|
|
53
52
|
* Uses the Admin GraphQL API. Vendure uses offset-based pagination
|
|
54
53
|
* with `take` and `skip` options.
|
|
55
54
|
*/
|
|
56
|
-
export const
|
|
55
|
+
export const createVendureProductSync = (barcodeField?: string): CollectionSync => ({
|
|
57
56
|
fetchAllIds: async (context: SyncContext) => {
|
|
58
57
|
const entries: { id: string; dateModified?: string }[] = [];
|
|
59
58
|
const take = 100;
|
|
@@ -86,7 +85,7 @@ export const vendureProductSync: CollectionSync = {
|
|
|
86
85
|
const products: any[] = [];
|
|
87
86
|
// Vendure doesn't support batch-by-IDs natively, so fetch individually
|
|
88
87
|
for (const id of ids) {
|
|
89
|
-
const res = await gql(context, PRODUCT_DETAIL_QUERY, { id });
|
|
88
|
+
const res = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id });
|
|
90
89
|
if (res.data?.product) {
|
|
91
90
|
products.push(res.data.product);
|
|
92
91
|
}
|
|
@@ -118,7 +117,7 @@ export const vendureProductSync: CollectionSync = {
|
|
|
118
117
|
|
|
119
118
|
// Fetch full details for each modified product
|
|
120
119
|
for (const item of data.items ?? []) {
|
|
121
|
-
const detail = await gql(context, PRODUCT_DETAIL_QUERY, { id: item.id });
|
|
120
|
+
const detail = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id: item.id });
|
|
122
121
|
if (detail.data?.product) {
|
|
123
122
|
products.push(detail.data.product);
|
|
124
123
|
}
|
|
@@ -129,7 +128,9 @@ export const vendureProductSync: CollectionSync = {
|
|
|
129
128
|
|
|
130
129
|
return products;
|
|
131
130
|
},
|
|
132
|
-
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export const vendureProductSync = createVendureProductSync();
|
|
133
134
|
|
|
134
135
|
/**
|
|
135
136
|
* Helper to execute a GraphQL query against the Vendure Admin API.
|