@tallyui/connector-vendure 1.0.0 → 3.0.0-next.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/README.md +7 -0
- package/dist/index.d.ts +100 -28
- package/dist/index.js +661 -145
- package/package.json +13 -4
- package/src/auth.ts +79 -0
- package/src/capabilities.ts +44 -0
- package/src/global-settings.ts +18 -0
- package/src/index.ts +86 -47
- 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 +162 -62
- package/src/replication/variant-feed.ts +108 -0
- package/src/schemas/products.ts +19 -1
- package/src/session-probe.ts +39 -0
- package/src/store-settings.ts +81 -0
- package/src/sync/products.ts +16 -9
- package/src/traits/product.ts +123 -23
- package/src/__tests__/product-traits.test.ts +0 -248
- package/src/replication/products.test.ts +0 -204
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/connector-vendure",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Vendure connector for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,14 +15,23 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
-
"src"
|
|
18
|
+
"src",
|
|
19
|
+
"!src/**/*.test.ts",
|
|
20
|
+
"!src/**/*.fixture.json"
|
|
19
21
|
],
|
|
20
22
|
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/TallyUI/tallyui.git",
|
|
26
|
+
"directory": "connectors/vendure"
|
|
27
|
+
},
|
|
21
28
|
"peerDependencies": {
|
|
22
|
-
"@tallyui/core": "0.
|
|
29
|
+
"@tallyui/core": "^3.0.0-next.0"
|
|
23
30
|
},
|
|
24
31
|
"devDependencies": {
|
|
25
|
-
"
|
|
32
|
+
"@tallyui/database": "3.0.0-next.0",
|
|
33
|
+
"@tallyui/pos": "3.0.0-next.0",
|
|
34
|
+
"rxdb": "17.5.0"
|
|
26
35
|
},
|
|
27
36
|
"scripts": {
|
|
28
37
|
"build": "tsup",
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { SignInError, type ConnectorAuth } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
import { readVendureCapabilities } from './capabilities';
|
|
4
|
+
|
|
5
|
+
const LOGIN = `mutation Login($email: String!, $password: String!) {
|
|
6
|
+
login(username: $email, password: $password) {
|
|
7
|
+
__typename
|
|
8
|
+
... on CurrentUser { id }
|
|
9
|
+
... on ErrorResult { errorCode message }
|
|
10
|
+
}
|
|
11
|
+
}`;
|
|
12
|
+
|
|
13
|
+
type LoginBody = { data?: { login?: { __typename: string; errorCode?: string; message?: string } }; errors?: { message: string }[] };
|
|
14
|
+
|
|
15
|
+
// Signs in on the Admin API; the session token comes back in the vendure-auth-token header.
|
|
16
|
+
// Vendure sessions roll forward on use, so there is no expiresAt.
|
|
17
|
+
export const vendureSignIn: NonNullable<ConnectorAuth['signIn']> = async (baseUrl, { email, password }, init = {}) => {
|
|
18
|
+
const doFetch = init.fetch ?? fetch;
|
|
19
|
+
let res: Response;
|
|
20
|
+
try {
|
|
21
|
+
res = await doFetch(`${baseUrl}/admin-api`, {
|
|
22
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: init.signal,
|
|
23
|
+
body: JSON.stringify({ query: LOGIN, variables: { email, password } }),
|
|
24
|
+
});
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if ((error as { name?: unknown })?.name === 'AbortError' || init.signal?.aborted) throw error;
|
|
27
|
+
throw new SignInError('failed', `Could not reach Vendure at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
28
|
+
}
|
|
29
|
+
let body: LoginBody;
|
|
30
|
+
try {
|
|
31
|
+
body = await res.json();
|
|
32
|
+
} catch {
|
|
33
|
+
throw new SignInError('server_error', `Vendure sent an unreadable response (HTTP ${res.status})`, res.status);
|
|
34
|
+
}
|
|
35
|
+
if (!res.ok) throw new SignInError('server_error', body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
|
|
36
|
+
const login = body?.data?.login;
|
|
37
|
+
if (body?.errors?.length || !login) {
|
|
38
|
+
throw new SignInError('server_error', body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
|
|
39
|
+
}
|
|
40
|
+
if (login.errorCode === 'INVALID_CREDENTIALS_ERROR') throw new SignInError('invalid_credentials', login.message ?? 'Invalid email or password');
|
|
41
|
+
// Native email/password auth is disabled on the server, so it needs a sign-in flow this connector can't do.
|
|
42
|
+
if (login.errorCode === 'NATIVE_AUTH_STRATEGY_ERROR') throw new SignInError('unsupported', login.message ?? 'Native email and password sign-in is disabled on this Vendure server');
|
|
43
|
+
if (login.__typename !== 'CurrentUser') throw new SignInError('server_error', login.message ?? `Vendure sign-in failed (${login.__typename})`, res.status);
|
|
44
|
+
const token = res.headers.get('vendure-auth-token');
|
|
45
|
+
if (!token) {
|
|
46
|
+
throw new SignInError('unsupported', "Vendure signed in but sent no 'vendure-auth-token' header. Add 'bearer' to the server's authOptions.tokenMethod. This connector reads the default header name, so a server that renames authOptions.authTokenHeaderKey also gives this error.");
|
|
47
|
+
}
|
|
48
|
+
// A capabilities read that fails never fails the sign-in: the capabilities are then unknown.
|
|
49
|
+
const capabilities = await readVendureCapabilities(baseUrl, { Authorization: `Bearer ${token}` }, { fetch: doFetch, signal: init.signal })
|
|
50
|
+
.catch(() => undefined);
|
|
51
|
+
return { token, expiresAt: undefined, capabilities };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Warns once per module lifetime when a caller still relies on the deprecated auth_token alias.
|
|
55
|
+
let warnedAuthToken = false;
|
|
56
|
+
|
|
57
|
+
export const vendureAuth: ConnectorAuth = {
|
|
58
|
+
type: 'Vendure Admin API',
|
|
59
|
+
fields: [
|
|
60
|
+
{ key: 'url', label: 'Backend URL', type: 'url', placeholder: 'https://my-vendure-server.com', required: true },
|
|
61
|
+
{ key: 'email', label: 'Email', type: 'text', required: true },
|
|
62
|
+
{ key: 'password', label: 'Password', type: 'password', required: true },
|
|
63
|
+
{ key: 'channel_token', label: 'Channel token (optional)', type: 'text' },
|
|
64
|
+
],
|
|
65
|
+
// An API key (Vendure 3.6+) wins over a signed-in session token; channel_token selects a non-default channel.
|
|
66
|
+
// auth_token is the deprecated name for token, kept so existing apps keep working.
|
|
67
|
+
getHeaders: (credentials): Record<string, string> => {
|
|
68
|
+
if (credentials.token === undefined && credentials.auth_token !== undefined && !warnedAuthToken) {
|
|
69
|
+
warnedAuthToken = true;
|
|
70
|
+
console.warn("@tallyui/connector-vendure: the 'auth_token' credential is deprecated; store the credential as 'token' instead.");
|
|
71
|
+
}
|
|
72
|
+
const token = credentials.token ?? credentials.auth_token;
|
|
73
|
+
return {
|
|
74
|
+
...(credentials.api_key ? { 'vendure-api-key': credentials.api_key } : token ? { Authorization: `Bearer ${token}` } : {}),
|
|
75
|
+
...(credentials.channel_token ? { 'vendure-token': credentials.channel_token } : {}),
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
signIn: vendureSignIn,
|
|
79
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ConnectorUnauthorizedError, parseInfoCapabilities, type ServerCapabilities } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
import { signedOut } from './session-probe';
|
|
4
|
+
|
|
5
|
+
/** The plugin's capability endpoint on Vendure's REST server (ADR-062), relative to the base URL. */
|
|
6
|
+
export const CAPABILITIES_PATH = '/tally/v1/info';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the store's contract capabilities (ADR-062) and `taxRounding` (#287) with the
|
|
10
|
+
* connector's own headers. A 2xx gives `parseInfoCapabilities`; a 404 or a non-JSON body
|
|
11
|
+
* means no plugin and gives `{ orderCreate: 1 }`; a network failure, a 5xx or any other
|
|
12
|
+
* non-2xx is unknown and gives `undefined`. The route answers a dead session and a missing
|
|
13
|
+
* `CreateOrder` permission alike with 403, so a 401/403 asks who is signed in (#279): nobody
|
|
14
|
+
* throws `ConnectorUnauthorizedError`, an administrator throws a plain Error naming the
|
|
15
|
+
* permission, and a failed check is unknown.
|
|
16
|
+
*/
|
|
17
|
+
export async function readVendureCapabilities(
|
|
18
|
+
baseUrl: string,
|
|
19
|
+
headers: Record<string, string>,
|
|
20
|
+
init: { fetch?: typeof fetch; signal?: AbortSignal } = {},
|
|
21
|
+
): Promise<ServerCapabilities | undefined> {
|
|
22
|
+
const doFetch = init.fetch ?? fetch;
|
|
23
|
+
let res: Response;
|
|
24
|
+
try {
|
|
25
|
+
res = await doFetch(`${baseUrl}${CAPABILITIES_PATH}`, { method: 'GET', headers, signal: init.signal });
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (res.status === 401 || res.status === 403) {
|
|
30
|
+
const out = await signedOut({ connectorId: 'vendure', baseUrl, headers, signal: init.signal });
|
|
31
|
+
if (out === true) throw new ConnectorUnauthorizedError(`Vendure refused ${CAPABILITIES_PATH}: the session is signed out`, res.status);
|
|
32
|
+
if (out === false) throw new Error(`Vendure refused ${CAPABILITIES_PATH} although the session is signed in: the CreateOrder permission is missing (HTTP ${res.status})`);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (res.status === 404) return { orderCreate: 1 };
|
|
36
|
+
if (!res.ok) return undefined;
|
|
37
|
+
let body: unknown;
|
|
38
|
+
try {
|
|
39
|
+
body = await res.json();
|
|
40
|
+
} catch {
|
|
41
|
+
return { orderCreate: 1 };
|
|
42
|
+
}
|
|
43
|
+
return parseInfoCapabilities(body, (reason) => console.warn(`@tallyui/connector-vendure: ${reason}`));
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SyncContext } from '@tallyui/core';
|
|
2
|
+
import { gql } from './replication/products';
|
|
3
|
+
|
|
4
|
+
const GLOBAL_SETTINGS_QUERY = `
|
|
5
|
+
query GlobalStockSettings {
|
|
6
|
+
globalSettings { trackInventory outOfStockThreshold }
|
|
7
|
+
}
|
|
8
|
+
`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The channel-wide stock defaults an `INHERIT` variant falls back to
|
|
12
|
+
* (backlog 28). Read with `vendureGlobalStockSettings` after sign-in, like
|
|
13
|
+
* `pricesIncludeTax` from `storeSettings`.
|
|
14
|
+
*/
|
|
15
|
+
export async function vendureGlobalStockSettings(context: SyncContext): Promise<{ trackInventory: boolean; outOfStockThreshold: number }> {
|
|
16
|
+
const res = await gql(context, GLOBAL_SETTINGS_QUERY);
|
|
17
|
+
return res.data.globalSettings;
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,73 +1,112 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { combinePullAdapters, createReconcileFeed, type TallyConnector } from '@tallyui/core';
|
|
2
2
|
|
|
3
|
+
import { vendureAuth } from './auth';
|
|
4
|
+
import { readVendureCapabilities } from './capabilities';
|
|
3
5
|
import { vendureProductSchema } from './schemas/products';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { createVendureProductTraits } from './traits/product';
|
|
7
|
+
import { createVendureProductSync } from './sync/products';
|
|
8
|
+
import { createVendureProductReplication } from './replication/products';
|
|
9
|
+
import { createVendureVariantFeedReplication } from './replication/variant-feed';
|
|
10
|
+
import { vendureStockReconcile } from './reconcile/stock';
|
|
11
|
+
import { createFetchByIds, fetchPages, variantIds } from './reconcile/ids';
|
|
12
|
+
import { fetchPages as fetchPricePages, fingerprint as priceFingerprint } from './reconcile/prices';
|
|
13
|
+
import { vendureStoreSettings } from './store-settings';
|
|
14
|
+
import { vendureGlobalStockSettings } from './global-settings';
|
|
15
|
+
|
|
16
|
+
/** Ids per fetchByIds request: it sends the reconcile feed's whole chunk (at most 1,000 ids, ADR-060) in one query. */
|
|
17
|
+
const VENDURE_IDS_PER_REQUEST = 1000;
|
|
7
18
|
|
|
8
19
|
/**
|
|
9
|
-
* Vendure connector for Tally UI.
|
|
20
|
+
* Vendure connector for Tally UI. Build one per store session, anew on each sign-in or store change:
|
|
21
|
+
* each instance owns its reconcile feed, whose queued work must never reach another store's database (#307).
|
|
10
22
|
*
|
|
11
23
|
* Connects to Vendure backends via the Admin GraphQL API.
|
|
12
24
|
* Products are stored in RxDB using a schema that mirrors the Vendure API shape.
|
|
13
25
|
*
|
|
14
26
|
* ```ts
|
|
15
|
-
* import {
|
|
27
|
+
* import { createVendureConnector } from '@tallyui/connector-vendure';
|
|
16
28
|
* import { ConnectorProvider } from '@tallyui/core';
|
|
17
29
|
*
|
|
18
|
-
*
|
|
30
|
+
* const connector = useMemo(() => createVendureConnector({ pricesIncludeTax }), [backendUrl, pricesIncludeTax]);
|
|
31
|
+
* <ConnectorProvider connector={connector}>
|
|
19
32
|
* <App />
|
|
20
33
|
* </ConnectorProvider>
|
|
21
34
|
* ```
|
|
35
|
+
*
|
|
36
|
+
* `pricesIncludeTax` must equal the POS tax setting (`TaxContext.pricesIncludeTax`);
|
|
37
|
+
* pass `settings.pricesIncludeTax` from `storeSettings`. `globalTrackInventory`
|
|
38
|
+
* and `globalOutOfStockThreshold` are the channel's stock defaults; read them
|
|
39
|
+
* with `vendureGlobalStockSettings` after sign-in, like `pricesIncludeTax`
|
|
40
|
+
* from `storeSettings`.
|
|
22
41
|
*/
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
export const createVendureConnector = (options: {
|
|
43
|
+
barcodeField?: string; stockLocationId?: string; pricesIncludeTax?: boolean; updatedAtSkewMs?: number;
|
|
44
|
+
globalTrackInventory?: boolean; globalOutOfStockThreshold?: number;
|
|
45
|
+
} = {}): TallyConnector => {
|
|
46
|
+
// The id reconcile's corrections reach `products` only through this pull adapter (ADR-060).
|
|
47
|
+
const idFeed = createReconcileFeed({ fetchByIds: createFetchByIds(options.barcodeField) });
|
|
48
|
+
return {
|
|
49
|
+
id: 'vendure',
|
|
50
|
+
name: 'Vendure',
|
|
51
|
+
description: 'Connect to Vendure backends via the Admin GraphQL API',
|
|
52
|
+
icon: undefined,
|
|
53
|
+
|
|
54
|
+
auth: vendureAuth,
|
|
55
|
+
|
|
56
|
+
schemas: {
|
|
57
|
+
products: vendureProductSchema,
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
traits: {
|
|
61
|
+
product: createVendureProductTraits(
|
|
62
|
+
options.barcodeField, options.stockLocationId, options.pricesIncludeTax,
|
|
63
|
+
options.globalTrackInventory, options.globalOutOfStockThreshold,
|
|
64
|
+
),
|
|
65
|
+
},
|
|
28
66
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{
|
|
33
|
-
key: 'url',
|
|
34
|
-
label: 'Backend URL',
|
|
35
|
-
type: 'url',
|
|
36
|
-
placeholder: 'https://my-vendure-server.com',
|
|
37
|
-
required: true,
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
key: 'auth_token',
|
|
41
|
-
label: 'Auth Token',
|
|
42
|
-
type: 'password',
|
|
43
|
-
placeholder: 'vendure-auth-token from login',
|
|
44
|
-
required: true,
|
|
45
|
-
},
|
|
46
|
-
],
|
|
47
|
-
getHeaders: (credentials) => ({
|
|
48
|
-
Authorization: `Bearer ${credentials.auth_token}`,
|
|
49
|
-
}),
|
|
50
|
-
},
|
|
67
|
+
sync: {
|
|
68
|
+
products: createVendureProductSync(options.barcodeField),
|
|
69
|
+
},
|
|
51
70
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
replication: {
|
|
72
|
+
// One replication per collection: the product, variant and id-reconcile feeds
|
|
73
|
+
// share it (ADR-060). reconcile is last so its fetch wins duplicates.
|
|
74
|
+
products: combinePullAdapters({
|
|
75
|
+
products: createVendureProductReplication(options.barcodeField, options.updatedAtSkewMs),
|
|
76
|
+
variants: createVendureVariantFeedReplication(options.barcodeField, options.updatedAtSkewMs),
|
|
77
|
+
reconcile: idFeed.adapter,
|
|
78
|
+
}, { legacyKey: 'products' }),
|
|
79
|
+
},
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
81
|
+
reconcile: {
|
|
82
|
+
stock: vendureStockReconcile,
|
|
83
|
+
ids: { fetchPages, variantIds, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
|
|
84
|
+
prices: { fetchPages: fetchPricePages, fingerprint: priceFingerprint, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
|
|
85
|
+
},
|
|
59
86
|
|
|
60
|
-
|
|
61
|
-
products: vendureProductSync,
|
|
62
|
-
},
|
|
87
|
+
storeSettings: vendureStoreSettings,
|
|
63
88
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
89
|
+
// Re-reads a restored session's capabilities; with an API key and no sign-in, the only reader.
|
|
90
|
+
capabilities: (context) => readVendureCapabilities(context.baseUrl, context.headers, { signal: context.signal }),
|
|
91
|
+
};
|
|
67
92
|
};
|
|
68
93
|
|
|
94
|
+
/**
|
|
95
|
+
* @deprecated One instance for the whole app: a store switch can leak queued reconcile work across stores.
|
|
96
|
+
* Use createVendureConnector() per store session. Removed in 4.0.
|
|
97
|
+
*/
|
|
98
|
+
export const vendureConnector = createVendureConnector();
|
|
99
|
+
|
|
69
100
|
// Re-export pieces for advanced usage
|
|
101
|
+
export { vendureAuth, vendureSignIn } from './auth';
|
|
70
102
|
export { vendureProductSchema } from './schemas/products';
|
|
71
103
|
export { vendureProductTraits } from './traits/product';
|
|
72
104
|
export { vendureProductSync } from './sync/products';
|
|
73
|
-
export { vendureProductReplication } from './replication/products';
|
|
105
|
+
export { createVendureProductReplication, vendureProductReplication } from './replication/products';
|
|
106
|
+
export { createVendureVariantFeedReplication } from './replication/variant-feed';
|
|
107
|
+
export { vendureStockReconcile } from './reconcile/stock';
|
|
108
|
+
export { vendureStoreSettings } from './store-settings';
|
|
109
|
+
export { vendureGlobalStockSettings } from './global-settings';
|
|
110
|
+
|
|
111
|
+
export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
112
|
+
export { VendureTimezoneConfigError } from './replication/products';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SyncContext } from '@tallyui/core';
|
|
2
|
+
import { gql, PRODUCT_LIST_QUERY, toProductDocument } from '../replication/products';
|
|
3
|
+
|
|
4
|
+
/** Vendure Admin API maximum `take`; also the id-listing page size. */
|
|
5
|
+
const PAGE_SIZE = 1000;
|
|
6
|
+
|
|
7
|
+
const ID_LIST_QUERY = `
|
|
8
|
+
query GetProductIds($options: ProductListOptions) {
|
|
9
|
+
products(options: $options) { items { id variants { id } } totalItems }
|
|
10
|
+
}
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
/** Every live product id with its live variant ids, one request per page (ADR-060). */
|
|
14
|
+
export async function* fetchPages(context: SyncContext): AsyncIterable<Array<{ id: string; variantIds: string[] }>> {
|
|
15
|
+
// total starts at 1 so the loop runs at least once, before totalItems is known.
|
|
16
|
+
for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
|
|
17
|
+
const res = await gql(context, ID_LIST_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: 'ASC' } } });
|
|
18
|
+
const { items, totalItems } = res.data.products as {
|
|
19
|
+
items: Array<{ id: string; variants: Array<{ id: string }> }>; totalItems: number;
|
|
20
|
+
};
|
|
21
|
+
total = totalItems;
|
|
22
|
+
yield items.map((p) => ({ id: String(p.id), variantIds: (p.variants ?? []).map((v) => String(v.id)) }));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The variant ids a local product document lists. */
|
|
27
|
+
export function variantIds(doc: any): string[] {
|
|
28
|
+
return (doc.variants ?? []).map((v: any) => String(v.id));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `fetchByIds` for `createReconcileFeed`, in the replication feeds' document shape. */
|
|
32
|
+
export function createFetchByIds(barcodeField?: string) {
|
|
33
|
+
return async function fetchByIds(ids: string[], context: SyncContext): Promise<Record<string, unknown>[]> {
|
|
34
|
+
if (!ids.length) return [];
|
|
35
|
+
const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options: { take: ids.length, filter: { id: { in: ids } } } });
|
|
36
|
+
return (res.data?.products?.items ?? []).map(toProductDocument);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { SyncContext } from '@tallyui/core';
|
|
2
|
+
import { gql } from '../replication/products';
|
|
3
|
+
|
|
4
|
+
/** Vendure Admin API maximum `take`; also the price-listing page size (ADR-060). */
|
|
5
|
+
const PAGE_SIZE = 1000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The nightly base-price backstop's interval: tax-rate changes move
|
|
9
|
+
* `priceWithTax` without bumping `updatedAt` (ADR-060).
|
|
10
|
+
*/
|
|
11
|
+
export const VENDURE_PRICE_RECONCILE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
const PRICE_LIST_QUERY = `
|
|
14
|
+
query GetVariantPrices($options: ProductVariantListOptions) {
|
|
15
|
+
productVariants(options: $options) { items { id productId price priceWithTax currencyCode } totalItems }
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
/** `${variantId}:${currencyCode}:${price}:${priceWithTax}` entries, sorted and joined. */
|
|
20
|
+
const fingerprintOf = (entries: string[]): string => [...entries].sort().join('|');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every product's price fingerprint, one `productVariants` page at a time
|
|
24
|
+
* (ADR-060's nightly backstop). The listing is not grouped by product, so a
|
|
25
|
+
* product's variants may land on different pages; a page's yielded entry is
|
|
26
|
+
* always recomputed from every variant seen for that product so far, so the
|
|
27
|
+
* last page that touches a product carries its complete, correct
|
|
28
|
+
* fingerprint. The reconcile runner keeps only the latest entry per id, so
|
|
29
|
+
* earlier, partial entries are harmless.
|
|
30
|
+
*/
|
|
31
|
+
export async function* fetchPages(context: SyncContext): AsyncIterable<Map<string, string>> {
|
|
32
|
+
const byProduct = new Map<string, string[]>();
|
|
33
|
+
for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
|
|
34
|
+
const res = await gql(context, PRICE_LIST_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: 'ASC' } } });
|
|
35
|
+
const { items, totalItems } = res.data.productVariants as {
|
|
36
|
+
items: Array<{ id: string; productId: string; price: number; priceWithTax: number; currencyCode: string }>;
|
|
37
|
+
totalItems: number;
|
|
38
|
+
};
|
|
39
|
+
total = totalItems;
|
|
40
|
+
const touched = new Set<string>();
|
|
41
|
+
for (const variant of items) {
|
|
42
|
+
const productId = String(variant.productId);
|
|
43
|
+
touched.add(productId);
|
|
44
|
+
const entries = byProduct.get(productId) ?? [];
|
|
45
|
+
byProduct.set(productId, entries);
|
|
46
|
+
entries.push(`${variant.id}:${variant.currencyCode}:${variant.price}:${variant.priceWithTax}`);
|
|
47
|
+
}
|
|
48
|
+
yield new Map([...touched].map((id) => [id, fingerprintOf(byProduct.get(id) ?? [])]));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The same fingerprint, computed from a local product document's variants. Replicated Vendure documents already carry `price`, `priceWithTax` and `currencyCode`. */
|
|
53
|
+
export function fingerprint(doc: any): string {
|
|
54
|
+
const entries: string[] = (doc.variants ?? []).map(
|
|
55
|
+
(v: any) => `${v.id}:${v.currencyCode}:${v.price}:${v.priceWithTax}`,
|
|
56
|
+
);
|
|
57
|
+
return fingerprintOf(entries);
|
|
58
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { StockReconcileAdapter } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
import { gql } from '../replication/products';
|
|
4
|
+
|
|
5
|
+
type StockLevel = { stockLocationId: string; stockOnHand: number; stockAllocated: number };
|
|
6
|
+
|
|
7
|
+
/** Admin API maximum `take`. */
|
|
8
|
+
const PAGE_SIZE = 1000;
|
|
9
|
+
|
|
10
|
+
const VARIANT_STOCK_QUERY = `
|
|
11
|
+
query VariantStock($options: ProductVariantListOptions) {
|
|
12
|
+
productVariants(options: $options) {
|
|
13
|
+
items { id stockLevels { stockLocationId stockOnHand stockAllocated } }
|
|
14
|
+
totalItems
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
/** Compared by value as sorted tuples; neither key nor level order is guaranteed. */
|
|
20
|
+
const levelsKey = (levels: StockLevel[] = []) =>
|
|
21
|
+
levels.map((l) => JSON.stringify([l.stockLocationId, l.stockOnHand, l.stockAllocated])).sort().join();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stock reconciler for Vendure products (ADR-060). Stock edits bump only the
|
|
25
|
+
* variant's updatedAt and allocations bump nothing, so replication misses them.
|
|
26
|
+
* Pages are keyed by variant id; each value is the variant's stockLevels.
|
|
27
|
+
*/
|
|
28
|
+
export const vendureStockReconcile: StockReconcileAdapter = {
|
|
29
|
+
async *fetchPages(context) {
|
|
30
|
+
for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
|
|
31
|
+
const body = await gql(context, VARIANT_STOCK_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: 'ASC' } } });
|
|
32
|
+
const { items, totalItems } = body.data.productVariants as { items: Array<{ id: string; stockLevels: StockLevel[] }>; totalItems: number };
|
|
33
|
+
total = totalItems;
|
|
34
|
+
yield new Map(items.map((v) => [v.id, v.stockLevels]));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
overlay(doc, stock) {
|
|
38
|
+
const variants: any[] = doc.variants ?? [];
|
|
39
|
+
let changed = false;
|
|
40
|
+
const next = variants.map((v) => {
|
|
41
|
+
const levels = stock.get(v.id) as StockLevel[] | undefined;
|
|
42
|
+
if (!levels || levelsKey(levels) === levelsKey(v.stockLevels)) return v;
|
|
43
|
+
changed = true;
|
|
44
|
+
return { ...v, stockLevels: levels };
|
|
45
|
+
});
|
|
46
|
+
return changed ? { variants: next } : undefined;
|
|
47
|
+
},
|
|
48
|
+
};
|