feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export New mileage_trips table (RLS, booked-delete trigger per BFL retention), lib/mileage service reusing the payroll schablon rates, /api/mileage routes (trips CRUD, period booking to 7331, salary-run push, körjournal CSV), Körjournal dashboard page + nav, and three staged MCP tools (search-only catalog). Trips book as one verifikat per period via the engine; salary path inserts mileage_taxfree line items. mileage_trips classified in the full-archive export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(mileage): use shared roundOre helper per tightened ratchet baseline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): pending_operations op-type migration + Swedish review findings - New migration pair adds log_mileage_trip/book_mileage_period to the pending_operations operation_type CHECK (pg-real audit). - bookMileagePeriod refuses a period spanning several employees and names the employee in the verifikationstext when scoped (BFL motpart). - vehicle_registration required for förmånsbil trips (schema, service, MCP staging, UI surfaces the field). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): claim-first booking, CSV injection guard and driver column - bookMileagePeriod claims trips (draft to booked CAS) before creating the verifikat, so a concurrent second booking loses the race instead of double-booking; claim reverts if verifikat creation fails. - Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a Förare column naming the employee per trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening - Copying a round trip no longer re-doubles the stored distance. - pushMileageToSalaryRun claims trips before inserting line items (retry can no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races. - Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration 20260807113215): only claim/link/revert transitions and notes edits pass. - Cross-year periods rejected (schablon rates are per calendar year); payroll config year read from the date string, not TZ-dependent getFullYear(). - MCP staged bookings freeze the previewed trip set (trip_ids in params) and the commit fails on drift; validation errors return 400, not 500. - PATCH enforces the förmånsbil regnr rule on the effective row; export validates dates before they reach the Content-Disposition header; employee_id is verified company-scoped on trip creation; stale orphaned claims released. - UI: fetch flags reset in finally; ICU plural for draft summary; distance stored at the column's 1-decimal precision. - Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift, cross-year and update-trigger pg cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): revert-to-draft must clear salary_run_id at the trigger level New migration 20260807114924 replaces the booked-immutability function: a booked -> draft revert now rejects rows keeping salary_run_id, closing the DB-level double-pay path CodeRabbit flagged. pg test pins both directions; the CLAIM_LOST unit test now asserts the revert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): company-scope employee_id on PATCH (Superagent P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mileage): valid v4 uuid in cross-company employee PATCH test 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
f7f3a31f8e
commit
7411a0171b
@@ -10493,6 +10493,246 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
// ── Payroll (Lönehantering) ──────────────────────────────────
|
||||
{
|
||||
name: 'gnubok_list_mileage_trips',
|
||||
title: 'List Mileage Trips (Körjournal)',
|
||||
catalogVisibility: 'search',
|
||||
description: 'List körjournal trips for the active company. Filter by date range, status (draft = not yet booked, booked) or employee. Use before gnubok_book_mileage_period to see what would be booked.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
from: { type: 'string', description: 'From date (YYYY-MM-DD)' },
|
||||
to: { type: 'string', description: 'To date (YYYY-MM-DD)' },
|
||||
status: { type: 'string', enum: ['draft', 'booked'], description: 'Filter by status' },
|
||||
employee_id: { type: 'string', description: 'Filter by employee UUID' },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
trips: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
total_km: { type: 'number' },
|
||||
draft_km: { type: 'number' },
|
||||
},
|
||||
required: ['trips', 'count', 'total_km', 'draft_km'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const { listTrips } = await import('@/lib/mileage/mileage-service')
|
||||
const status = args.status as 'draft' | 'booked' | undefined
|
||||
const rows = await listTrips(supabase, companyId, {
|
||||
from: (args.from as string) || undefined,
|
||||
to: (args.to as string) || undefined,
|
||||
status: status === 'draft' || status === 'booked' ? status : undefined,
|
||||
employeeId: (args.employee_id as string) || undefined,
|
||||
})
|
||||
const { roundOre: round2 } = await import('@/lib/money')
|
||||
const trips = rows.map((t) => ({
|
||||
mileage_trip_id: t.id,
|
||||
trip_date: t.trip_date,
|
||||
vehicle_type: t.vehicle_type,
|
||||
vehicle_registration: t.vehicle_registration,
|
||||
odometer_start: t.odometer_start,
|
||||
odometer_end: t.odometer_end,
|
||||
distance_km: Number(t.distance_km),
|
||||
from_location: t.from_location,
|
||||
to_location: t.to_location,
|
||||
purpose: t.purpose,
|
||||
visited: t.visited,
|
||||
is_round_trip: t.is_round_trip,
|
||||
status: t.status,
|
||||
journal_entry_id: t.journal_entry_id,
|
||||
salary_run_id: t.salary_run_id,
|
||||
}))
|
||||
return {
|
||||
trips,
|
||||
count: trips.length,
|
||||
total_km: round2(trips.reduce((sum, t) => sum + t.distance_km, 0)),
|
||||
draft_km: round2(
|
||||
trips.filter((t) => t.status === 'draft').reduce((sum, t) => sum + t.distance_km, 0)
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_log_mileage_trip',
|
||||
title: 'Log Mileage Trip (Körjournal)',
|
||||
catalogVisibility: 'search',
|
||||
description: 'Stage a körjournal trip (date, route, km, purpose per Skatteverket requirements). Approve via gnubok_approve_pending_operation. The trip stays a draft until booked via gnubok_book_mileage_period.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
trip_date: { type: 'string', description: 'Trip date (YYYY-MM-DD)' },
|
||||
vehicle_type: { type: 'string', enum: ['own_car', 'company_car_fossil', 'company_car_electric'], description: 'Vehicle type; drives the tax-free rate (default own_car, 25 kr/mil)' },
|
||||
vehicle_registration: { type: 'string', description: 'Registration number (regnr)' },
|
||||
odometer_start: { type: 'number', description: 'Odometer at start (km)' },
|
||||
odometer_end: { type: 'number', description: 'Odometer at arrival (km)' },
|
||||
distance_km: { type: 'number', description: 'Distance in km' },
|
||||
from_location: { type: 'string', description: 'Start location' },
|
||||
to_location: { type: 'string', description: 'Destination' },
|
||||
purpose: { type: 'string', description: 'Business purpose (ärende)' },
|
||||
visited: { type: 'string', description: 'Who/which company was visited' },
|
||||
is_round_trip: { type: 'boolean', description: 'Distance covers the return leg too' },
|
||||
employee_id: { type: 'string', description: 'Employee UUID when the trip belongs to an employee' },
|
||||
notes: { type: 'string', description: 'Free-text note' },
|
||||
},
|
||||
required: ['trip_date', 'distance_km', 'from_location', 'to_location', 'purpose'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const tripDate = args.trip_date as string
|
||||
const distanceKm = args.distance_km as number
|
||||
if (typeof tripDate !== 'string' || !ISO_DATE_RE.test(tripDate)) {
|
||||
throw new Error('trip_date must be YYYY-MM-DD')
|
||||
}
|
||||
if (typeof distanceKm !== 'number' || !(distanceKm > 0)) {
|
||||
throw new Error('distance_km must be a positive number')
|
||||
}
|
||||
const odoStart = args.odometer_start as number | undefined
|
||||
const odoEnd = args.odometer_end as number | undefined
|
||||
if (odoStart != null && odoEnd != null && odoEnd <= odoStart) {
|
||||
throw new Error('odometer_end must be greater than odometer_start')
|
||||
}
|
||||
|
||||
const vehicleType = (args.vehicle_type as string) || 'own_car'
|
||||
if (vehicleType !== 'own_car' && !(args.vehicle_registration as string | undefined)?.trim()) {
|
||||
throw new Error('vehicle_registration is required for a förmånsbil trip (körjournal must identify the vehicle)')
|
||||
}
|
||||
// Preview the tax-free allowance at the schablon rate; non-fatal if the
|
||||
// payroll config year is missing.
|
||||
let approxAmount: number | undefined
|
||||
try {
|
||||
const { loadPayrollConfig } = await import('@/lib/salary/payroll-config')
|
||||
const { ratePerMil } = await import('@/lib/mileage/mileage-service')
|
||||
const { roundOre } = await import('@/lib/money')
|
||||
const config = await loadPayrollConfig(supabase, Number(tripDate.slice(0, 4)))
|
||||
approxAmount = roundOre((distanceKm / 10) * ratePerMil(config, vehicleType as never))
|
||||
} catch {
|
||||
approxAmount = undefined
|
||||
}
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'log_mileage_trip',
|
||||
`Körjournal: ${args.from_location} till ${args.to_location} ${tripDate} (${distanceKm} km)`,
|
||||
{
|
||||
trip_date: tripDate,
|
||||
vehicle_type: vehicleType,
|
||||
vehicle_registration: args.vehicle_registration ?? null,
|
||||
odometer_start: odoStart ?? null,
|
||||
odometer_end: odoEnd ?? null,
|
||||
distance_km: distanceKm,
|
||||
from_location: args.from_location,
|
||||
to_location: args.to_location,
|
||||
purpose: args.purpose,
|
||||
visited: args.visited ?? null,
|
||||
is_round_trip: args.is_round_trip === true,
|
||||
employee_id: args.employee_id ?? null,
|
||||
notes: args.notes ?? null,
|
||||
},
|
||||
{
|
||||
trip_date: tripDate,
|
||||
route: `${args.from_location} → ${args.to_location}`,
|
||||
distance_km: distanceKm,
|
||||
purpose: args.purpose,
|
||||
vehicle_type: vehicleType,
|
||||
...(approxAmount != null ? { tax_free_allowance_sek: approxAmount } : {}),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'Once approved, the trip is a draft in the körjournal. Book the period via gnubok_book_mileage_period.',
|
||||
tool: 'gnubok_book_mileage_period',
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_book_mileage_period',
|
||||
title: 'Book Mileage Period (Milersättning)',
|
||||
catalogVisibility: 'search',
|
||||
description: 'Stage booking of all draft körjournal trips in a date range as one milersättning verifikat: debit 7331 at the tax-free schablon rate, credit 2820/2893/1930. Approve via gnubok_approve_pending_operation. Call gnubok_list_mileage_trips first.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
from: { type: 'string', description: 'Period start (YYYY-MM-DD)' },
|
||||
to: { type: 'string', description: 'Period end (YYYY-MM-DD)' },
|
||||
entry_date: { type: 'string', description: 'Verifikat date (YYYY-MM-DD); must be in an open period' },
|
||||
counter_account: { type: 'string', enum: ['2820', '2893', '1930'], description: 'Credit side: 2820 skuld till anställda (default), 2893 avräkning aktieägare, 1930 when already paid out from bank' },
|
||||
employee_id: { type: 'string', description: 'Only book trips for this employee UUID' },
|
||||
},
|
||||
required: ['from', 'to', 'entry_date'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const from = args.from as string
|
||||
const to = args.to as string
|
||||
const entryDate = args.entry_date as string
|
||||
for (const [label, value] of [['from', from], ['to', to], ['entry_date', entryDate]] as const) {
|
||||
if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) {
|
||||
throw new Error(`${label} must be YYYY-MM-DD`)
|
||||
}
|
||||
}
|
||||
if (from > to) throw new Error('from must be <= to')
|
||||
if (from.slice(0, 4) !== to.slice(0, 4)) {
|
||||
throw new Error('Schablon rates are per calendar year: book one year at a time')
|
||||
}
|
||||
const counterAccount = (args.counter_account as string) || '2820'
|
||||
|
||||
// Read-only preflight: aggregate the draft trips so the approver sees
|
||||
// exactly what would be booked. The commit path re-reads atomically.
|
||||
const { listTrips, summarizeTrips } = await import('@/lib/mileage/mileage-service')
|
||||
const { loadPayrollConfig } = await import('@/lib/salary/payroll-config')
|
||||
const { roundOre } = await import('@/lib/money')
|
||||
const trips = await listTrips(supabase, companyId, {
|
||||
from, to, status: 'draft',
|
||||
employeeId: (args.employee_id as string) || undefined,
|
||||
})
|
||||
if (trips.length === 0) {
|
||||
throw new Error('No unbooked trips in the selected period. Log trips first via gnubok_log_mileage_trip.')
|
||||
}
|
||||
if (new Set(trips.map((t) => t.employee_id ?? 'unassigned')).size > 1) {
|
||||
throw new Error('The period spans several employees. Book per employee by passing employee_id (BFL motpart traceability).')
|
||||
}
|
||||
const config = await loadPayrollConfig(supabase, Number(to.slice(0, 4)))
|
||||
const summaries = summarizeTrips(trips, config)
|
||||
const totalAmount = roundOre(summaries.reduce((sum, s) => sum + s.amount, 0))
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'book_mileage_period',
|
||||
`Bokför milersättning ${from} till ${to}: ${totalAmount} kr (${trips.length} resor)`,
|
||||
{
|
||||
from, to,
|
||||
entry_date: entryDate,
|
||||
counter_account: counterAccount,
|
||||
// Freeze the previewed trip set: the commit fails if the drafts in
|
||||
// range change between staging and approval.
|
||||
trip_ids: trips.map((t) => t.id),
|
||||
...(args.employee_id ? { employee_id: args.employee_id } : {}),
|
||||
},
|
||||
{
|
||||
trip_count: trips.length,
|
||||
total_amount: totalAmount,
|
||||
debit_account: '7331',
|
||||
credit_account: counterAccount,
|
||||
summaries: summaries.map((s) => ({
|
||||
vehicle_type: s.vehicle_type,
|
||||
total_mil: s.total_mil,
|
||||
rate_per_mil: s.rate_per_mil,
|
||||
amount: s.amount,
|
||||
})),
|
||||
},
|
||||
actor,
|
||||
undefined,
|
||||
{ dateForPeriodCheck: entryDate },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_list_employees',
|
||||
title: 'List Employees',
|
||||
|
||||
Reference in New Issue
Block a user