8dde46ad96
* fix(db): reconcile prod-orphaned migrations blocking Supabase branching Prod's schema_migrations carries three versions with no committed file on main, leaving the default Supabase branch in MIGRATIONS_FAILED and stopping preview branches from being created: 20260707113729 add_transactions_enrichment (adopted from #927) 20260708120000 ledger_stats_committed_at_lag (adopted from #935) 20260708130000 ledger_deep_context (adopted from #935) Adopt the byte-identical SQL under the exact apply-time versions, plus the matching pg-tests and fixtures for the two RPCs so pg-real stays green: 20260708120000 switches get_ledger_usage_stats' median_booking_lag_days to committed_at, so the existing test now asserts the new behavior. Idempotent (ADD COLUMN IF NOT EXISTS / CREATE OR REPLACE FUNCTION): no-op on prod, clean on fresh replays, no-op on #927/#935's next rebase. The knowledge-page UI/lib/i18n stay in #935. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deps): pin @anthropic-ai/bedrock-sdk to 0.29.1 0.32.0 (grouped dependabot bump #884) broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks", taking down the in-app AI assistant and invoice OCR. Local dev ran the stale 0.29.1 in node_modules, so it only failed on deploys built fresh from the lockfile. Revert to the six-week-stable 0.29.1; creds/region were never the cause (proven AKIA key + eu-west-1). Guard against an accidental re-bump three ways: exact pin (no caret), a dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock. See DECISIONS.md.
261 lines
9.9 KiB
PL/PgSQL
261 lines
9.9 KiB
PL/PgSQL
-- Fix: median_booking_lag_days measures real posting promptness (committed_at),
|
|
-- not the accounting date (entry_date).
|
|
--
|
|
-- The prior version computed median(entry_date - transaction.date). But the
|
|
-- bank-booking flow dates the voucher (entry_date) TO the transaction date, so
|
|
-- entry_date - date is ~0 by construction: a tautology, not a signal. Verified
|
|
-- on prod (company ed461bc1...): entry_date == transaction date for 151 of 152
|
|
-- booked transactions, so the median came out 0, while the REAL lag,
|
|
-- committed_at (when the voucher was actually posted) minus the transaction
|
|
-- date, had median 90 days. This switches the metric to committed_at, the
|
|
-- honest "how promptly does this company book" signal the field was meant to
|
|
-- be. committed_at is set by set_committed_at() on draft->posted and is
|
|
-- non-null for posted entries; the IS NOT NULL guard is belt-and-braces.
|
|
--
|
|
-- Everything else is unchanged from 20260708110000 (26xx dominant-contra
|
|
-- exclusion, storno filter, normalize_counterparty_key keying). Full
|
|
-- CREATE OR REPLACE, no signature change.
|
|
--
|
|
-- pg-test: tests/pg/ledger-usage-stats-rpc.pg.test.ts
|
|
|
|
CREATE OR REPLACE FUNCTION public.get_ledger_usage_stats(
|
|
p_company_id uuid,
|
|
p_from_date date
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
SELECT jsonb_build_object(
|
|
'account_usage',
|
|
(
|
|
SELECT coalesce(
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'account_number', au.account_number,
|
|
'account_name', au.account_name,
|
|
'postings', au.postings,
|
|
'last_used', au.last_used
|
|
)
|
|
ORDER BY au.postings DESC, au.account_number
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM (
|
|
SELECT
|
|
l.account_number,
|
|
max(coa.account_name) AS account_name,
|
|
count(*)::bigint AS postings,
|
|
max(je.entry_date) AS last_used
|
|
FROM public.journal_entry_lines l
|
|
JOIN public.journal_entries je ON je.id = l.journal_entry_id
|
|
LEFT JOIN public.chart_of_accounts coa
|
|
ON coa.company_id = p_company_id
|
|
AND coa.account_number = l.account_number
|
|
WHERE je.company_id = p_company_id
|
|
AND je.status = 'posted'
|
|
-- Stornos annul: counting their swapped lines re-inflates the
|
|
-- account the correction moved away from. Corrections stay.
|
|
AND je.source_type <> 'storno'
|
|
AND je.entry_date >= p_from_date
|
|
GROUP BY l.account_number
|
|
ORDER BY count(*) DESC, l.account_number
|
|
LIMIT 20
|
|
) au
|
|
),
|
|
'counterparty_patterns',
|
|
(
|
|
WITH booked AS (
|
|
SELECT
|
|
public.normalize_counterparty_key(t.merchant_name) AS counterparty_key,
|
|
t.merchant_name,
|
|
t.category,
|
|
t.journal_entry_id,
|
|
t.date
|
|
FROM public.transactions t
|
|
JOIN public.journal_entries je ON je.id = t.journal_entry_id
|
|
WHERE t.company_id = p_company_id
|
|
AND t.journal_entry_id IS NOT NULL
|
|
AND je.status = 'posted'
|
|
-- Defensive: no code path should link a transaction to a storno
|
|
-- (correctEntry relinks to the correction, reverseEntry unlinks),
|
|
-- but legacy rows may predate the unlink behavior. Corrections are
|
|
-- deliberately NOT excluded: they are the live booking.
|
|
AND je.source_type <> 'storno'
|
|
AND t.merchant_name IS NOT NULL
|
|
AND trim(t.merchant_name) <> ''
|
|
AND t.date >= p_from_date
|
|
),
|
|
keyed AS (
|
|
-- All-digit/reference-only merchant labels normalize to '': no
|
|
-- identity, no pattern.
|
|
SELECT * FROM booked WHERE counterparty_key <> ''
|
|
),
|
|
totals AS (
|
|
SELECT
|
|
counterparty_key,
|
|
mode() WITHIN GROUP (ORDER BY merchant_name) AS display_name,
|
|
count(*)::bigint AS occurrences,
|
|
max(date) AS last_booked
|
|
FROM keyed
|
|
GROUP BY counterparty_key
|
|
),
|
|
dominant_category AS (
|
|
SELECT DISTINCT ON (counterparty_key)
|
|
counterparty_key,
|
|
category,
|
|
cnt
|
|
FROM (
|
|
SELECT counterparty_key, category, count(*)::bigint AS cnt
|
|
FROM keyed
|
|
WHERE category IS NOT NULL AND category <> 'uncategorized'
|
|
GROUP BY counterparty_key, category
|
|
) c
|
|
ORDER BY counterparty_key, cnt DESC, category
|
|
),
|
|
dominant_account AS (
|
|
SELECT DISTINCT ON (counterparty_key)
|
|
counterparty_key,
|
|
account_number
|
|
FROM (
|
|
SELECT b.counterparty_key, l.account_number, count(*)::bigint AS cnt
|
|
FROM keyed b
|
|
JOIN public.journal_entry_lines l ON l.journal_entry_id = b.journal_entry_id
|
|
WHERE l.account_number NOT LIKE '19%'
|
|
-- Exclude VAT accounts (26xx): on a reverse-charge purchase the
|
|
-- expense, 2645 and 2614 lines tie, and the account_number
|
|
-- tiebreak would otherwise pick the low VAT number over the
|
|
-- expense. 26xx is always moms, never the informative contra.
|
|
AND l.account_number NOT LIKE '26%'
|
|
GROUP BY b.counterparty_key, l.account_number
|
|
) a
|
|
ORDER BY counterparty_key, cnt DESC, account_number
|
|
)
|
|
SELECT coalesce(
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'counterparty', t.display_name,
|
|
'counterparty_key', t.counterparty_key,
|
|
'occurrences', t.occurrences,
|
|
'last_booked', t.last_booked,
|
|
'dominant_category', dc.category,
|
|
'dominant_category_count', coalesce(dc.cnt, 0),
|
|
'dominant_account_number', da.account_number
|
|
)
|
|
ORDER BY t.occurrences DESC, t.display_name
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM (
|
|
SELECT * FROM totals ORDER BY occurrences DESC, display_name LIMIT 25
|
|
) t
|
|
LEFT JOIN dominant_category dc ON dc.counterparty_key = t.counterparty_key
|
|
LEFT JOIN dominant_account da ON da.counterparty_key = t.counterparty_key
|
|
),
|
|
'supplier_patterns',
|
|
(
|
|
-- AP-side booking patterns: bank-transaction patterns only see rows
|
|
-- with a merchant_name, so an invoice-heavy company would be half
|
|
-- blind without this. Supplier identity here is exact (FK), no
|
|
-- normalization needed.
|
|
WITH sinv AS (
|
|
SELECT si.id, si.supplier_id, s.name AS supplier_name,
|
|
si.invoice_date, si.vat_treatment
|
|
FROM public.supplier_invoices si
|
|
JOIN public.suppliers s ON s.id = si.supplier_id
|
|
WHERE si.company_id = p_company_id
|
|
AND si.invoice_date >= p_from_date
|
|
-- Reversed bookings and credited invoices are undone business;
|
|
-- credit notes repeat their original's accounts with flipped sign.
|
|
AND si.status NOT IN ('reversed', 'credited')
|
|
AND si.is_credit_note = false
|
|
),
|
|
totals AS (
|
|
SELECT
|
|
supplier_id,
|
|
max(supplier_name) AS supplier_name,
|
|
count(*)::bigint AS invoices,
|
|
max(invoice_date) AS last_invoice,
|
|
mode() WITHIN GROUP (ORDER BY vat_treatment) AS dominant_vat
|
|
FROM sinv
|
|
GROUP BY supplier_id
|
|
),
|
|
dominant_account AS (
|
|
-- Invoices (not lines) touching each account, so a many-line invoice
|
|
-- does not outvote ten single-line ones.
|
|
SELECT DISTINCT ON (supplier_id)
|
|
supplier_id,
|
|
account_number,
|
|
cnt
|
|
FROM (
|
|
SELECT v.supplier_id, i.account_number, count(DISTINCT v.id)::bigint AS cnt
|
|
FROM sinv v
|
|
JOIN public.supplier_invoice_items i ON i.supplier_invoice_id = v.id
|
|
GROUP BY v.supplier_id, i.account_number
|
|
) a
|
|
ORDER BY supplier_id, cnt DESC, account_number
|
|
)
|
|
SELECT coalesce(
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'supplier', t.supplier_name,
|
|
'invoices', t.invoices,
|
|
'last_invoice', t.last_invoice,
|
|
'vat_treatment', t.dominant_vat,
|
|
'dominant_account_number', da.account_number,
|
|
'dominant_account_count', coalesce(da.cnt, 0)
|
|
)
|
|
ORDER BY t.invoices DESC, t.supplier_name
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM (
|
|
SELECT * FROM totals ORDER BY invoices DESC, supplier_name LIMIT 15
|
|
) t
|
|
LEFT JOIN dominant_account da ON da.supplier_id = t.supplier_id
|
|
),
|
|
'vat_treatments_used',
|
|
(
|
|
SELECT coalesce(jsonb_agg(DISTINCT vt), '[]'::jsonb)
|
|
FROM (
|
|
SELECT i.vat_treatment AS vt
|
|
FROM public.invoices i
|
|
WHERE i.company_id = p_company_id
|
|
AND i.invoice_date >= p_from_date
|
|
AND i.vat_treatment IS NOT NULL
|
|
UNION
|
|
SELECT si.vat_treatment AS vt
|
|
FROM public.supplier_invoices si
|
|
WHERE si.company_id = p_company_id
|
|
AND si.invoice_date >= p_from_date
|
|
AND si.vat_treatment IS NOT NULL
|
|
) treatments
|
|
),
|
|
'median_booking_lag_days',
|
|
(
|
|
-- committed_at (when the voucher was POSTED), not entry_date (the
|
|
-- accounting date the bank flow sets to the transaction date, making
|
|
-- entry_date - date a ~0 tautology). This is real posting promptness.
|
|
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY (je.committed_at::date - t.date))
|
|
FROM public.transactions t
|
|
JOIN public.journal_entries je ON je.id = t.journal_entry_id
|
|
WHERE t.company_id = p_company_id
|
|
AND je.status = 'posted'
|
|
-- A legacy transaction still linked to a storno (a reversal posted
|
|
-- long after the transaction) would inject a spurious large lag into
|
|
-- the very metric this migration exists to make honest. Exclude it,
|
|
-- matching account_usage and counterparty_patterns.
|
|
AND je.source_type <> 'storno'
|
|
AND je.committed_at IS NOT NULL
|
|
AND t.date >= p_from_date
|
|
)
|
|
);
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.get_ledger_usage_stats(uuid, date) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.get_ledger_usage_stats(uuid, date) TO authenticated, service_role;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|