* 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>
79 lines
3.7 KiB
PL/PgSQL
79 lines
3.7 KiB
PL/PgSQL
-- Körjournal: mileage trip log per Skatteverket documentation requirements.
|
|
-- Trips are drafts until booked; a booked trip is underlag for a verifikat
|
|
-- (or a salary run line) and falls under BFL 7-year retention, so booked
|
|
-- rows can never be deleted.
|
|
|
|
CREATE TABLE public.mileage_trips (
|
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
employee_id uuid REFERENCES public.employees(id) ON DELETE SET NULL,
|
|
trip_date date NOT NULL,
|
|
vehicle_type text NOT NULL DEFAULT 'own_car'
|
|
CHECK (vehicle_type IN ('own_car', 'company_car_fossil', 'company_car_electric')),
|
|
vehicle_registration text,
|
|
odometer_start integer CHECK (odometer_start >= 0),
|
|
odometer_end integer CHECK (odometer_end >= 0),
|
|
distance_km numeric(10,1) NOT NULL CHECK (distance_km > 0),
|
|
from_location text NOT NULL,
|
|
to_location text NOT NULL,
|
|
purpose text NOT NULL,
|
|
visited text,
|
|
is_round_trip boolean NOT NULL DEFAULT false,
|
|
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'booked')),
|
|
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
|
|
salary_run_id uuid REFERENCES public.salary_runs(id) ON DELETE SET NULL,
|
|
notes text,
|
|
created_via text NOT NULL DEFAULT 'manual' CHECK (created_via IN ('manual', 'mcp', 'import')),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT mileage_trips_odometer_order
|
|
CHECK (odometer_start IS NULL OR odometer_end IS NULL OR odometer_end > odometer_start)
|
|
);
|
|
|
|
ALTER TABLE public.mileage_trips ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "view own-company mileage_trips"
|
|
ON public.mileage_trips FOR SELECT USING (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "insert own-company mileage_trips"
|
|
ON public.mileage_trips FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "update own-company mileage_trips"
|
|
ON public.mileage_trips FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "delete own-company mileage_trips"
|
|
ON public.mileage_trips FOR DELETE USING (company_id IN (SELECT user_company_ids()));
|
|
|
|
CREATE INDEX idx_mileage_trips_company_date ON public.mileage_trips (company_id, trip_date DESC);
|
|
CREATE INDEX idx_mileage_trips_company_status ON public.mileage_trips (company_id, status);
|
|
CREATE INDEX idx_mileage_trips_journal_entry
|
|
ON public.mileage_trips (journal_entry_id) WHERE journal_entry_id IS NOT NULL;
|
|
|
|
CREATE TRIGGER set_updated_at_mileage_trips
|
|
BEFORE UPDATE ON public.mileage_trips
|
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
|
|
|
CREATE TRIGGER audit_mileage_trips
|
|
AFTER INSERT OR UPDATE OR DELETE ON public.mileage_trips
|
|
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
|
|
|
-- A booked trip is bookkeeping underlag (BFL 7 kap): block deletion.
|
|
CREATE OR REPLACE FUNCTION public.block_booked_mileage_trip_deletion()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
BEGIN
|
|
IF OLD.status = 'booked' THEN
|
|
RAISE EXCEPTION 'Cannot delete a booked mileage trip: it is retained as underlag (BFL). Reverse the verifikat first.'
|
|
USING ERRCODE = 'P0001';
|
|
END IF;
|
|
RETURN OLD;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER block_booked_mileage_trip_deletion
|
|
BEFORE DELETE ON public.mileage_trips
|
|
FOR EACH ROW EXECUTE FUNCTION public.block_booked_mileage_trip_deletion();
|
|
|
|
NOTIFY pgrst, 'reload schema';
|