# LandveX Enterprise Platform — Rapporterings- och Analysmodul ## Översikt Denna modul ger kommuner och andra kunder self-service rapportering med schemaläggning, delning och export. Den integreras med LandveX befintliga datakällor och exponeras via ett REST-API. --- ## 1. Datamodell ### 1.1 Entiteter (sammansatt vy) ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Report │ │ ReportSchedule │ │ ReportShare │ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │ id (PK) │ │ id (PK) │ │ id (PK) │ │ tenantId (FK) │◄────┤ reportId (FK) │ │ reportId (FK) │ │ name │ │ cronExpression │ │ sharedWithUserId│ │ description │ │ nextRunAt │ │ sharedWithEmail │ │ type │ │ lastRunAt │ │ permission │ │ config (JSON) │ │ status │ │ expiresAt │ │ dataSourceId │ │ createdBy │ │ createdAt │ │ createdBy │ └─────────────────┘ └─────────────────┘ │ createdAt │ │ updatedAt │ ┌─────────────────┐ ┌─────────────────┐ └─────────────────┘ │ ReportRun │ │ DataSource │ ├─────────────────┤ ├─────────────────┤ │ id (PK) │ │ id (PK) │ │ reportId (FK) │ │ tenantId (FK) │ │ status │ │ name │ │ startedAt │ │ type │ │ completedAt │ │ connection │ │ resultUrl │ │ config (JSON) │ │ rowCount │ │ status │ │ errorMessage │ └─────────────────┘ └─────────────────┘ ``` ### 1.2 PostgreSQL DDL ```sql -- Rapporter CREATE TABLE reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), name VARCHAR(255) NOT NULL, description TEXT, type VARCHAR(50) NOT NULL CHECK (type IN ('table','chart','pivot','map','dashboard')), config JSONB NOT NULL DEFAULT '{}', data_source_id UUID REFERENCES data_sources(id), created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- Körningshistorik CREATE TABLE report_runs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE, status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','completed','failed')), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, result_url TEXT, row_count INTEGER, error_message TEXT, triggered_by UUID REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT now() ); -- Scheman CREATE TABLE report_schedules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE, cron_expression VARCHAR(100) NOT NULL, timezone VARCHAR(50) DEFAULT 'Europe/Stockholm', next_run_at TIMESTAMPTZ, last_run_at TIMESTAMPTZ, status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active','paused','disabled')), created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT now() ); -- Delning CREATE TABLE report_shares ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE, shared_with_user_id UUID REFERENCES users(id), shared_with_email VARCHAR(255), permission VARCHAR(20) NOT NULL DEFAULT 'read' CHECK (permission IN ('read','write','admin')), expires_at TIMESTAMPTZ, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT now() ); -- Datakällor (befintlig tabell, utökas) CREATE TABLE data_sources ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), name VARCHAR(255) NOT NULL, type VARCHAR(50) NOT NULL CHECK (type IN ('postgresql','mysql','api','s3','bigquery','snowflake')), connection JSONB NOT NULL, config JSONB DEFAULT '{}', status VARCHAR(20) DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT now() ); -- Index CREATE INDEX idx_reports_tenant ON reports(tenant_id); CREATE INDEX idx_report_runs_report ON report_runs(report_id, created_at DESC); CREATE INDEX idx_schedules_next_run ON report_schedules(next_run_at) WHERE status = 'active'; CREATE INDEX idx_shares_user ON report_shares(shared_with_user_id); ``` --- ## 2. Rapport-API ### 2.1 REST Endpoints | Metod | Endpoint | Beskrivning | |-------|----------|-------------| | GET | `/api/v1/reports` | Lista rapporter (tenant-scoped) | | POST | `/api/v1/reports` | Skapa ny rapport | | GET | `/api/v1/reports/:id` | Hämta rapport med config | | PUT | `/api/v1/reports/:id` | Uppdatera rapport | | DELETE | `/api/v1/reports/:id` | Ta bort rapport | | POST | `/api/v1/reports/:id/run` | Kör rapport on-demand | | GET | `/api/v1/reports/:id/runs` | Körningshistorik | | GET | `/api/v1/reports/:id/runs/:runId/result` | Hämta resultat (JSON/CSV) | | POST | `/api/v1/reports/:id/export` | Exportera rapport | | POST | `/api/v1/reports/:id/schedule` | Skapa schema | | GET | `/api/v1/reports/:id/schedule` | Visa schema | | PUT | `/api/v1/reports/:id/schedule` | Uppdatera schema | | DELETE | `/api/v1/reports/:id/schedule` | Ta bort schema | | POST | `/api/v1/reports/:id/share` | Dela rapport | | GET | `/api/v1/reports/:id/shares` | Lista delningar | | DELETE | `/api/v1/reports/:id/shares/:shareId` | Återkalla delning | ### 2.2 OpenAPI-spec (kärn-endpoints) ```yaml openapi: 3.0.3 info: title: LandveX Reporting API version: 1.0.0 paths: /api/v1/reports: get: summary: Lista rapporter parameters: - name: tenantId in: query required: true schema: { type: string, format: uuid } - name: type in: query schema: { type: string, enum: [table, chart, pivot, map, dashboard] } - name: q in: query schema: { type: string } responses: 200: description: Lista med rapporter content: application/json: schema: type: object properties: data: type: array items: $ref: '#/components/schemas/Report' meta: type: object properties: total: { type: integer } page: { type: integer } pageSize: { type: integer } post: summary: Skapa rapport requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ReportInput' responses: 201: description: Skapad content: application/json: schema: $ref: '#/components/schemas/Report' /api/v1/reports/{id}/run: post: summary: Kör rapport parameters: - name: id in: path required: true schema: { type: string, format: uuid } requestBody: content: application/json: schema: type: object properties: parameters: type: object description: Körningsparametrar (ersätter default) format: type: string enum: [json, csv, xlsx, pdf] default: json responses: 202: description: Körs content: application/json: schema: $ref: '#/components/schemas/ReportRun' /api/v1/reports/{id}/export: post: summary: Exportera rapport parameters: - name: id in: path required: true schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: type: object properties: format: type: string enum: [csv, xlsx, pdf, json] default: csv filters: type: object email: type: string description: Skicka via e-post istället för nedladdning responses: 200: description: Fil eller bekräftelse 202: description: E-post kommer skickas /api/v1/reports/{id}/schedule: post: summary: Skapa schema parameters: - name: id in: path required: true schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ScheduleInput' responses: 201: description: Schema skapat components: schemas: Report: type: object properties: id: { type: string, format: uuid } tenantId: { type: string, format: uuid } name: { type: string } description: { type: string } type: { type: string, enum: [table, chart, pivot, map, dashboard] } config: { type: object } dataSourceId: { type: string, format: uuid } createdBy: { type: string, format: uuid } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } ReportInput: type: object required: [name, type, config, dataSourceId] properties: name: { type: string, minLength: 1, maxLength: 255 } description: { type: string } type: { type: string, enum: [table, chart, pivot, map, dashboard] } config: { type: object } dataSourceId: { type: string, format: uuid } ReportRun: type: object properties: id: { type: string, format: uuid } reportId: { type: string, format: uuid } status: { type: string, enum: [pending, running, completed, failed] } startedAt: { type: string, format: date-time } completedAt: { type: string, format: date-time } resultUrl: { type: string } rowCount: { type: integer } errorMessage: { type: string } ScheduleInput: type: object required: [cronExpression] properties: cronExpression: { type: string, example: "0 6 * * 1" } timezone: { type: string, default: "Europe/Stockholm" } status: { type: string, enum: [active, paused, disabled], default: active } ``` --- ## 3. Tre Standardrapporter ### 3.1 Rapport A: "Fastighetsöversikt per Kommun" **Typ:** Tabell + Karta **Beskrivning:** Översikt över alla fastigheter inom kommunens gränser med nyckeltal. ```json { "name": "Fastighetsöversikt per Kommun", "type": "dashboard", "description": "Sammanställning av fastigheter, areal och värden per kommun", "dataSourceId": "ds-landvex-gis", "config": { "widgets": [ { "type": "metric", "title": "Totalt antal fastigheter", "query": "SELECT COUNT(*) FROM properties WHERE municipality_id = :municipalityId" }, { "type": "metric", "title": "Total areal (ha)", "query": "SELECT SUM(area_hectares) FROM properties WHERE municipality_id = :municipalityId" }, { "type": "table", "title": "Fastigheter per typ", "query": "SELECT type, COUNT(*) as count, SUM(area_hectares) as total_area FROM properties WHERE municipality_id = :municipalityId GROUP BY type", "columns": [ { "field": "type", "header": "Fastighetstyp" }, { "field": "count", "header": "Antal" }, { "field": "total_area", "header": "Total areal (ha)" } ] }, { "type": "map", "title": "Fastighetskarta", "layer": "properties", "filter": { "municipality_id": ":municipalityId" } } ], "parameters": [ { "name": "municipalityId", "type": "string", "required": true, "label": "Kommun", "source": "tenant.config.municipality_id" } ], "defaultFormat": "pdf" } } ``` --- ### 3.2 Rapport B: "Transaktionshistorik med Trender" **Typ:** Diagram + Tabell **Beskrivning:** Historik över fastighetstransaktioner med prisutveckling och trender. ```json { "name": "Transaktionshistorik med Trender", "type": "chart", "description": "Transaktioner över tid med prisutveckling", "dataSourceId": "ds-landvex-transactions", "config": { "charts": [ { "type": "line", "title": "Prisutveckling över tid", "xAxis": { "field": "transaction_month", "label": "Månad" }, "yAxis": { "field": "avg_price_per_hectare", "label": "Kr/ha (genomsnitt)" }, "query": "SELECT DATE_TRUNC('month', transaction_date) as transaction_month, AVG(price_per_hectare) as avg_price_per_hectare FROM transactions WHERE municipality_id = :municipalityId AND transaction_date >= :fromDate GROUP BY 1 ORDER BY 1" }, { "type": "bar", "title": "Antal transaktioner per månad", "xAxis": { "field": "transaction_month" }, "yAxis": { "field": "transaction_count" }, "query": "SELECT DATE_TRUNC('month', transaction_date) as transaction_month, COUNT(*) as transaction_count FROM transactions WHERE municipality_id = :municipalityId AND transaction_date >= :fromDate GROUP BY 1 ORDER BY 1" } ], "detailTable": { "title": "Senaste transaktioner", "query": "SELECT t.id, t.property_id, p.address, t.transaction_date, t.price, t.area_hectares, t.price_per_hectare FROM transactions t JOIN properties p ON t.property_id = p.id WHERE t.municipality_id = :municipalityId ORDER BY t.transaction_date DESC LIMIT 100", "columns": [ { "field": "id", "header": "Transaktions-ID" }, { "field": "address", "header": "Fastighet" }, { "field": "transaction_date", "header": "Datum", "format": "date" }, { "field": "price", "header": "Pris (kr)", "format": "currency" }, { "field": "area_hectares", "header": "Areal (ha)", "format": "number" }, { "field": "price_per_hectare", "header": "Kr/ha", "format": "currency" } ] }, "parameters": [ { "name": "municipalityId", "type": "string", "required": true, "source": "tenant.config.municipality_id" }, { "name": "fromDate", "type": "date", "required": false, "default": "-1 year", "label": "Från datum" } ], "defaultFormat": "xlsx" } } ``` --- ### 3.3 Rapport C: "Skogsbruksplan — Åtgärdsförslag" **Typ:** Tabell + Pivot **Beskrivning:** Åtgärdsförslag från skogsbruksplaner med tidsplan och volymuppskattning. ```json { "name": "Skogsbruksplan — Åtgärdsförslag", "type": "pivot", "description": "Åtgärdsförslag från skogsbruksplaner sorterade på prioritet och tidsplan", "dataSourceId": "ds-landvex-forestry", "config": { "pivot": { "rows": ["action_type", "forest_stand_id"], "columns": ["planned_year"], "values": [ { "field": "estimated_volume", "aggregation": "sum", "format": "number" }, { "field": "estimated_cost", "aggregation": "sum", "format": "currency" } ] }, "sourceQuery": "SELECT fa.action_type, fa.forest_stand_id, fa.planned_year, fa.estimated_volume_m3 as estimated_volume, fa.estimated_cost, fa.priority, fa.status FROM forestry_actions fa JOIN forest_stands fs ON fa.forest_stand_id = fs.id WHERE fs.municipality_id = :municipalityId AND fa.planned_year BETWEEN :fromYear AND :toYear", "detailTable": { "title": "Åtgärdslista", "query": "SELECT fa.id, fa.action_type, fs.stand_name, fa.planned_year, fa.estimated_volume_m3, fa.estimated_cost, fa.priority, fa.status, fa.notes FROM forestry_actions fa JOIN forest_stands fs ON fa.forest_stand_id = fs.id WHERE fs.municipality_id = :municipalityId AND fa.planned_year BETWEEN :fromYear AND :toYear ORDER BY fa.priority, fa.planned_year", "columns": [ { "field": "action_type", "header": "Åtgärd" }, { "field": "stand_name", "header": "Bestånd" }, { "field": "planned_year", "header": "Planerat år" }, { "field": "estimated_volume_m3", "header": "Volym (m³)", "format": "number" }, { "field": "estimated_cost", "header": "Kostnad (kr)", "format": "currency" }, { "field": "priority", "header": "Prioritet" }, { "field": "status", "header": "Status" } ] }, "parameters": [ { "name": "municipalityId", "type": "string", "required": true, "source": "tenant.config.municipality_id" }, { "name": "fromYear", "type": "integer", "required": false, "default": "current_year", "label": "Från år" }, { "name": "toYear", "type": "integer", "required": false, "default": "current_year + 5", "label": "Till år" } ], "defaultFormat": "xlsx" } } ``` --- ## 4. Arkitektur & Integration ### 4.1 Komponentdiagram ``` ┌─────────────────────────────────────────────────────────────┐ │ LandveX Enterprise Platform │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Web UI │ │ Mobile App │ │ API-klient │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ └──────────────────┼──────────────────┘ │ │ ▼ │ │ ┌─────────────────────────┐ │ │ │ Reporting API │ │ │ │ (REST + Auth/tenant) │ │ │ └───────────┬─────────────┘ │ │ │ │ │ ┌────────────────┼────────────────┐ │ │ ▼ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Scheduler │ │ Query Eng. │ │ Export Svc │ │ │ │ (BullMQ) │ │ (PostgREST)│ │ (CSV/XLSX/ │ │ │ │ │ │ (Direct) │ │ PDF/JSON) │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ LandveX Datakällor │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌────────┐│ │ │ │ │GIS/PostGIS│ │Transakt.│ │Skogsbr. │ │ Extern ││ │ │ │ │ │ │ DB │ │ DB │ │ API ││ │ │ │ └─────────┘ └─────────┘ └─────────┘ └────────┘│ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Delning & Notifikationer │ │ │ │ • E-post (SendGrid/AWS SES) │ │ │ │ • Länk-baserad delning (token) │ │ │ │ • Inbäddad rapport (iframe) │ │ │ └─────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### 4.2 Scheduler-flöde (BullMQ) ``` ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Cron-jobb │────►│ Query Engine │────►│ Store result│ │ (var 1 min) │ │ (timeout 5m) │ │ (S3/DB) │ └──────────────┘ └──────────────┘ └──────────────┘ │ ▼ ┌──────────────┐ │ Notifiera │ │ (e-post/ │ │ webhook) │ └──────────────┘ ``` ### 4.3 Exempel: Köra rapport via API ```bash # 1. Skapa rapport curl -X POST https://api.landvex.se/v1/reports \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d @fastighetsöversikt.json # 2. Kör rapport curl -X POST https://api.landvex.se/v1/reports/$REPORT_ID/run \ -H "Authorization: Bearer $TOKEN" \ -d '{"format": "pdf", "parameters": {"municipalityId": "0180"}}' # → { "id": "run-uuid", "status": "pending" } # 3. Polling tills klar curl https://api.landvex.se/v1/reports/$REPORT_ID/runs/$RUN_ID \ -H "Authorization: Bearer $TOKEN" # → { "status": "completed", "resultUrl": "https://cdn.landvex.se/..." } # 4. Ladda ner resultat curl -O "$RESULT_URL" ``` --- ## 5. Säkerhet & Rättigheter | Nivå | Behörighet | |------|-----------| | `read` | Se rapport och resultat | | `write` | Redigera config, köra manuellt | | `admin` | Ändra schema, dela, ta bort | - **Tenant-isolering:** Alla queries filtreras automatiskt på `tenant_id` - **Parametriserade queries:** Inga strängkonkateneringar — använd prepared statements - **Resultat-URL:er:** Tidsbegränsade signerade URL:er (S3 presigned, 1h default) - **Audit-logg:** Alla körningar och exporter loggas med `user_id` och `timestamp` --- ## 6. Leverabler | Fil | Innehåll | |-----|----------| | `DESIGN.md` | Denna fil — översikt och design | | `schema.sql` | PostgreSQL DDL | | `openapi.yaml` | Fullständig OpenAPI-spec | | `examples/` | 3 standardrapporter (JSON) | | `architecture.md` | Djupare arkitekturbeskrivning |