feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2359)
* feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2293) The bridge table said "ej matchad" and steered to Bokför when a Bankgirot aggregate was already booked as two or three unlinked vouchers. The booking doors have refused that double booking since #2300 and #2346 with detectExplainingVoucherSet; the view never ran it. - duplicate-payment-detection: split the set detector into fetch and pure steps and add detectExplainingVoucherSets, the batch form (one ledger scan, one anchor lookup, per-row verdict identical to the single detector; a voucher explains at most one row per call). ExplainingVoucher now also carries voucher_series and voucher_number. - reconciliation/covering-set-candidate (new): maps sets to proposals (0.95 same date, 0.85 within seven days), SEK accounts only, fails open. - items: open bank rows nothing explains 1:1 are searched before they land in unmatched_external; a hit lands in proposed with proposal.vouchers. - schemas: ReconciliationProposal.vouchers (optional, set proposals only). - AccountOverview: "= A57 + A58" with the legs' amounts, one Koppla that posts every voucher as a 1:N pair to the existing links route. - i18n: reconciliation.proposal_set_title and proposal_set_same_day (sv, en). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * chore(skill): regenerate accounted-api for ReconciliationProposal.vouchers (#2293) The set proposal field added to the reconciliation items response shape flows into the generated agent skill; regenerated with `npm run apiskill:generate`, which changes one line of references/banking.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
fcda4a75dd
commit
6776cb4fc6
@@ -191,8 +191,13 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
if (!item.proposal) return
|
||||
setBusy(item.item_id)
|
||||
try {
|
||||
// A set proposal (#2293) links the row to every verifikat in it (1:N,
|
||||
// all or nothing, slices revalidated server-side); a 1:1 proposal to one.
|
||||
const journalEntryIds = item.proposal.vouchers?.map((v) => v.journal_entry_id) ?? [
|
||||
item.proposal.journal_entry_id,
|
||||
]
|
||||
const data = await postJson(`${base}/links`, {
|
||||
pairs: [{ external_ids: [item.item_id], journal_entry_ids: [item.proposal.journal_entry_id] }],
|
||||
pairs: [{ external_ids: [item.item_id], journal_entry_ids: journalEntryIds }],
|
||||
})
|
||||
if (data) {
|
||||
const skipped = data.skipped as Array<{ message: string }>
|
||||
@@ -918,6 +923,43 @@ function ItemRow({
|
||||
{item.awaiting_external && <Chip>{t('chip_awaiting', { source: sourceLabel })}</Chip>}
|
||||
</span>
|
||||
)
|
||||
} else if (item.proposal && item.proposal.vouchers && item.proposal.vouchers.length > 1) {
|
||||
// An explaining set (#2293): "= A57 + A58", the legs' amounts underneath,
|
||||
// one Koppla that links the row to all of them.
|
||||
const p = item.proposal
|
||||
const setVouchers = p.vouchers ?? []
|
||||
const sameDay = setVouchers.every((v) => v.entry_date === item.date)
|
||||
voucherCell = (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"
|
||||
title={t('proposal_set_title', { count: setVouchers.length })}
|
||||
>
|
||||
<span className="text-muted-foreground" aria-hidden>
|
||||
=
|
||||
</span>
|
||||
{setVouchers.map((v, i) => (
|
||||
<Fragment key={v.journal_entry_id}>
|
||||
{i > 0 && (
|
||||
<span className="text-muted-foreground" aria-hidden>
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
<Link href={`/bookkeeping/${v.journal_entry_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
|
||||
{voucherOf(v) ?? v.journal_entry_id.slice(0, 8)}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('confidence', { percent: Math.round(p.confidence * 100) })}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-[12px] tabular-nums text-muted-foreground" data-ph-mask>
|
||||
{setVouchers.map((v) => formatCurrency(v.amount, currency)).join(' + ')}
|
||||
{sameDay ? ` · ${t('proposal_set_same_day')}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
} else if (item.proposal) {
|
||||
const p = item.proposal
|
||||
voucherCell = (
|
||||
|
||||
Reference in New Issue
Block a user