fix(pending-ops): record posted ids and land failed_partial instead of clean rejected after partial commits (#842) (#1110)
Multi-step executors (match_transaction_invoice, credit_invoice) post an irreversible voucher or persist a credit note and then run later fallible steps. A failure there previously marked the whole op status=rejected, hiding the posted entity and its id from operators. - new migration 20260722134114: add failed_partial to the pending_operations status CHECK and treat it as terminal in both immutability triggers (immutable, undeletable, never re-claimable) - PartialCommitError + ExecutorResult.partialPostedIds carry the posted ids; the dispatcher writes status=failed_partial with result_data.posted_ids and returns code=partial_commit - instrument only the two named executors; hoist the read-only settlement-account resolution above the storno in the match executor - consumer sweep: status union + query schema widened, failed_partial folds into the Avvisade tab with a badge and posted-ids detail line, bulk/reject routes and MCP tools message it explicitly, worklist and expiry sweep intentionally untouched (not pending work) - tests: pg-real coverage for the new terminal semantics, dispatcher unit tests for both partial paths plus byte-for-byte regression guards Fixes #842 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
27ef398623
commit
3e1ea29d02
@@ -275,6 +275,19 @@ function isAutoExpired(op: PendingOperation): boolean {
|
||||
return op.status === 'rejected' && rd?.auto_rejected === true && rd?.reason === 'expired'
|
||||
}
|
||||
|
||||
/**
|
||||
* failed_partial rows (issue #842): the executor posted an irreversible
|
||||
* voucher/credit note and then failed a later step. The dispatcher persisted
|
||||
* the ids of what WAS posted in result_data.posted_ids; render them so the
|
||||
* reviewer can locate the orphaned entity.
|
||||
*/
|
||||
function failedPartialPostedIds(op: PendingOperation): string | null {
|
||||
const rd = op.result_data as { posted_ids?: Record<string, string> } | null
|
||||
const entries = Object.entries(rd?.posted_ids ?? {})
|
||||
if (entries.length === 0) return null
|
||||
return entries.map(([key, value]) => `${key}: ${value}`).join(', ')
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
@@ -1253,6 +1266,11 @@ export default function PendingOperationsPage() {
|
||||
{t('badge_auto_expired')}
|
||||
</Badge>
|
||||
)}
|
||||
{op.status === 'failed_partial' && (
|
||||
<Badge variant="warning" className="ml-1 h-4 px-1.5 py-0 text-[10px]">
|
||||
{t('badge_failed_partial')}
|
||||
</Badge>
|
||||
)}
|
||||
</DataListMeta>
|
||||
{showHighRiskWarning && (
|
||||
<p className="mt-1 flex items-start gap-1 text-xs text-destructive">
|
||||
@@ -1273,6 +1291,14 @@ export default function PendingOperationsPage() {
|
||||
{t('auto_expired_detail')}
|
||||
</p>
|
||||
)}
|
||||
{op.status === 'failed_partial' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('failed_partial_detail')}
|
||||
{failedPartialPostedIds(op) && (
|
||||
<span className="font-mono"> ({failedPartialPostedIds(op)})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</DataListRow>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user