* 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>
80 lines
2.7 KiB
TypeScript
80 lines
2.7 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { BookMileagePeriodSchema } from '@/lib/api/schemas'
|
|
import { bookMileagePeriod } from '@/lib/mileage/mileage-service'
|
|
|
|
ensureInitialized()
|
|
|
|
export const POST = withRouteContext(
|
|
'mileage.book',
|
|
async (request, { supabase, companyId, user, log }) => {
|
|
const validation = await validateBody(request, BookMileagePeriodSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
const result = await bookMileagePeriod(supabase, companyId, user.id, {
|
|
from: body.from,
|
|
to: body.to,
|
|
entryDate: body.entry_date,
|
|
counterAccount: body.counter_account,
|
|
employeeId: body.employee_id,
|
|
})
|
|
|
|
if (!result.ok) {
|
|
if (result.code === 'NO_TRIPS') {
|
|
return NextResponse.json(
|
|
{ error: 'Inga obokförda resor i den valda perioden' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
if (result.code === 'MIXED_EMPLOYEES') {
|
|
return NextResponse.json(
|
|
{ error: 'Resorna i perioden gäller flera anställda. Bokför per anställd.' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
if (result.code === 'PERIOD_NOT_OPEN') {
|
|
return NextResponse.json(
|
|
{ error: 'Bokföringsdatumet ligger i en stängd eller låst period' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
if (result.code === 'CLAIM_LOST' || result.code === 'TRIPS_CHANGED') {
|
|
return NextResponse.json(
|
|
{ error: 'Körjournalen ändrades samtidigt av en annan bokning. Ladda om och försök igen.' },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
// STAMP_FAILED: the verifikat exists but some trips could not be marked
|
|
// as booked. Surface loudly so the user does not book the period twice.
|
|
log.error('mileage stamp failed after verifikat creation', undefined, {
|
|
operation: 'mileage.book',
|
|
companyId,
|
|
entityType: 'journal_entry',
|
|
entityId: result.journalEntryId,
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'Verifikatet skapades men alla resor kunde inte markeras som bokförda. Kontrollera körjournalen innan du bokför perioden igen.',
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
journal_entry_id: result.journalEntryId,
|
|
voucher_series: result.voucherSeries,
|
|
voucher_number: result.voucherNumber,
|
|
trip_count: result.tripCount,
|
|
total_amount: result.totalAmount,
|
|
summaries: result.summaries,
|
|
},
|
|
})
|
|
},
|
|
{ requireWrite: true }
|
|
)
|