feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion (#1075)

* feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion

Close the last MCP-surface gaps for running payroll end-to-end via the
connector (the v1 REST API already had the full chain):

- gnubok_book_salary_run: stages a high-risk book operation; on approval
  the executor walks review -> approved -> paid -> booked via the new
  lib/salary/book-run.ts (extracted from the dashboard book route, which
  now calls the same core) and posts the immutable salary vouchers.
- gnubok_delete_absence: staged inverse of gnubok_register_absence,
  reusing deleteAbsenceRange with a dry-run day-count preview.
- Wire the missing payroll operation types into the Granskning label map
  (register_absence, update_payslip_line, employee ops, vacation_year_close
  had translations but fell back to humanized snake_case).
- Update stale 'booking happens in the web UI' prose in tool descriptions,
  the payroll-monthly skill, and the workflow hint; payload-size ceiling
  56K -> 57K per the documented bump protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): widen pending_operations op-type CHECK + roster typing for book_salary_run

The op-type audit (pg-real) caught the exact bug class it exists for:
book_salary_run and delete_absence were staged in code without the
constraint-expansion migration, so every real staging INSERT would have
failed with check_violation while dry_run previewed clean. Ships the
documented widen (NOT VALID) + validate migration pair. Also fixes the
strict-mode cast in book-run.ts that failed the production typecheck.

Verified locally against supabase/postgres 15.8.1.060 with all migrations
applied: op-type audit green, pg-real 692/693 (the one failure is the
pre-existing TZ-sensitive get_unlinked_1930_lines assertion, green under
TZ=UTC as in CI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-20 13:00:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 378611a2dc
commit 30771b1619
19 changed files with 1199 additions and 166 deletions
+105
View File
@@ -3724,6 +3724,105 @@ async function commitRegisterAbsence(
}
}
async function commitBookSalaryRun(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const salaryRunId = params.salary_run_id as string
if (!salaryRunId) return { error: 'salary_run_id is required', status: 400 }
try {
const { advanceAndBookSalaryRun } = await import('@/lib/salary/book-run')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await advanceAndBookSalaryRun(supabase, {
companyId,
userId,
salaryRunId,
log: createLogger('commit/book_salary_run'),
})
if (!result.ok) {
const entry = getErrorEntry(result.code)
const detail =
(result.details?.reason as string | undefined) ??
(Array.isArray(result.details?.employees)
? `Saknar beräkning: ${(result.details.employees as string[]).join(', ')}`
: undefined)
return {
error: [entry?.message_sv ?? `Kunde inte bokföra lönekörningen: ${result.code}`, detail]
.filter(Boolean)
.join(' '),
status: entry?.httpStatus ?? 500,
}
}
const run = result.data.run as { period_year?: number; period_month?: number; status?: string }
return {
data: {
salary_run_id: salaryRunId,
status: run.status ?? 'booked',
period: run.period_year
? `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
: undefined,
journal_entry_ids: result.data.entryIds,
nollkorning: result.data.nollkorning,
warnings: result.data.warnings,
},
}
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to book salary run',
status: 500,
}
}
}
async function commitDeleteAbsence(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const employeeId = params.employee_id as string
const from = params.from as string
const to = params.to as string
if (!employeeId || !from || !to) {
return { error: 'employee_id, from and to are required', status: 400 }
}
try {
const { deleteAbsenceRange } = await import('@/lib/salary/absence')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await deleteAbsenceRange(supabase, {
companyId,
employeeId,
from,
to,
absenceType: (params.absence_type as string | undefined) || undefined,
})
if (!result.ok) {
const entry = getErrorEntry(result.code)
return {
error: entry?.message_sv ?? `Kunde inte ta bort frånvaron: ${result.code}`,
status: entry?.httpStatus ?? 500,
}
}
return {
data: {
employee_id: employeeId,
from,
to,
absence_type: (params.absence_type as string | undefined) ?? null,
deleted_count: result.data.deleted_count,
},
}
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to delete absence',
status: 500,
}
}
}
async function commitSetEmployeeOpeningBalances(
supabase: SupabaseClient,
userId: string,
@@ -4316,6 +4415,12 @@ async function commitPendingOperationInner(
case 'register_absence':
result = await commitRegisterAbsence(supabase, companyId, pendingOp.params)
break
case 'book_salary_run':
result = await commitBookSalaryRun(supabase, userId, companyId, pendingOp.params)
break
case 'delete_absence':
result = await commitDeleteAbsence(supabase, companyId, pendingOp.params)
break
case 'create_employee':
result = await commitCreateEmployee(supabase, userId, companyId, pendingOp.params)
break