7411a0171b
* 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>
77 lines
2.2 KiB
TypeScript
77 lines
2.2 KiB
TypeScript
import type { MileageTrip } from '@/types'
|
|
|
|
/**
|
|
* Körjournal CSV export for Skatteverket audit purposes. Column labels are
|
|
* statutory-adjacent Swedish terms and stay Swedish in both locales (same
|
|
* policy as SIE/INK2 exports). Semicolon-separated with a UTF-8 BOM so
|
|
* Swedish Excel opens it correctly.
|
|
*/
|
|
|
|
const HEADERS = [
|
|
'Datum',
|
|
'Förare',
|
|
'Fordon',
|
|
'Registreringsnummer',
|
|
'Mätarställning start',
|
|
'Mätarställning slut',
|
|
'Antal km',
|
|
'Från',
|
|
'Till',
|
|
'Ärende',
|
|
'Besökt (kund/plats)',
|
|
'Tur och retur',
|
|
'Status',
|
|
'Verifikat',
|
|
] as const
|
|
|
|
const VEHICLE_LABELS: Record<MileageTrip['vehicle_type'], string> = {
|
|
own_car: 'Egen bil',
|
|
company_car_fossil: 'Förmånsbil (bensin/diesel)',
|
|
company_car_electric: 'Förmånsbil (el)',
|
|
}
|
|
|
|
function csvField(value: string | number | null | undefined): string {
|
|
if (value === null || value === undefined || value === '') return ''
|
|
let text = String(value)
|
|
// Formula-injection guard (OWASP CSV injection): user-entered text starting
|
|
// with a formula trigger would execute when the export opens in Excel.
|
|
// Neutralize with a leading apostrophe; spreadsheet apps hide it.
|
|
if (/^[=+@\t\r-]/.test(text)) {
|
|
text = `'${text}`
|
|
}
|
|
if (/[";\n\r]/.test(text)) {
|
|
return `"${text.replace(/"/g, '""')}"`
|
|
}
|
|
return text
|
|
}
|
|
|
|
export function mileageTripsToCsv(
|
|
trips: MileageTrip[],
|
|
voucherLabels: Map<string, string> = new Map(),
|
|
driverLabels: Map<string, string> = new Map()
|
|
): string {
|
|
const rows = trips.map((trip) =>
|
|
[
|
|
trip.trip_date,
|
|
trip.employee_id ? (driverLabels.get(trip.employee_id) ?? '') : '',
|
|
VEHICLE_LABELS[trip.vehicle_type],
|
|
trip.vehicle_registration,
|
|
trip.odometer_start,
|
|
trip.odometer_end,
|
|
// Swedish decimal comma for Excel.
|
|
String(trip.distance_km).replace('.', ','),
|
|
trip.from_location,
|
|
trip.to_location,
|
|
trip.purpose,
|
|
trip.visited,
|
|
trip.is_round_trip ? 'Ja' : 'Nej',
|
|
trip.status === 'booked' ? 'Bokförd' : 'Utkast',
|
|
trip.journal_entry_id ? (voucherLabels.get(trip.journal_entry_id) ?? '') : '',
|
|
]
|
|
.map(csvField)
|
|
.join(';')
|
|
)
|
|
|
|
return '\uFEFF' + [HEADERS.join(';'), ...rows].join('\r\n') + '\r\n'
|
|
}
|