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:
Mattsson
2026-08-07 14:17:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f7f3a31f8e
commit 7411a0171b
30 changed files with 3656 additions and 2 deletions
+65
View File
@@ -2217,6 +2217,10 @@ export type PendingOperationType =
// Payroll: salary run creation + AGI declaration
| 'create_salary_run'
| 'generate_agi'
// Körjournal: log a trip (pure travel documentation) + book the period's
// milersättning as one verifikat (7331 at schablon rate)
| 'log_mileage_trip'
| 'book_mileage_period'
// Mark invoice paid by linking an existing posted verifikat (no new JE)
| 'link_invoice_voucher'
// Supplier-side mirror: mark a leverantörsfaktura paid by linking an existing
@@ -4184,3 +4188,64 @@ export interface StoredStagedOperation {
risk_level?: string | null
preview_data?: unknown
}
// ============================================================
// Körjournal (mileage trips)
// ============================================================
export type MileageVehicleType = 'own_car' | 'company_car_fossil' | 'company_car_electric'
export type MileageTripStatus = 'draft' | 'booked'
/** A `mileage_trips` row: one business trip in the körjournal. */
export interface MileageTrip {
id: string
company_id: string
user_id: string
employee_id: string | null
trip_date: string
vehicle_type: MileageVehicleType
vehicle_registration: string | null
odometer_start: number | null
odometer_end: number | null
distance_km: number
from_location: string
to_location: string
purpose: string
visited: string | null
is_round_trip: boolean
status: MileageTripStatus
journal_entry_id: string | null
salary_run_id: string | null
notes: string | null
created_via: 'manual' | 'mcp' | 'import'
created_at: string
updated_at: string
}
export interface CreateMileageTripInput {
trip_date: string
vehicle_type?: MileageVehicleType
vehicle_registration?: string | null
odometer_start?: number | null
odometer_end?: number | null
distance_km: number
from_location: string
to_location: string
purpose: string
visited?: string | null
is_round_trip?: boolean
employee_id?: string | null
notes?: string | null
created_via?: 'manual' | 'mcp' | 'import'
}
/** Per-vehicle-type aggregation of draft trips for a period. */
export interface MileagePeriodSummary {
vehicle_type: MileageVehicleType
trip_count: number
total_km: number
total_mil: number
rate_per_mil: number
amount: number
}