5291806c37
Migrants arrive with vouchers, not bank transactions, so the bank-keyed ledger context is empty for them. This adds the description-keyed twin. - public.ledger_key(text): legibility key on top of the frozen normalize_counterparty_key mirror: strips AP-register prefixes (levfakt, leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier number that follows them, and trailing 1-3 digit runs, never "inköp". Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared fixture list in the pg test. - public.get_observed_parties(company, from_date, limit): posted vouchers grouped by ledger_key(description) with occurrences, variants, expense and revenue SEK from the lines, first/last seen, median cadence and the Laplace-smoothed dominant result account. Excludes storno, opening balance, year-end and VAT settlement, and vouchers that carry a bank merchant name (those stay with get_ledger_deep_context). SECURITY INVOKER, so RLS scopes it. Never stored. - lib/parties/classify.ts: the deterministic pre-classifier moved out of the evaluation script so product and evaluation share one implementation (0.965 agreement with the founder labels, party recall 0.99). - lib/parties/observed.ts: RPC wrapper that classifies each row and derives a display rhythm from the cadence. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
67 lines
2.5 KiB
TypeScript
67 lines
2.5 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { classifyKey, type PartyLabel } from './classify'
|
|
|
|
/**
|
|
* Observed parties: counterparts derived from posted vouchers, never stored.
|
|
* Backed by the get_observed_parties RPC (migration 20260902170000), the
|
|
* description-keyed twin of get_ledger_deep_context for companies whose
|
|
* history arrived by SIE import rather than bank feed.
|
|
*
|
|
* Each row is classified by the deterministic pre-classifier so callers can
|
|
* route it: parties go to the register and the resolver, categories to the
|
|
* "utan namn i texten" band, payroll and adjustments nowhere.
|
|
*/
|
|
export interface ObservedPartyRow {
|
|
key: string
|
|
name: string
|
|
variants: string[]
|
|
variant_count: number
|
|
occurrences: number
|
|
expense_sek: number
|
|
revenue_sek: number
|
|
first_seen: string
|
|
last_seen: string
|
|
cadence_days: number | null
|
|
dominant_account_number: string | null
|
|
dominant_account_share: number | null
|
|
dominant_account_count: number | null
|
|
dominant_account_total: number | null
|
|
}
|
|
|
|
export interface ObservedParty extends ObservedPartyRow {
|
|
label: PartyLabel
|
|
/** Rough rhythm bucket derived from the median gap, for display. */
|
|
rhythm: 'weekly' | 'monthly' | 'quarterly' | 'yearly' | 'irregular' | null
|
|
}
|
|
|
|
export function rhythmFromCadence(cadenceDays: number | null): ObservedParty['rhythm'] {
|
|
if (cadenceDays === null) return null
|
|
if (cadenceDays >= 5 && cadenceDays <= 9) return 'weekly'
|
|
if (cadenceDays >= 25 && cadenceDays <= 35) return 'monthly'
|
|
if (cadenceDays >= 80 && cadenceDays <= 100) return 'quarterly'
|
|
if (cadenceDays >= 340 && cadenceDays <= 390) return 'yearly'
|
|
return 'irregular'
|
|
}
|
|
|
|
export async function getObservedParties(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
options: { fromDate?: string | null; limit?: number; labels?: PartyLabel[] } = {},
|
|
): Promise<ObservedParty[]> {
|
|
const { data, error } = await supabase.rpc('get_observed_parties', {
|
|
p_company_id: companyId,
|
|
p_from_date: options.fromDate ?? null,
|
|
p_limit: options.limit ?? 200,
|
|
})
|
|
if (error) throw new Error(`get_observed_parties failed: ${error.message}`)
|
|
const rows = (Array.isArray(data) ? data : []) as ObservedPartyRow[]
|
|
const wanted = options.labels ? new Set(options.labels) : null
|
|
const out: ObservedParty[] = []
|
|
for (const row of rows) {
|
|
const label = classifyKey({ key: row.key, acct: row.dominant_account_number })
|
|
if (wanted && !wanted.has(label)) continue
|
|
out.push({ ...row, label, rhythm: rhythmFromCadence(row.cadence_days) })
|
|
}
|
|
return out
|
|
}
|