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
@@ -3164,3 +3164,80 @@ export const DimensionTaggingApplySchema = z.object({
|
||||
dimensions: DimensionsBagSchema,
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Körjournal (mileage trips)
|
||||
// ============================================================
|
||||
|
||||
const mileageVehicleType = z.enum(['own_car', 'company_car_fossil', 'company_car_electric'])
|
||||
|
||||
export const CreateMileageTripSchema = z
|
||||
.object({
|
||||
trip_date: saneIsoDate,
|
||||
vehicle_type: mileageVehicleType.default('own_car'),
|
||||
vehicle_registration: z.string().trim().max(20).optional().nullable(),
|
||||
odometer_start: z.number().int().nonnegative().optional().nullable(),
|
||||
odometer_end: z.number().int().nonnegative().optional().nullable(),
|
||||
distance_km: z.number().positive().max(100000),
|
||||
from_location: z.string().trim().min(1).max(200),
|
||||
to_location: z.string().trim().min(1).max(200),
|
||||
purpose: z.string().trim().min(1).max(500),
|
||||
visited: z.string().trim().max(200).optional().nullable(),
|
||||
is_round_trip: z.boolean().default(false),
|
||||
employee_id: uuid.optional().nullable(),
|
||||
notes: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
.refine(
|
||||
(t) =>
|
||||
t.odometer_start == null || t.odometer_end == null || t.odometer_end > t.odometer_start,
|
||||
{ message: 'Mätarställning vid ankomst måste vara högre än vid start' }
|
||||
)
|
||||
.refine((t) => t.vehicle_type === 'own_car' || Boolean(t.vehicle_registration?.trim()), {
|
||||
message: 'Ange registreringsnummer för förmånsbilen',
|
||||
})
|
||||
|
||||
export const UpdateMileageTripSchema = z
|
||||
.object({
|
||||
trip_date: saneIsoDate.optional(),
|
||||
vehicle_type: mileageVehicleType.optional(),
|
||||
vehicle_registration: z.string().trim().max(20).optional().nullable(),
|
||||
odometer_start: z.number().int().nonnegative().optional().nullable(),
|
||||
odometer_end: z.number().int().nonnegative().optional().nullable(),
|
||||
distance_km: z.number().positive().max(100000).optional(),
|
||||
from_location: z.string().trim().min(1).max(200).optional(),
|
||||
to_location: z.string().trim().min(1).max(200).optional(),
|
||||
purpose: z.string().trim().min(1).max(500).optional(),
|
||||
visited: z.string().trim().max(200).optional().nullable(),
|
||||
is_round_trip: z.boolean().optional(),
|
||||
employee_id: uuid.optional().nullable(),
|
||||
notes: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
.refine((t) => Object.keys(t).length > 0, { message: 'Inga fält att uppdatera' })
|
||||
|
||||
export const BookMileagePeriodSchema = z
|
||||
.object({
|
||||
from: saneIsoDate,
|
||||
to: saneIsoDate,
|
||||
entry_date: saneIsoDate,
|
||||
counter_account: z.enum(['2820', '2893', '1930']).default('2820'),
|
||||
employee_id: uuid.optional(),
|
||||
})
|
||||
.refine((p) => p.from <= p.to, { message: 'Ogiltigt datumintervall' })
|
||||
// Schablon rates are per calendar year: a cross-year period would book
|
||||
// every trip at one year's rate.
|
||||
.refine((p) => p.from.slice(0, 4) === p.to.slice(0, 4), {
|
||||
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
|
||||
})
|
||||
|
||||
export const MileageSalaryPushSchema = z
|
||||
.object({
|
||||
run_id: uuid,
|
||||
employee_id: uuid,
|
||||
from: saneIsoDate,
|
||||
to: saneIsoDate,
|
||||
include_unassigned: z.boolean().default(true),
|
||||
})
|
||||
.refine((p) => p.from <= p.to, { message: 'Ogiltigt datumintervall' })
|
||||
.refine((p) => p.from.slice(0, 4) === p.to.slice(0, 4), {
|
||||
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user