feat(dimensions): voucher-level retro-tagging workbench — reversal pairs hidden by default (#874)
* feat(dimensions): voucher-level retro-tagging workbench, reversal pairs hidden by default UX rework of the BulkTagWorkbench (follow-up to #867): the verifikat is now the unit of work, matching how users think ('that invoice belongs to project X') and how every Swedish bookkeeping tool presents entries. - /api/dimensions/tagging/lines returns voucher-grouped results via a two-step query: filters select QUALIFYING vouchers (line-level predicates become 'voucher has such a line' through the inner join), then the complete line set for each — tagging a voucher always covers the whole verifikat, never the filtered subset of one - reversal pairs are EXCLUDED by default: an annulled entry and its storno net to zero in every dimension bucket as long as both sides carry the same tag, so retro-tagging them is a no-op with an asymmetry foot-gun attached; 'Visa annullerade' opts them back in, and the blocking motverifikat confirmation survives only in that view (correction rebooks remain taggable — only the original+storno pair is hidden) - voucher rows: label, description, date, line count, distinct tag chips + 'Delvis taggad' state, single total amount (no debit/credit columns); chevron expands to per-line rows (signed amount, per-line checkboxes) for the mixed case (a voucher split across projects) - selection stays line-id based under the hood (the retag RPC is per line); shift-click ranges operate on vouchers; apply groups by resulting map and now chunks to the apply route's 500-line cap; failed vouchers auto-expand with their Swedish RPC errors inline - 'endast otaggade' now means 'vouchers with at least one untagged line'; the cap counts vouchers (default 150, max 300) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pull counter-vouchers into the annulled view even outside filters (review) Without this, a pair leg whose counter fell outside the date range would show no motverifikat warning at all — one-sided tagging would slip through silently, exactly the Srf U 14 skew the guard exists to prevent. Also scope the line fetch explicitly through the parent company filter (defense in depth). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
45438d4d64
commit
28827c2613
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Tests for GET /api/dimensions/tagging/lines (bulk retro-tagging browser).
|
||||
* Tests for GET /api/dimensions/tagging/lines (bulk retro-tagging browser,
|
||||
* voucher-level rework).
|
||||
*
|
||||
* Covers: 401, query validation (400), the happy path (flattened DTO,
|
||||
* date-sorted, total_capped false), the hard-cap contract (limit+1 fetch →
|
||||
* total_capped true), and the DB error path.
|
||||
* Covers: 401, query validation (400), the voucher-grouped happy path (two
|
||||
* queries: qualifying entries → complete line sets), the hard-cap contract
|
||||
* (limit+1 entries → total_capped true, second query only for the page), the
|
||||
* empty result short-circuit (no line query), and the DB error paths.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -33,25 +35,46 @@ const noParams = { params: Promise.resolve({}) }
|
||||
const request = (searchParams?: Record<string, string>) =>
|
||||
createMockRequest('/api/dimensions/tagging/lines', { searchParams })
|
||||
|
||||
interface FlatLine {
|
||||
interface VoucherLine {
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
dimensions: Record<string, string>
|
||||
}
|
||||
|
||||
interface Voucher {
|
||||
journal_entry_id: string
|
||||
entry_date: string
|
||||
voucher_number: number | null
|
||||
voucher_series: string | null
|
||||
description: string
|
||||
annulled: boolean
|
||||
reversed_by_id: string | null
|
||||
reverses_id: string | null
|
||||
fiscal_period_id: string
|
||||
lines: VoucherLine[]
|
||||
}
|
||||
|
||||
type LinesBody = { data: { lines: FlatLine[]; total_capped: boolean } }
|
||||
type VouchersBody = { data: { vouchers: Voucher[]; total_capped: boolean } }
|
||||
|
||||
/** Raw row as the Supabase select returns it (nested journal_entries). */
|
||||
/** Raw entry row as the Supabase step-1 select returns it. */
|
||||
function makeRawEntry(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'entry-1',
|
||||
entry_date: '2026-03-10',
|
||||
voucher_number: 42,
|
||||
voucher_series: 'A',
|
||||
description: 'Inköp material',
|
||||
reversed_by_id: null,
|
||||
reverses_id: null,
|
||||
fiscal_period_id: 'period-1',
|
||||
journal_entry_lines: [{ id: 'line-1' }],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw line row as the Supabase step-2 select returns it. */
|
||||
function makeRawLine(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'line-1',
|
||||
@@ -60,15 +83,7 @@ function makeRawLine(overrides: Record<string, unknown> = {}) {
|
||||
credit_amount: 0,
|
||||
dimensions: { '1': 'KS01' },
|
||||
journal_entry_id: 'entry-1',
|
||||
journal_entries: {
|
||||
entry_date: '2026-03-10',
|
||||
voucher_number: 42,
|
||||
voucher_series: 'A',
|
||||
description: 'Inköp material',
|
||||
reversed_by_id: null,
|
||||
reverses_id: null,
|
||||
fiscal_period_id: 'period-1',
|
||||
},
|
||||
sort_order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -108,89 +123,166 @@ describe('GET /api/dimensions/tagging/lines', () => {
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns flattened lines sorted by entry date, total_capped false', async () => {
|
||||
it('groups complete line sets under their vouchers', async () => {
|
||||
// Step 1: qualifying entries (annulled linkage rides along for the pair
|
||||
// guard in the opt-in view).
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawLine({
|
||||
id: 'line-2',
|
||||
journal_entries: {
|
||||
entry_date: '2026-04-01',
|
||||
voucher_number: 50,
|
||||
voucher_series: 'A',
|
||||
description: 'Senare verifikat',
|
||||
reversed_by_id: 'entry-9',
|
||||
reverses_id: null,
|
||||
fiscal_period_id: 'period-1',
|
||||
},
|
||||
makeRawEntry(),
|
||||
makeRawEntry({
|
||||
id: 'entry-2',
|
||||
entry_date: '2026-04-01',
|
||||
voucher_number: 50,
|
||||
description: 'Senare verifikat',
|
||||
reversed_by_id: 'entry-9',
|
||||
}),
|
||||
makeRawLine({ id: 'line-1', dimensions: {} }),
|
||||
],
|
||||
})
|
||||
// Step 2: the COMPLETE line sets — entry-1 has a line the account filter
|
||||
// would not have matched; it must still be present (whole-verifikat
|
||||
// contract, null dimensions normalized to {}).
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawLine(),
|
||||
makeRawLine({ id: 'line-2', account_number: '1930', debit_amount: 0, credit_amount: 100, dimensions: null, sort_order: 1 }),
|
||||
makeRawLine({ id: 'line-3', journal_entry_id: 'entry-2', dimensions: {} }),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<LinesBody>(response)
|
||||
const { status, body } = await parseJsonResponse<VouchersBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.total_capped).toBe(false)
|
||||
expect(body.data.lines).toHaveLength(2)
|
||||
// Sorted by entry_date: line-1 (2026-03-10) before line-2 (2026-04-01).
|
||||
expect(body.data.lines[0]).toMatchObject({
|
||||
id: 'line-1',
|
||||
account_number: '4010',
|
||||
debit_amount: 100,
|
||||
credit_amount: 0,
|
||||
dimensions: {},
|
||||
expect(body.data.vouchers).toHaveLength(2)
|
||||
|
||||
const [first, second] = body.data.vouchers
|
||||
expect(first).toMatchObject({
|
||||
journal_entry_id: 'entry-1',
|
||||
entry_date: '2026-03-10',
|
||||
voucher_number: 42,
|
||||
voucher_series: 'A',
|
||||
description: 'Inköp material',
|
||||
fiscal_period_id: 'period-1',
|
||||
annulled: false,
|
||||
})
|
||||
// Reversal linkage rides along for the storno-pair warning.
|
||||
expect(body.data.lines[1].reversed_by_id).toBe('entry-9')
|
||||
expect(first.lines).toHaveLength(2)
|
||||
expect(first.lines[0]).toMatchObject({ id: 'line-1', dimensions: { '1': 'KS01' } })
|
||||
expect(first.lines[1]).toMatchObject({ id: 'line-2', account_number: '1930', dimensions: {} })
|
||||
|
||||
expect(second.annulled).toBe(true)
|
||||
expect(second.reversed_by_id).toBe('entry-9')
|
||||
expect(second.lines.map((l) => l.id)).toEqual(['line-3'])
|
||||
})
|
||||
|
||||
it('normalizes a null dimensions map to {}', async () => {
|
||||
enqueue({ data: [makeRawLine({ dimensions: null })] })
|
||||
|
||||
const response = await GET(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<LinesBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.lines[0].dimensions).toEqual({})
|
||||
})
|
||||
|
||||
it('caps the result at limit and reports total_capped', async () => {
|
||||
// limit=2 → route fetches 3; a third row means "there is more".
|
||||
it('caps the result at limit vouchers and reports total_capped', async () => {
|
||||
// limit=2 → route fetches 3 entries; a third means "there is more".
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawLine({ id: 'line-1' }),
|
||||
makeRawLine({ id: 'line-2' }),
|
||||
makeRawLine({ id: 'line-3' }),
|
||||
makeRawEntry({ id: 'entry-1' }),
|
||||
makeRawEntry({ id: 'entry-2' }),
|
||||
makeRawEntry({ id: 'entry-3' }),
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawLine({ id: 'line-1', journal_entry_id: 'entry-1' }),
|
||||
makeRawLine({ id: 'line-2', journal_entry_id: 'entry-2' }),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(request({ limit: '2' }), noParams)
|
||||
const { status, body } = await parseJsonResponse<LinesBody>(response)
|
||||
const { status, body } = await parseJsonResponse<VouchersBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.lines).toHaveLength(2)
|
||||
expect(body.data.vouchers).toHaveLength(2)
|
||||
expect(body.data.vouchers.map((v) => v.journal_entry_id)).toEqual(['entry-1', 'entry-2'])
|
||||
expect(body.data.total_capped).toBe(true)
|
||||
})
|
||||
|
||||
it('returns an empty list when nothing matches', async () => {
|
||||
it('short-circuits an empty entry page without a line query', async () => {
|
||||
enqueue({ data: [] })
|
||||
|
||||
const response = await GET(request({ only_untagged: '1' }), noParams)
|
||||
const { status, body } = await parseJsonResponse<LinesBody>(response)
|
||||
const { status, body } = await parseJsonResponse<VouchersBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.lines).toEqual([])
|
||||
expect(body.data.vouchers).toEqual([])
|
||||
expect(body.data.total_capped).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 500 when the query fails', async () => {
|
||||
it('include_annulled=1 pulls in a counter-voucher that fell outside the filters', async () => {
|
||||
// Step 1 returns only the storno leg (its original, entry-0, is outside
|
||||
// the date range). The route must fetch entry-0 anyway — otherwise the
|
||||
// workbench's motverifikat guard cannot see the missing leg and one-sided
|
||||
// tagging slips through silently.
|
||||
enqueue({ data: [makeRawEntry({ reverses_id: 'entry-0', entry_date: '2026-05-01' })] })
|
||||
// Counter-voucher fetch by id.
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawEntry({
|
||||
id: 'entry-0',
|
||||
entry_date: '2026-02-01',
|
||||
voucher_number: 40,
|
||||
description: 'Original',
|
||||
reversed_by_id: 'entry-1',
|
||||
}),
|
||||
],
|
||||
})
|
||||
// Complete line sets for BOTH vouchers.
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawLine({ id: 'line-0', journal_entry_id: 'entry-0' }),
|
||||
makeRawLine(),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(request({ include_annulled: '1' }), noParams)
|
||||
const { status, body } = await parseJsonResponse<VouchersBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.vouchers).toHaveLength(2)
|
||||
// Sorted by entry_date: the pulled-in original first.
|
||||
expect(body.data.vouchers[0]).toMatchObject({
|
||||
journal_entry_id: 'entry-0',
|
||||
annulled: true,
|
||||
lines: [{ id: 'line-0' }],
|
||||
})
|
||||
expect(body.data.vouchers[1].annulled).toBe(true)
|
||||
})
|
||||
|
||||
it('include_annulled=1 skips the counter fetch when both legs already qualified', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawEntry({ reversed_by_id: 'entry-2' }),
|
||||
makeRawEntry({ id: 'entry-2', reverses_id: 'entry-1', voucher_number: 43 }),
|
||||
],
|
||||
})
|
||||
// No counter query — next enqueued result is the line fetch.
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawLine(),
|
||||
makeRawLine({ id: 'line-2', journal_entry_id: 'entry-2' }),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(request({ include_annulled: '1' }), noParams)
|
||||
const { status, body } = await parseJsonResponse<VouchersBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.vouchers).toHaveLength(2)
|
||||
expect(body.data.vouchers.every((v) => v.annulled)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 500 when the entry query fails', async () => {
|
||||
enqueue({ error: { message: 'relation missing' } })
|
||||
|
||||
const response = await GET(request(), noParams)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
|
||||
it('returns 500 when the line query fails', async () => {
|
||||
enqueue({ data: [makeRawEntry()] })
|
||||
enqueue({ error: { message: 'relation missing' } })
|
||||
|
||||
const response = await GET(request(), noParams)
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* GET /api/dimensions/tagging/lines — posted journal-entry lines for the bulk
|
||||
* retro-tagging workbench (dimensions plan PR6 §3).
|
||||
* GET /api/dimensions/tagging/lines — posted VOUCHERS (with their complete
|
||||
* line sets) for the bulk retro-tagging workbench (dimensions plan PR6 §3,
|
||||
* voucher-level rework).
|
||||
*
|
||||
* Read-only line browser: filter by period, entry-date range, account range,
|
||||
* free text (ilike on the entry description) and "only untagged" (empty
|
||||
* dimensions map). Hard cap instead of pagination for v1 — the route fetches
|
||||
* limit+1 rows and reports `total_capped: true` so the UI can show a
|
||||
* "narrow your filter" notice.
|
||||
* The verifikat is the unit of work: filters select qualifying vouchers
|
||||
* (period, entry-date range, free text, "has a line in the account range",
|
||||
* "has an untagged line") and the response carries every line of each
|
||||
* qualifying voucher so tagging a voucher means tagging a complete verifikat
|
||||
* — never the filtered subset of one.
|
||||
*
|
||||
* Response: 200 { data: { lines: [...], total_capped: boolean } } where each
|
||||
* line is flattened ({ id, account_number, debit_amount, credit_amount,
|
||||
* dimensions, journal_entry_id, entry_date, voucher_number, voucher_series,
|
||||
* description, reversed_by_id, reverses_id, fiscal_period_id }). The reversal
|
||||
* linkage rides along so the workbench can warn about storno pairs.
|
||||
* Reversal pairs are EXCLUDED by default: an annulled entry and its storno
|
||||
* net to zero in every dimension bucket as long as both sides carry the same
|
||||
* tag, so retro-tagging them is a no-op with an asymmetry foot-gun attached.
|
||||
* `include_annulled=1` opts them back in (the workbench keeps its blocking
|
||||
* motverifikat confirmation in that view only).
|
||||
*
|
||||
* Hard cap counts VOUCHERS (limit+1 fetch → `total_capped: true`); the UI
|
||||
* shows a "narrow your filter" notice.
|
||||
*
|
||||
* Response: 200 { data: { vouchers: [...], total_capped } } where each
|
||||
* voucher is { journal_entry_id, entry_date, voucher_number, voucher_series,
|
||||
* description, annulled, lines: [{ id, account_number, debit_amount,
|
||||
* credit_amount, dimensions }] }.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
@@ -24,24 +33,25 @@ import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface RawTaggingLine {
|
||||
interface RawEntry {
|
||||
id: string
|
||||
entry_date: string
|
||||
voucher_number: number | null
|
||||
voucher_series: string | null
|
||||
description: string
|
||||
reversed_by_id: string | null
|
||||
reverses_id: string | null
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
interface RawLine {
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
dimensions: Record<string, string> | null
|
||||
journal_entry_id: string
|
||||
// Supabase types !inner joins as arrays; for many-to-one (line → entry) it
|
||||
// returns a single object at runtime (same caveat as lib/reports/general-ledger.ts).
|
||||
journal_entries: {
|
||||
entry_date: string
|
||||
voucher_number: number | null
|
||||
voucher_series: string | null
|
||||
description: string
|
||||
reversed_by_id: string | null
|
||||
reverses_id: string | null
|
||||
fiscal_period_id: string
|
||||
}
|
||||
sort_order: number | null
|
||||
}
|
||||
|
||||
export const GET = withRouteContext(
|
||||
@@ -56,74 +66,152 @@ export const GET = withRouteContext(
|
||||
if (!validation.success) return validation.response
|
||||
const q = validation.data
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
// Step 1: qualifying vouchers. Line-level filters (account range, only
|
||||
// untagged) become "voucher HAS such a line" via the inner join — the
|
||||
// parent row appears once regardless of how many lines match.
|
||||
let entryQuery = supabase
|
||||
.from('journal_entries')
|
||||
.select(
|
||||
'id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, company_id, status)',
|
||||
'id, entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, journal_entry_lines!inner(id)',
|
||||
)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('company_id', companyId)
|
||||
// Posted only — drafts are edited directly in the voucher editor and the
|
||||
// retag RPC rejects them anyway.
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.eq('status', 'posted')
|
||||
|
||||
if (q.period_id) query = query.eq('journal_entries.fiscal_period_id', q.period_id)
|
||||
if (q.date_from) query = query.gte('journal_entries.entry_date', q.date_from)
|
||||
if (q.date_to) query = query.lte('journal_entries.entry_date', q.date_to)
|
||||
if (q.account_from) query = query.gte('account_number', q.account_from)
|
||||
if (q.account_to) query = query.lte('account_number', q.account_to)
|
||||
if (q.include_annulled !== '1') {
|
||||
// Default view: no reversal pairs. Both sides net to zero in every
|
||||
// dimension bucket when kept together, so they are pure noise here —
|
||||
// and hiding them makes tagging one side without the other impossible.
|
||||
entryQuery = entryQuery.is('reversed_by_id', null).is('reverses_id', null)
|
||||
}
|
||||
|
||||
if (q.period_id) entryQuery = entryQuery.eq('fiscal_period_id', q.period_id)
|
||||
if (q.date_from) entryQuery = entryQuery.gte('entry_date', q.date_from)
|
||||
if (q.date_to) entryQuery = entryQuery.lte('entry_date', q.date_to)
|
||||
if (q.text) {
|
||||
// Escape LIKE wildcards (\ % _) so they match literally — same posture
|
||||
// as the journal-entries list route.
|
||||
query = query.ilike('journal_entries.description', `%${escapeLikePattern(q.text)}%`)
|
||||
entryQuery = entryQuery.ilike('description', `%${escapeLikePattern(q.text)}%`)
|
||||
}
|
||||
if (q.account_from) {
|
||||
entryQuery = entryQuery.gte('journal_entry_lines.account_number', q.account_from)
|
||||
}
|
||||
if (q.account_to) {
|
||||
entryQuery = entryQuery.lte('journal_entry_lines.account_number', q.account_to)
|
||||
}
|
||||
if (q.only_untagged === '1') {
|
||||
// dimensions is NOT NULL DEFAULT '{}' (substrate migration), so the
|
||||
// empty-map equality is the complete "untagged" predicate.
|
||||
query = query.eq('dimensions', '{}')
|
||||
// empty-map equality is the complete "untagged" predicate. Combined
|
||||
// with an account range this reads "has an untagged line in the range".
|
||||
entryQuery = entryQuery.eq('journal_entry_lines.dimensions', '{}')
|
||||
}
|
||||
|
||||
// Deterministic order on the line PK; fetch one row past the cap so the
|
||||
// response can say "there is more" without a count query.
|
||||
const { data, error } = await query
|
||||
const { data: entryData, error: entryError } = await entryQuery
|
||||
.order('entry_date', { ascending: true })
|
||||
.order('voucher_series', { ascending: true })
|
||||
.order('voucher_number', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.limit(q.limit + 1)
|
||||
|
||||
if (error) {
|
||||
log.error('tagging line browse failed', error)
|
||||
return errorResponse(error, log, { requestId })
|
||||
if (entryError) {
|
||||
log.error('tagging voucher browse failed', entryError)
|
||||
return errorResponse(entryError, log, { requestId })
|
||||
}
|
||||
|
||||
const raw = (data ?? []) as unknown as RawTaggingLine[]
|
||||
const totalCapped = raw.length > q.limit
|
||||
const page = totalCapped ? raw.slice(0, q.limit) : raw
|
||||
const rawEntries = (entryData ?? []) as unknown as RawEntry[]
|
||||
const totalCapped = rawEntries.length > q.limit
|
||||
const entries = totalCapped ? rawEntries.slice(0, q.limit) : rawEntries
|
||||
|
||||
const lines = page
|
||||
.map((l) => ({
|
||||
if (entries.length === 0) {
|
||||
return NextResponse.json({ data: { vouchers: [], total_capped: totalCapped } })
|
||||
}
|
||||
|
||||
// Pair completion (annulled view only): if a pair leg qualified but its
|
||||
// counter-entry fell outside the filters (e.g. the storno is in a later
|
||||
// month than the date range), pull the counter in anyway. Without it the
|
||||
// workbench's motverifikat guard cannot see the missing leg and the user
|
||||
// could tag one side alone — exactly the P&L skew the guard exists to
|
||||
// prevent (Srf U 14 gross reporting). Counters ride on top of the cap:
|
||||
// they are required for correctness, not part of the browsed page.
|
||||
if (q.include_annulled === '1') {
|
||||
const present = new Set(entries.map((e) => e.id))
|
||||
const counterIds = [
|
||||
...new Set(
|
||||
entries
|
||||
.flatMap((e) => [e.reversed_by_id, e.reverses_id])
|
||||
.filter((id): id is string => id !== null && !present.has(id)),
|
||||
),
|
||||
]
|
||||
if (counterIds.length > 0) {
|
||||
const { data: counterData, error: counterError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select(
|
||||
'id, entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted')
|
||||
.in('id', counterIds)
|
||||
|
||||
if (counterError) {
|
||||
log.error('tagging counter-voucher fetch failed', counterError)
|
||||
return errorResponse(counterError, log, { requestId })
|
||||
}
|
||||
entries.push(...((counterData ?? []) as unknown as RawEntry[]))
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
a.entry_date.localeCompare(b.entry_date) ||
|
||||
(a.voucher_series ?? '').localeCompare(b.voucher_series ?? '') ||
|
||||
(a.voucher_number ?? 0) - (b.voucher_number ?? 0) ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: the COMPLETE line set for each qualifying voucher, so voucher-
|
||||
// level tagging always covers the whole verifikat. The entry IDs already
|
||||
// come from the company-filtered step-1 query; the explicit parent scope
|
||||
// here is defense in depth (repo convention).
|
||||
const { data: lineData, error: lineError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, sort_order, journal_entries!inner(company_id)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entry_id', entries.map((e) => e.id))
|
||||
.order('journal_entry_id', { ascending: true })
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
|
||||
if (lineError) {
|
||||
log.error('tagging voucher line fetch failed', lineError)
|
||||
return errorResponse(lineError, log, { requestId })
|
||||
}
|
||||
|
||||
const linesByEntry = new Map<string, RawLine[]>()
|
||||
for (const line of (lineData ?? []) as unknown as RawLine[]) {
|
||||
const bucket = linesByEntry.get(line.journal_entry_id) ?? []
|
||||
bucket.push(line)
|
||||
linesByEntry.set(line.journal_entry_id, bucket)
|
||||
}
|
||||
|
||||
const vouchers = entries.map((e) => ({
|
||||
journal_entry_id: e.id,
|
||||
entry_date: e.entry_date,
|
||||
voucher_number: e.voucher_number,
|
||||
voucher_series: e.voucher_series,
|
||||
description: e.description,
|
||||
annulled: Boolean(e.reversed_by_id || e.reverses_id),
|
||||
reversed_by_id: e.reversed_by_id,
|
||||
reverses_id: e.reverses_id,
|
||||
fiscal_period_id: e.fiscal_period_id,
|
||||
lines: (linesByEntry.get(e.id) ?? []).map((l) => ({
|
||||
id: l.id,
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
dimensions: l.dimensions ?? {},
|
||||
journal_entry_id: l.journal_entry_id,
|
||||
entry_date: l.journal_entries.entry_date,
|
||||
voucher_number: l.journal_entries.voucher_number,
|
||||
voucher_series: l.journal_entries.voucher_series,
|
||||
description: l.journal_entries.description,
|
||||
reversed_by_id: l.journal_entries.reversed_by_id,
|
||||
reverses_id: l.journal_entries.reverses_id,
|
||||
fiscal_period_id: l.journal_entries.fiscal_period_id,
|
||||
}))
|
||||
// Presentation order: date, then voucher, then line id. Sorting happens
|
||||
// after the cap (the cap follows insertion-ordered PKs) — acceptable for
|
||||
// the v1 hard-cap contract; the UI shows a narrow-your-filter notice.
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.entry_date.localeCompare(b.entry_date) ||
|
||||
(a.voucher_series ?? '').localeCompare(b.voucher_series ?? '') ||
|
||||
(a.voucher_number ?? 0) - (b.voucher_number ?? 0) ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ data: { lines, total_capped: totalCapped } })
|
||||
return NextResponse.json({ data: { vouchers, total_capped: totalCapped } })
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user