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
@@ -4661,6 +4661,106 @@ async function commitCreateSalaryRun(
|
||||
}
|
||||
}
|
||||
|
||||
async function commitLogMileageTrip(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
try {
|
||||
const { createTrip } = await import('@/lib/mileage/mileage-service')
|
||||
const trip = await createTrip(supabase, companyId, userId, {
|
||||
trip_date: params.trip_date as string,
|
||||
vehicle_type: params.vehicle_type as never,
|
||||
vehicle_registration: (params.vehicle_registration as string) || null,
|
||||
odometer_start: (params.odometer_start as number) ?? null,
|
||||
odometer_end: (params.odometer_end as number) ?? null,
|
||||
distance_km: params.distance_km as number,
|
||||
from_location: params.from_location as string,
|
||||
to_location: params.to_location as string,
|
||||
purpose: params.purpose as string,
|
||||
visited: (params.visited as string) || null,
|
||||
is_round_trip: params.is_round_trip === true,
|
||||
employee_id: (params.employee_id as string) || null,
|
||||
notes: (params.notes as string) || null,
|
||||
created_via: 'mcp',
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
mileage_trip_id: trip.id,
|
||||
trip_date: trip.trip_date,
|
||||
distance_km: trip.distance_km,
|
||||
status: trip.status,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to log mileage trip'
|
||||
// Input-validation failures from the service are permanent for these
|
||||
// params: 400 so agents fix the arguments instead of retrying blindly.
|
||||
const isValidation = /registreringsnummer|hittades inte/i.test(message)
|
||||
return { error: message, status: isValidation ? 400 : 500 }
|
||||
}
|
||||
}
|
||||
|
||||
async function commitBookMileagePeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
try {
|
||||
const { bookMileagePeriod } = await import('@/lib/mileage/mileage-service')
|
||||
const result = await bookMileagePeriod(supabase, companyId, userId, {
|
||||
from: params.from as string,
|
||||
to: params.to as string,
|
||||
entryDate: params.entry_date as string,
|
||||
counterAccount: (params.counter_account as never) || '2820',
|
||||
employeeId: (params.employee_id as string) || undefined,
|
||||
createdVia: 'mcp',
|
||||
// Staged approvals freeze the trip set: what was previewed is exactly
|
||||
// what may be booked; drift fails the commit instead of booking blind.
|
||||
expectedTripIds: Array.isArray(params.trip_ids)
|
||||
? (params.trip_ids as string[])
|
||||
: undefined,
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.code === 'NO_TRIPS') {
|
||||
return { error: 'No unbooked trips in the selected period', status: 400 }
|
||||
}
|
||||
if (result.code === 'MIXED_EMPLOYEES') {
|
||||
return { error: 'The period spans several employees; book per employee via employee_id', status: 400 }
|
||||
}
|
||||
if (result.code === 'PERIOD_NOT_OPEN') {
|
||||
return { error: 'The entry date falls in a closed or locked period', status: 400 }
|
||||
}
|
||||
if (result.code === 'TRIPS_CHANGED' || result.code === 'CLAIM_LOST') {
|
||||
return {
|
||||
error: 'The körjournal changed since this booking was staged; stage it again to get a fresh preview',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
return {
|
||||
error: `Voucher ${result.journalEntryId} was created but trips could not all be linked; review the körjournal before booking again`,
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
journal_entry_id: result.journalEntryId,
|
||||
voucher: `${result.voucherSeries ?? ''}${result.voucherNumber ?? ''}`,
|
||||
trip_count: result.tripCount,
|
||||
total_amount: result.totalAmount,
|
||||
summaries: result.summaries,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Failed to book mileage period',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitGenerateAgi(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -5572,6 +5672,12 @@ async function commitPendingOperationInner(
|
||||
case 'create_salary_run':
|
||||
result = await commitCreateSalaryRun(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'log_mileage_trip':
|
||||
result = await commitLogMileageTrip(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'book_mileage_period':
|
||||
result = await commitBookMileagePeriod(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'generate_agi':
|
||||
result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user