diff --git a/.changeset/charge-matching-queue-defer-suggestions.md b/.changeset/charge-matching-queue-defer-suggestions.md new file mode 100644 index 000000000..fee4b9240 --- /dev/null +++ b/.changeset/charge-matching-queue-defer-suggestions.md @@ -0,0 +1,12 @@ +--- +"@accounter/server": patch +"@accounter/client": patch +--- + +Stream charge-match suggestions to the queue with `@defer`. `ChargeWithSuggestions.suggestions` is +now a lazy field resolver (backed by a per-operation DataLoader that still builds the shared +candidate pool once), so the `BY_DATE` queue resolver returns base charges without scoring them. +The client `ChargesAwaitingMatchQueue` query wraps `suggestions` in a deferred inline fragment, so +the queue list paints immediately and each charge's suggestions stream in, with a per-card +"Scoring suggestions…" state while they load. `BY_SCORE` still evaluates up front (scores are +needed to sort) and carries the suggestions on the response. diff --git a/codegen.ts b/codegen.ts index d7048f269..c6d1f1bcc 100644 --- a/codegen.ts +++ b/codegen.ts @@ -96,6 +96,7 @@ const config: CodegenConfig = { CardFinancialAccount: '../modules/financial-accounts/types.js#IGetFinancialAccountsByOwnerIdsResult', ChargeMatch: '../modules/charges-matcher/types.js#ChargeMatchProto', + ChargeWithSuggestions: '../modules/charges-matcher/types.js#ChargeWithSuggestionsMapper', ChargeMetadata: '../modules/charges/types.js#IGetChargesByIdsResult', Client: '../modules/financial-entities/types.js#IGetAllClientsResult', ClientIntegrations: '../modules/financial-entities/types.js#IGetAllClientsResult', diff --git a/packages/client/src/components/charge-matching/index.tsx b/packages/client/src/components/charge-matching/index.tsx index dbf2c1342..fc2675755 100644 --- a/packages/client/src/components/charge-matching/index.tsx +++ b/packages/client/src/components/charge-matching/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState, type ReactElement } from 'react'; -import { Check, SkipForward } from 'lucide-react'; +import { Check, Loader2, SkipForward } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; import { useQuery } from 'urql'; import { @@ -38,10 +38,16 @@ export const QUEUE_PAGE_SIZE = 20; type QueueEntry = ChargesAwaitingMatchQueueQuery['chargesAwaitingMatchQueue']['baseCharges'][number]; +// `suggestions` is delivered via @defer, so it is absent until the deferred +// payload for that charge arrives. +type QueueEntrySuggestions = NonNullable; + export type ChargeMatchQueueItem = { id: string; baseCharge: ChargeMatchCardFieldsFragment; - suggestions: QueueEntry['suggestions']; + suggestions: QueueEntrySuggestions; + /** True until the deferred suggestions payload for this charge has arrived */ + suggestionsPending: boolean; }; export const ChargeMatchingReviewScreen = (): ReactElement => { @@ -96,7 +102,12 @@ export const ChargeMatchingReviewScreen = (): ReactElement => { () => (data?.chargesAwaitingMatchQueue.baseCharges ?? []).map(entry => { const baseCharge = getFragmentData(ChargeMatchCardFieldsFragmentDoc, entry.baseCharge); - return { id: baseCharge.id, baseCharge, suggestions: entry.suggestions }; + return { + id: baseCharge.id, + baseCharge, + suggestions: entry.suggestions ?? [], + suggestionsPending: entry.suggestions == null, + }; }), [data], ); @@ -179,7 +190,7 @@ export const ChargeMatchingReviewScreen = (): ReactElement => { )} - {fetching ? ( + {fetching && !data ? (
@@ -221,9 +232,16 @@ export const ChargeMatchingReviewScreen = (): ReactElement => { ) : (

Suggested Match

-

- {activeItem ? 'No suggestions for this charge.' : '—'} -

+ {activeItem?.suggestionsPending ? ( +

+ + Scoring suggestions… +

+ ) : ( +

+ {activeItem ? 'No suggestions for this charge.' : '—'} +

+ )}
)}
diff --git a/packages/client/src/components/charge-matching/queue-query.graphql.ts b/packages/client/src/components/charge-matching/queue-query.graphql.ts index eacc56891..6c5dbca90 100644 --- a/packages/client/src/components/charge-matching/queue-query.graphql.ts +++ b/packages/client/src/components/charge-matching/queue-query.graphql.ts @@ -71,11 +71,15 @@ baseCharge { ...ChargeMatchCardFields } - suggestions { - chargeId - confidenceScore - charge { - ...ChargeMatchCardFields + # Scored lazily on the server so the queue paints immediately; the + # suggestions stream in via @defer once evaluated. + ... on ChargeWithSuggestions @defer { + suggestions { + chargeId + confidenceScore + charge { + ...ChargeMatchCardFields + } } } } diff --git a/packages/server/src/modules/charges-matcher/__tests__/charges-awaiting-match-queue.test.ts b/packages/server/src/modules/charges-matcher/__tests__/charges-awaiting-match-queue.test.ts index 0984a6f46..624a0f192 100644 --- a/packages/server/src/modules/charges-matcher/__tests__/charges-awaiting-match-queue.test.ts +++ b/packages/server/src/modules/charges-matcher/__tests__/charges-awaiting-match-queue.test.ts @@ -224,16 +224,14 @@ describe('chargesAwaitingMatchQueue resolver', () => { }); describe('BY_DATE path', () => { - it('should evaluate scores only for the requested page (limit + offset applied before scoring)', async () => { + it('should paginate the page and defer scoring (no eager evaluation)', async () => { const charges = Array.from({ length: 10 }, (_, i) => txCharge(`charge-${i}`)); const { injector, evaluateMatchesForCharges } = createTestContext({ charges }); const result = await resolve(null, { ...baseArgs, limit: 3, offset: 4 }, { injector }, null); - expect(evaluateMatchesForCharges).toHaveBeenCalledTimes(1); - expect( - (evaluateMatchesForCharges.mock.calls[0][0] as { id: string }[]).map(c => c.id), - ).toEqual(['charge-4', 'charge-5', 'charge-6']); + // Suggestions are resolved lazily by the field resolver, not up front + expect(evaluateMatchesForCharges).not.toHaveBeenCalled(); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([ 'charge-4', 'charge-5', @@ -262,7 +260,7 @@ describe('chargesAwaitingMatchQueue resolver', () => { expect(result.baseCharges).toEqual([]); expect(result.totalCount).toBe(1); - expect(evaluateMatchesForCharges).toHaveBeenCalledWith([]); + expect(evaluateMatchesForCharges).not.toHaveBeenCalled(); }); }); diff --git a/packages/server/src/modules/charges-matcher/__tests__/queue-match-evaluator.test.ts b/packages/server/src/modules/charges-matcher/__tests__/queue-match-evaluator.test.ts index 078e5a249..225b75e0b 100644 --- a/packages/server/src/modules/charges-matcher/__tests__/queue-match-evaluator.test.ts +++ b/packages/server/src/modules/charges-matcher/__tests__/queue-match-evaluator.test.ts @@ -104,4 +104,40 @@ describe('QueueMatchEvaluatorProvider', () => { expect(result.baseCharge).toBe(baseCharge); }); }); + + describe('suggestionsByChargeIdLoader', () => { + it('should coalesce concurrent loads into a single batch and sort each result', async () => { + const { provider, chargesMatcherMock } = createProvider(async chargeIds => { + return new Map( + chargeIds.map(chargeId => [ + chargeId, + [ + { chargeId: `${chargeId}-low`, confidenceScore: 0.2 }, + { chargeId: `${chargeId}-high`, confidenceScore: 0.8 }, + ], + ]), + ); + }); + + const [first, second] = await Promise.all([ + provider.suggestionsByChargeIdLoader.load('charge-1'), + provider.suggestionsByChargeIdLoader.load('charge-2'), + ]); + + // Single shared-pool call for both charges + expect(chargesMatcherMock.findMatchesForCharges).toHaveBeenCalledTimes(1); + expect(chargesMatcherMock.findMatchesForCharges).toHaveBeenCalledWith(['charge-1', 'charge-2']); + // Each result sorted by confidence, highest first + expect(first.map(s => s.chargeId)).toEqual(['charge-1-high', 'charge-1-low']); + expect(second.map(s => s.chargeId)).toEqual(['charge-2-high', 'charge-2-low']); + }); + + it('should return an empty list for a charge missing from the matcher result', async () => { + const { provider } = createProvider(async () => new Map()); + + const result = await provider.suggestionsByChargeIdLoader.load('charge-1'); + + expect(result).toEqual([]); + }); + }); }); diff --git a/packages/server/src/modules/charges-matcher/providers/queue-match-evaluator.provider.ts b/packages/server/src/modules/charges-matcher/providers/queue-match-evaluator.provider.ts index 60533b014..6968caadd 100644 --- a/packages/server/src/modules/charges-matcher/providers/queue-match-evaluator.provider.ts +++ b/packages/server/src/modules/charges-matcher/providers/queue-match-evaluator.provider.ts @@ -6,6 +6,7 @@ * no DB mutations happen here. */ +import DataLoader from 'dataloader'; import { Injectable, Scope } from 'graphql-modules'; import type { ChargeMatchProto } from '../types.js'; import { ChargesMatcherProvider } from './charges-matcher.provider.js'; @@ -57,4 +58,29 @@ export class QueueMatchEvaluatorProvider { ), })); } + + /** + * DataLoader for lazily resolving a single charge's match suggestions. + * + * Backs the `ChargeWithSuggestions.suggestions` field resolver so the queue can + * return base charges immediately and stream suggestions in via `@defer`. All + * suggestions requested within the operation are coalesced into a single + * `findMatchesForCharges` call, so the shared candidate pool is still built once. + */ + public suggestionsByChargeIdLoader = new DataLoader( + chargeIds => this.batchLoadSuggestions(chargeIds), + { name: 'suggestionsByChargeIdLoader' }, + ); + + private async batchLoadSuggestions(chargeIds: readonly string[]): Promise { + const matchesByChargeId = await this.chargesMatcherProvider.findMatchesForCharges([ + ...chargeIds, + ]); + return chargeIds.map(chargeId => + // Copy before sorting so we never mutate an array owned by another provider + [...(matchesByChargeId.get(chargeId) ?? [])].sort( + (a, b) => b.confidenceScore - a.confidenceScore, + ), + ); + } } diff --git a/packages/server/src/modules/charges-matcher/resolvers/charges-awaiting-match-queue.resolver.ts b/packages/server/src/modules/charges-matcher/resolvers/charges-awaiting-match-queue.resolver.ts index 34ec6e180..42a6681da 100644 --- a/packages/server/src/modules/charges-matcher/resolvers/charges-awaiting-match-queue.resolver.ts +++ b/packages/server/src/modules/charges-matcher/resolvers/charges-awaiting-match-queue.resolver.ts @@ -39,7 +39,9 @@ export const chargesAwaitingMatchQueueResolver: ChargesMatcherModule.Resolvers = if (sortBy === 'BY_SCORE') { // Evaluate the capped window of most recent charges, sort the whole - // derived list by top confidence score, and paginate that list + // derived list by top confidence score, and paginate that list. Scores + // are required to sort, so they are computed here and carried on each + // item (the suggestions field resolver returns them directly). const window = queue.slice(0, BY_SCORE_EVALUATION_CAP); const evaluated = await evaluator.evaluateMatchesForCharges(window); const topScore = (suggestions: { confidenceScore: number }[]): number => @@ -53,12 +55,13 @@ export const chargesAwaitingMatchQueueResolver: ChargesMatcherModule.Resolvers = }; } - // BY_DATE (default): paginate first, then lazily evaluate scores for - // the requested page only + // BY_DATE (default): paginate first and return the base charges without + // scoring them. Suggestions are resolved lazily by the + // ChargeWithSuggestions.suggestions field resolver, so the client can + // @defer them and paint the queue immediately. const page = queue.slice(safeOffset, safeOffset + safeLimit); - const baseCharges = await evaluator.evaluateMatchesForCharges(page); return { - baseCharges, + baseCharges: page.map(baseCharge => ({ id: baseCharge.id, baseCharge })), totalCount: queue.length, }; } catch (e) { diff --git a/packages/server/src/modules/charges-matcher/resolvers/index.ts b/packages/server/src/modules/charges-matcher/resolvers/index.ts index 59121e47a..3270139b9 100644 --- a/packages/server/src/modules/charges-matcher/resolvers/index.ts +++ b/packages/server/src/modules/charges-matcher/resolvers/index.ts @@ -1,4 +1,5 @@ import { ChargesProvider } from '../../charges/providers/charges.provider.js'; +import { QueueMatchEvaluatorProvider } from '../providers/queue-match-evaluator.provider.js'; import type { ChargesMatcherModule } from '../types.js'; import { autoMatchChargesResolver } from './auto-match-charges.resolver.js'; import { chargesAwaitingMatchQueueResolver } from './charges-awaiting-match-queue.resolver.js'; @@ -24,4 +25,12 @@ export const chargesMatcherResolvers: ChargesMatcherModule.Resolvers = { return res; }), }, + ChargeWithSuggestions: { + // Lazily scored so the queue can return base charges immediately and stream + // suggestions via @defer. `BY_SCORE` precomputes them (needed for the sort) + // and carries them on the parent, so we return those directly when present. + suggestions: (parent, _args, { injector }) => + parent.suggestions ?? + injector.get(QueueMatchEvaluatorProvider).suggestionsByChargeIdLoader.load(parent.id), + }, }; diff --git a/packages/server/src/modules/charges-matcher/types.ts b/packages/server/src/modules/charges-matcher/types.ts index ea77278d6..d26bd8d0a 100644 --- a/packages/server/src/modules/charges-matcher/types.ts +++ b/packages/server/src/modules/charges-matcher/types.ts @@ -1,3 +1,4 @@ +import type { IGetChargesByFiltersResult } from '../charges/types.js'; import type { currency, document_type, IGetAllDocumentsResult } from '../documents/types.js'; import type { IGetTransactionsByIdsResult } from '../transactions/types.js'; @@ -31,6 +32,24 @@ export interface ChargeMatchesResult { matches: ChargeMatchProto[]; } +/** + * GraphQL mapper for the `ChargeWithSuggestions` type. + * + * `suggestions` is optional so the queue resolver can return base charges without + * eagerly scoring them: the `ChargeWithSuggestions.suggestions` field resolver + * computes them lazily (enabling `@defer` on the client). When the queue is sorted + * `BY_SCORE` the suggestions are precomputed (needed for the sort) and carried here + * so the field resolver can return them directly. + */ +export interface ChargeWithSuggestionsMapper { + /** UUID of the unmatched base charge */ + id: string; + /** The unmatched base charge under review (enriched charge row) */ + baseCharge: IGetChargesByFiltersResult; + /** Precomputed match suggestions, when already evaluated (e.g. BY_SCORE sort) */ + suggestions?: ChargeMatchProto[]; +} + /** * Represents a charge that was successfully merged during auto-match */