Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3330a92a44 | ||
|
|
aa8abf4e56 |
@@ -0,0 +1,10 @@
|
||||
target
|
||||
.git
|
||||
.gitignore
|
||||
uploads
|
||||
*.log
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
docs
|
||||
README.md
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copy to `.env` on the server and fill in real values. NEVER commit the real .env.
|
||||
|
||||
# ---- Database (required) ----
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=change-me-to-a-strong-password
|
||||
|
||||
# ---- Email notifications (optional) ----
|
||||
MAIL_ENABLED=false
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM=no-reply@mvrent.com
|
||||
MAIL_ADMIN=admin@mvrent.com
|
||||
|
||||
# ---- GPS telematics ingest key (optional) ----
|
||||
TELEMATICS_API_KEY=
|
||||
|
||||
# ---- Google OAuth2 login (optional) ----
|
||||
GOOGLE_CLIENT_ID=not-configured
|
||||
GOOGLE_CLIENT_SECRET=not-configured
|
||||
+23
-135
@@ -1,138 +1,26 @@
|
||||
# ---> Java
|
||||
# Compiled class file
|
||||
*.class
|
||||
### Secrets / environment (NEVER commit real .env) ###
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Log file
|
||||
target/
|
||||
*.jar
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### IntelliJ ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
### Logs ###
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# ---> JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# ---> Maven
|
||||
target/
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
pom.xml.next
|
||||
release.properties
|
||||
dependency-reduced-pom.xml
|
||||
buildNumber.properties
|
||||
.mvn/timing.properties
|
||||
# https://github.com/takari/maven-wrapper#usage-without-binary-jar
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
# Eclipse m2e generated files
|
||||
# Eclipse Core
|
||||
.project
|
||||
# JDT-specific (Eclipse Java Development Tools)
|
||||
.classpath
|
||||
|
||||
### Uploaded files (configurable storage dir) ###
|
||||
uploads/
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Deploying MV Rent to a server (Docker Compose)
|
||||
|
||||
Target: `root@93.127.166.135`. The stack runs the Spring Boot app + PostgreSQL in
|
||||
containers. The server only needs Docker — no Java or Postgres install required.
|
||||
|
||||
---
|
||||
|
||||
## 1. Install Docker on the server (one time)
|
||||
|
||||
SSH in and install Docker + the Compose plugin:
|
||||
|
||||
```bash
|
||||
ssh root@93.127.166.135
|
||||
|
||||
# Ubuntu/Debian:
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker --version && docker compose version
|
||||
```
|
||||
|
||||
Open the firewall for HTTP (and HTTPS if you'll add TLS later):
|
||||
|
||||
```bash
|
||||
ufw allow 80/tcp && ufw allow 443/tcp # skip if ufw isn't enabled
|
||||
```
|
||||
|
||||
## 2. Copy the project to the server
|
||||
|
||||
From your **local machine** (in the project folder `car-rent`):
|
||||
|
||||
```bash
|
||||
# excludes build output, .git and local uploads
|
||||
rsync -az --delete \
|
||||
--exclude target --exclude .git --exclude uploads \
|
||||
./ root@93.127.166.135:/opt/mvrent/
|
||||
```
|
||||
|
||||
(Or `git clone` your repo into `/opt/mvrent` if it's pushed somewhere.)
|
||||
|
||||
## 3. Configure secrets
|
||||
|
||||
On the **server**:
|
||||
|
||||
```bash
|
||||
cd /opt/mvrent
|
||||
cp .env.example .env
|
||||
nano .env # set a strong DB_PASSWORD (and mail creds if you want email)
|
||||
```
|
||||
|
||||
## 4. Build & start
|
||||
|
||||
```bash
|
||||
cd /opt/mvrent
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
First run takes a few minutes (it builds the app image and downloads Postgres).
|
||||
On startup the app creates the schema and seeds demo data automatically.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
```bash
|
||||
docker compose ps # both services "running"/"healthy"
|
||||
docker compose logs -f app # look for "Started CarRentalApplication"
|
||||
curl -I http://localhost # expect HTTP 200/302
|
||||
```
|
||||
|
||||
Then open **http://93.127.166.135/** in a browser.
|
||||
|
||||
| Area | URL | Default login |
|
||||
|------|-----|---------------|
|
||||
| Public site | `/` | — |
|
||||
| Admin | `/admin/login` | `admin` / `admin123` |
|
||||
| Vendor portal | `/vendor/login` | created from admin |
|
||||
| Driver portal | `/driver/login` | created from admin |
|
||||
|
||||
> ⚠️ **Change the admin password immediately** (Admin → profile) and delete the
|
||||
> demo customer if this is production.
|
||||
|
||||
---
|
||||
|
||||
## Day-2 operations
|
||||
|
||||
**Update after code changes** (re-run rsync from step 2, then):
|
||||
```bash
|
||||
cd /opt/mvrent && docker compose up -d --build
|
||||
```
|
||||
|
||||
**Logs / restart / stop:**
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
docker compose restart app
|
||||
docker compose down # stop (keeps data volumes)
|
||||
```
|
||||
|
||||
**Back up the database:**
|
||||
```bash
|
||||
docker compose exec db pg_dump -U postgres mv_rent > backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
**Restore:**
|
||||
```bash
|
||||
cat backup-YYYY-MM-DD.sql | docker compose exec -T db psql -U postgres -d mv_rent
|
||||
```
|
||||
|
||||
Uploaded files persist in the `uploads` Docker volume; DB in `db-data`.
|
||||
|
||||
---
|
||||
|
||||
## Optional: domain + HTTPS (nginx + Let's Encrypt)
|
||||
|
||||
If you point a domain at the server, put nginx in front and get a free cert.
|
||||
First change the app's port mapping in `docker-compose.yml` from `"80:8080"` to
|
||||
`"127.0.0.1:8080:8080"` (so only nginx is public), then:
|
||||
|
||||
```bash
|
||||
apt install -y nginx certbot python3-certbot-nginx
|
||||
# create /etc/nginx/sites-available/mvrent -> proxy_pass http://127.0.0.1:8080;
|
||||
certbot --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
Ask me and I'll generate the exact nginx server block for your domain.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ---------- Build stage ----------
|
||||
FROM maven:3.9-eclipse-temurin-17 AS build
|
||||
WORKDIR /build
|
||||
# Cache dependencies first
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -q dependency:go-offline
|
||||
# Build the app
|
||||
COPY src ./src
|
||||
RUN mvn -B -q -DskipTests clean package
|
||||
|
||||
# ---------- Runtime stage ----------
|
||||
FROM eclipse-temurin:17-jre
|
||||
WORKDIR /app
|
||||
# Uploaded documents/photos live here (mounted as a volume)
|
||||
ENV STORAGE_DIR=/data/uploads
|
||||
RUN mkdir -p /data/uploads
|
||||
COPY --from=build /build/target/car-rental-0.0.1-SNAPSHOT.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
|
||||
@@ -1,2 +1,94 @@
|
||||
# vehicle_rent_management
|
||||
# EVM Wheels — Car Rental Management
|
||||
|
||||
A Spring Boot 3 + Thymeleaf + PostgreSQL web app for managing a self-drive car rental business.
|
||||
|
||||
## Features
|
||||
|
||||
- **Public site** — landing page with hero, search bar, and featured cars
|
||||
- **Browse & detail** — `/cars` (with optional date-range availability filter) and `/cars/{id}`
|
||||
- **Auth** — username/password registration & login (Spring Security + BCrypt)
|
||||
- **Customer flow** — book a car, view your bookings at `/my-bookings`
|
||||
- **Admin panel** — `/admin` dashboard, full CRUD for cars, manage all bookings & update status
|
||||
- **Date-range conflict detection** — prevents double-booking the same car
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Choice |
|
||||
|----------|--------------------------------|
|
||||
| Runtime | Java 17, Spring Boot 3.3 |
|
||||
| Web | Spring MVC + Thymeleaf |
|
||||
| Data | Spring Data JPA + Hibernate |
|
||||
| DB | PostgreSQL |
|
||||
| Security | Spring Security 6 + BCrypt |
|
||||
| Build | Maven |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- JDK 17+
|
||||
- Maven 3.9+
|
||||
- PostgreSQL 13+ running locally
|
||||
|
||||
## Database setup
|
||||
|
||||
```sql
|
||||
CREATE DATABASE evm_wheels;
|
||||
```
|
||||
|
||||
By default the app connects with `postgres` / `postgres` on `localhost:5432/evm_wheels`. Override via env vars or by editing [src/main/resources/application.properties](src/main/resources/application.properties):
|
||||
|
||||
```bash
|
||||
export DB_URL=jdbc:postgresql://localhost:5432/evm_wheels
|
||||
export DB_USER=postgres
|
||||
export DB_PASSWORD=postgres
|
||||
```
|
||||
|
||||
Hibernate's `ddl-auto=update` creates the schema on first run, and `data.sql` seeds 6 demo cars. Users are seeded from [DataInitializer.java](src/main/java/com/evmwheels/carrental/config/DataInitializer.java) using the real password encoder.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
Then open <http://localhost:8080>.
|
||||
|
||||
## Seeded accounts
|
||||
|
||||
| Role | Username | Password |
|
||||
|----------|----------|------------|
|
||||
| Admin | `admin` | `admin123` |
|
||||
| Customer | `demo` | `demo1234` |
|
||||
|
||||
Change these immediately if you deploy this anywhere real.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/main/java/com/evmwheels/carrental/
|
||||
├── CarRentalApplication.java
|
||||
├── config/ SecurityConfig, DataInitializer
|
||||
├── controller/ Home, Car, Booking, Auth, Admin
|
||||
├── model/ Car, User, Booking + enums
|
||||
├── repository/ Spring Data JPA interfaces
|
||||
└── service/ Business logic (booking conflict checks, etc.)
|
||||
|
||||
src/main/resources/
|
||||
├── application.properties
|
||||
├── data.sql seed cars
|
||||
├── static/{css,images}/ assets
|
||||
└── templates/ Thymeleaf views
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
| Method | Path | Access |
|
||||
|--------|----------------------------------|------------|
|
||||
| GET | `/` | public |
|
||||
| GET | `/cars`, `/cars/{id}` | public |
|
||||
| GET/POST | `/login`, `/register` | public |
|
||||
| GET/POST | `/cars/{id}/book` | customer |
|
||||
| GET | `/my-bookings` | customer |
|
||||
| GET | `/admin` | admin |
|
||||
| GET/POST | `/admin/cars`, `/admin/cars/**` | admin |
|
||||
| GET | `/admin/bookings` | admin |
|
||||
| POST | `/admin/bookings/{id}/status` | admin |
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mv_rent
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d mv_rent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_URL: jdbc:postgresql://db:5432/mv_rent
|
||||
DB_USER: ${DB_USER:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
STORAGE_DIR: /data/uploads
|
||||
# Optional integrations — safe defaults; override in .env to activate
|
||||
MAIL_ENABLED: ${MAIL_ENABLED:-false}
|
||||
MAIL_HOST: ${MAIL_HOST:-}
|
||||
MAIL_PORT: ${MAIL_PORT:-587}
|
||||
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-no-reply@mvrent.local}
|
||||
MAIL_ADMIN: ${MAIL_ADMIN:-admin@mvrent.local}
|
||||
TELEMATICS_API_KEY: ${TELEMATICS_API_KEY:-}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-not-configured}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-not-configured}
|
||||
volumes:
|
||||
- uploads:/data/uploads
|
||||
ports:
|
||||
# host:container — default 80 on the server; override HTTP_PORT locally
|
||||
- "${HTTP_PORT:-80}:8080"
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
uploads:
|
||||
@@ -0,0 +1,232 @@
|
||||
# Car Rental Management System — Implementation Plan
|
||||
|
||||
> Status: **DRAFT for review** · Date: 2026-06-26
|
||||
> Baseline: Spring Boot 3.3.4 · Java 17 · Thymeleaf · PostgreSQL · Spring Security (admin + customer chains, Google OAuth2)
|
||||
> Purpose: turn the current customer-booking + admin-CRUD app into the full multi-party platform described in the feature spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope summary
|
||||
|
||||
The current app covers ~25–30% of the spec (customer-facing booking + basic admin car/booking CRUD).
|
||||
This plan adds the four missing pillars — **Driver, Vendor, Billing/Settlement, Reporting** — plus the
|
||||
**Organization/config** foundation and enrichment of Fleet, Customer, and Booking.
|
||||
|
||||
Current coverage vs target:
|
||||
|
||||
| Area | Now | Target |
|
||||
|---|---|---|
|
||||
| Organization / Config | ~20% | Org profile, staff users + permissions, tax/currency/commission config, category masters |
|
||||
| Fleet & Car | ~45% | + year, colour, ownership, feature tags, hourly/monthly rates, documents+expiry, maintenance |
|
||||
| Driver | 0% | full module |
|
||||
| Vendor | 0% | full module + vendor login + settlement |
|
||||
| Customer | ~50% | + categories (Individual/Company/Corporate), GST/billing |
|
||||
| Trip Booking | ~40% | + with-driver, hours/months, tax & driver charges, invoice |
|
||||
| Billing/Settlement | ~5% | invoices, commission, settlements, profit split |
|
||||
| Reporting | 0% | filtered reports + Excel/PDF export, dashboards |
|
||||
| Customer page | ~55% | richer filters + driver option |
|
||||
|
||||
---
|
||||
|
||||
## 2. Target architecture
|
||||
|
||||
- **Single Spring Boot modular-monolith** (no microservices). Package-by-feature:
|
||||
`org`, `fleet`, `driver`, `vendor`, `customer`, `booking`, `billing`, `reporting`, `web`, `security`, `config`.
|
||||
- **Three security filter chains** (extends today's pattern):
|
||||
1. `/admin/**` → staff (ROLE_ADMIN, ROLE_STAFF) — existing admin chain, expanded
|
||||
2. `/vendor/**` → ROLE_VENDOR — **new** vendor portal chain
|
||||
3. everything else → customers + Google OAuth (existing customer chain)
|
||||
- **Persistence**: PostgreSQL + JPA. Move `ddl-auto=update` → **Flyway migrations** (versioned schema; required once money/settlement tables exist).
|
||||
- **Money**: always `BigDecimal`, scale 2, currency from config. Never `double`.
|
||||
- **Exports**: Apache POI (Excel), OpenPDF or Flying Saucer (HTML→PDF) for invoices/statements/reports.
|
||||
- **Server-side rendering** stays Thymeleaf; add small JS for dynamic price calc on the customer page.
|
||||
|
||||
---
|
||||
|
||||
## 3. Role & permission model
|
||||
|
||||
Replace the 2-value `Role` enum with a richer model:
|
||||
|
||||
```
|
||||
Role (enum): ADMIN, STAFF, VENDOR, CUSTOMER
|
||||
Permission (enum): MANAGE_FLEET, MANAGE_DRIVERS, MANAGE_VENDORS, MANAGE_BOOKINGS,
|
||||
MANAGE_BILLING, VIEW_REPORTS, MANAGE_CONFIG, MANAGE_USERS
|
||||
```
|
||||
|
||||
- `ADMIN` = all permissions. `STAFF` = a configurable subset (stored per-user).
|
||||
- `VENDOR` = scoped to own data only (own cars, own trips, own settlements).
|
||||
- Authorization: method-level `@PreAuthorize("hasAuthority('MANAGE_FLEET')")` on services/controllers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Domain model (entities & key fields)
|
||||
|
||||
New entities in **bold**; modified existing in _italics_.
|
||||
|
||||
### 4.1 Organization / Config
|
||||
- **Organization** — name, legalName, logoUrl, address, gstNumber, contactEmail, phone, currencyCode
|
||||
- **SystemConfig** (singleton) — currencyCode, defaultTaxPercent, defaultCommissionPercent,
|
||||
defaultDriverDailyCharge, invoicePrefix, settlementCycle (WEEKLY/MONTHLY)
|
||||
- **Category** (generic master) — `type` (VEHICLE / DRIVER / CUSTOMER), name, description, active
|
||||
→ replaces hard-coded enums so categories are admin-configurable
|
||||
- _User_ — add `permissions` (set), `vendor` (nullable FK for vendor-portal users)
|
||||
|
||||
### 4.2 Fleet
|
||||
- _Car_ — add: `yearOfRegistration`, `colour`, `ownership` (OWNED / VENDOR), `vendor` (nullable FK),
|
||||
`hourlyRate`, `dailyRate` (rename pricePerDay), `monthlyRate`, `unitsTotal`,
|
||||
`featureTags` (AC, GPS, … as a set/element-collection), `status` (ACTIVE / MAINTENANCE / RETIRED)
|
||||
- **CarDocument** — car FK, docType (RC / INSURANCE / PERMIT / POLLUTION), fileUrl, issuedDate, expiryDate
|
||||
- **CarPhoto** — car FK, url, sortOrder (gallery instead of single imageUrl)
|
||||
|
||||
### 4.3 Driver
|
||||
- **Driver** — name, phone, licenceNumber, licenceExpiry, yearsExperience, category (FK Category),
|
||||
employmentType (EMPLOYED / VENDOR), vendor (nullable FK), hourlyCharge, dailyCharge, monthlyCharge,
|
||||
status (AVAILABLE / ON_TRIP / OFF), photoUrl
|
||||
- **DriverDocument** — driver FK, docType, fileUrl, expiryDate
|
||||
- Trip history derived from `Booking` where `driver = X`
|
||||
|
||||
### 4.4 Vendor
|
||||
- **Vendor** — name, contactPerson, phone, email, address, gstNumber,
|
||||
bankAccount/settlementDetails, commissionPercent (nullable → overrides global), active
|
||||
- Vendor login = a `User` with ROLE_VENDOR linked to the Vendor
|
||||
- **VendorSettlement** — vendor FK, periodStart, periodEnd, grossRevenue, commissionAmount,
|
||||
driverCharges, netPayable, status (DRAFT / FINALISED / PAID), generatedAt
|
||||
|
||||
### 4.5 Customer
|
||||
- _User_ (customer) — add: `customerCategory` (INDIVIDUAL / COMPANY / CORPORATE),
|
||||
`companyName`, `gstNumber`, `billingAddress`, `contactPersons` (for company/corporate)
|
||||
|
||||
### 4.6 Booking & Billing
|
||||
- _Booking_ — add: `driver` (nullable FK), `withDriver` (bool), `rentalUnit` (HOUR / DAY / MONTH),
|
||||
`quantity` (units of the period), `vehicleCharge`, `driverCharge`, `taxPercent`, `taxAmount`,
|
||||
`commissionPercent`, `commissionAmount`, `revenueOwner` (OWNED / VENDOR), `vendor` (nullable FK)
|
||||
- **Invoice** — booking FK, invoiceNumber, issuedAt, lineItems, subtotal, tax, total, pdfUrl
|
||||
- **CommissionRule** — scope (GLOBAL / VENDOR / VEHICLE), refId, percent
|
||||
(resolution order: vehicle → vendor → global)
|
||||
|
||||
### 4.7 ER overview (relationships)
|
||||
```
|
||||
Organization 1─* User
|
||||
User *─1 Vendor (vendor-portal users) Vendor 1─* Car
|
||||
Vendor 1─* Driver Car 1─* CarDocument / CarPhoto
|
||||
Category 1─* Car / Driver / User(customer) Driver 1─* DriverDocument
|
||||
Customer(User) 1─* Booking Car 1─* Booking
|
||||
Driver 1─* Booking (optional) Booking 1─1 Invoice
|
||||
Vendor 1─* Booking (revenue attribution) Vendor 1─* VendorSettlement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Calculation rules (single source of truth)
|
||||
|
||||
```
|
||||
periodCount = ceil(duration in chosen unit) // HOUR / DAY / MONTH
|
||||
vehicleCharge = rate(unit) × periodCount × units
|
||||
driverCharge = withDriver ? driverRate(unit) × periodCount : 0
|
||||
subtotal = vehicleCharge + driverCharge
|
||||
taxAmount = subtotal × taxPercent/100
|
||||
total = subtotal + taxAmount
|
||||
|
||||
// Vendor settlement (per vendor trip)
|
||||
commissionPercent = resolve(vehicle → vendor → global)
|
||||
commissionAmount = vehicleCharge × commissionPercent/100
|
||||
netVendorPayable = vehicleCharge − commissionAmount + (vendorDriverCharge − orgDriverMargin)
|
||||
|
||||
// Owned vs vendor profit split tracked via Booking.revenueOwner
|
||||
```
|
||||
All money `BigDecimal`, `RoundingMode.HALF_UP`, scale 2, currency from `SystemConfig`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Screen inventory
|
||||
|
||||
**Admin / Staff (`/admin`)**
|
||||
- Dashboard (KPIs + owned-vs-vendor split), Fleet (list/form + docs/photos/maintenance),
|
||||
Drivers (list/form/profile + trip history), Vendors (list/form + settlements),
|
||||
Customers (list/detail), Bookings (list/detail/assign-driver/invoice),
|
||||
Billing & Settlements, Reports (filter + export), Settings (org, config, categories, staff & permissions)
|
||||
|
||||
**Vendor portal (`/vendor`)** — new
|
||||
- Login, Dashboard (own trips/revenue), My Cars, My Drivers, Trips (period filter),
|
||||
Settlements (view + export PDF/Excel)
|
||||
|
||||
**Customer page (public)**
|
||||
- Home, Car listing **with filters** (category, date, seating, transmission, price, driver option) + live price,
|
||||
Car detail, Booking (with/without driver + category), Register/Login, My Bookings + invoices
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased delivery plan
|
||||
|
||||
Each phase is independently shippable and ends with a working app + tests.
|
||||
|
||||
### Phase 0 — Foundations (enablers)
|
||||
- Add Flyway; convert current schema to `V1__baseline.sql`; turn off `ddl-auto`
|
||||
- Expand `Role` + add `Permission`; method security
|
||||
- `Organization` + `SystemConfig` + `Category` master; Settings screens
|
||||
- Replace hard-coded category enums with `Category` lookups (keep enums as seed data)
|
||||
|
||||
### Phase 1 — Fleet enrichment
|
||||
- Car: year, colour, ownership, feature tags, hourly/daily/monthly rates, units, status(maintenance)
|
||||
- CarDocument + CarPhoto with expiry tracking + expiry warnings on dashboard
|
||||
- Maintenance blocking in availability logic
|
||||
|
||||
### Phase 2 — Driver module
|
||||
- Driver entity + documents + categories + charges + availability
|
||||
- Driver list/form/profile + trip history; driver-availability check
|
||||
|
||||
### Phase 3 — Vendor module
|
||||
- Vendor onboarding + ROLE_VENDOR login + vendor filter chain (`/vendor/**`)
|
||||
- Vendor dashboard (own cars/drivers/trips); commission config (global/vendor/vehicle)
|
||||
|
||||
### Phase 4 — Booking upgrades
|
||||
- With/without driver + driver category assignment
|
||||
- Hourly/daily/monthly periods; tax + driver-charge calc; revenue attribution
|
||||
- Driver double-booking prevention; invoice generation (PDF)
|
||||
|
||||
### Phase 5 — Billing & settlement
|
||||
- Invoices/receipts for customers
|
||||
- Vendor settlement aggregation per cycle + statement (Excel + PDF) + profit split
|
||||
|
||||
### Phase 6 — Reporting
|
||||
- Filtered reports (date, vehicle, category, driver, vendor, customer category, ownership, status)
|
||||
- Excel + PDF export; summary dashboard (owned vs vendor); vendor-wise & driver-wise reports
|
||||
|
||||
### Phase 7 — Customer page polish
|
||||
- Full filter set + live cost-by-duration; with-driver booking flow; responsive QA
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependencies to add
|
||||
- `flyway-core` + `flyway-database-postgresql`
|
||||
- `org.apache.poi:poi-ooxml` (Excel)
|
||||
- `com.github.librepdf:openpdf` **or** `org.xhtmlrenderer:flying-saucer-pdf` (PDF from Thymeleaf)
|
||||
- (already present: web, security, oauth2-client, data-jpa, validation, thymeleaf, postgresql, lombok)
|
||||
|
||||
---
|
||||
|
||||
## 9. Cross-cutting concerns
|
||||
- **Migrations**: every schema change via Flyway; never edit a shipped migration.
|
||||
- **Validation**: bean validation + show `th:errors` on all admin/vendor forms (current admin forms lack this).
|
||||
- **Auditing**: `createdAt/updatedAt/createdBy` via JPA auditing on money-bearing entities.
|
||||
- **Performance**: fix N+1 in booking/report queries with fetch-joins; add pagination to all admin tables.
|
||||
- **Files**: documents/photos need storage — decide local disk vs S3-compatible (config-driven path).
|
||||
- **Testing**: service-layer unit tests for all calc rules; `@WebMvcTest` for access control per chain.
|
||||
- **Seed data**: keep `DataInitializer` for org/config/admin + demo vendor/driver/cars.
|
||||
|
||||
---
|
||||
|
||||
## 10. Decisions (LOCKED — 2026-06-26, recommended defaults accepted)
|
||||
1. **File storage** → **local filesystem** (config-driven path) for v1.
|
||||
2. **PDF library** → **Flying Saucer** (Thymeleaf HTML/CSS → PDF).
|
||||
3. **Tenancy** → **single organization** per install.
|
||||
4. **Staff permissions** → **per-user permission checkboxes** (granular `Permission` set).
|
||||
5. **Settlement cycle** → **monthly** default.
|
||||
6. **Bookings** → **day/month** for v1 (hourly *rate fields* stored, hourly *booking flow* deferred).
|
||||
|
||||
### Deviation from §7
|
||||
- **Flyway is deferred** to a later hardening phase. During active development the app keeps
|
||||
`spring.jpa.hibernate.ddl-auto=update`, which additively creates the new tables/columns without
|
||||
disrupting the running PostgreSQL database. Flyway baseline will be introduced before production.
|
||||
|
||||
> Plan locked. Implementation started with Phase 0 — Foundations.
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.3.4</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.mvrent</groupId>
|
||||
<artifactId>car-rental</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>car-rental</name>
|
||||
<description>MV Rent — Car Rental Management</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<!-- Override Spring Boot's 1.18.34: needed for JDK 23+/25 (fixes TypeTag :: UNKNOWN) -->
|
||||
<lombok.version>1.18.40</lombok.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.thymeleaf.extras</groupId>
|
||||
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<!-- PDF: Thymeleaf HTML -> PDF (Flying Saucer + OpenPDF) -->
|
||||
<dependency>
|
||||
<groupId>org.xhtmlrenderer</groupId>
|
||||
<artifactId>flying-saucer-pdf-openpdf</artifactId>
|
||||
<version>9.1.22</version>
|
||||
</dependency>
|
||||
<!-- Excel export -->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.mvrent.carrental;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class CarRentalApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CarRentalApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/** Enables @Async so outbound email/SMS never blocks the request thread. */
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import com.mvrent.carrental.service.AuditService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.AfterReturning;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Pointcut;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Automatically records every mutating service-layer call (save / create /
|
||||
* update / delete / add / remove / generate) to the audit trail. Argument
|
||||
* <em>values</em> are never logged (to avoid leaking passwords etc.) — only the
|
||||
* action, entity type and id.
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuditAspect {
|
||||
|
||||
private final AuditService auditService;
|
||||
|
||||
@Pointcut("execution(* com.mvrent.carrental.service.*.*(..))")
|
||||
void serviceLayer() {}
|
||||
|
||||
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
|
||||
public void afterMutation(JoinPoint jp, Object result) {
|
||||
String method = jp.getSignature().getName();
|
||||
String action = mapAction(method);
|
||||
if (action == null) return;
|
||||
|
||||
String cls = jp.getSignature().getDeclaringType().getSimpleName();
|
||||
// Avoid recursion / noise from the auditing beans themselves.
|
||||
if (cls.equals("AuditService") || cls.equals("AuditWriter")) return;
|
||||
|
||||
String entityType = cls.endsWith("Service") ? cls.substring(0, cls.length() - "Service".length()) : cls;
|
||||
String entityId = extractId(result, jp.getArgs());
|
||||
auditService.log(action, entityType, entityId, method);
|
||||
}
|
||||
|
||||
private static String mapAction(String m) {
|
||||
if (m.startsWith("delete") || m.startsWith("remove")) return "DELETE";
|
||||
if (m.startsWith("add") || m.startsWith("create") || m.startsWith("register")) return "CREATE";
|
||||
if (m.startsWith("generate")) return "GENERATE";
|
||||
if (m.startsWith("save") || m.startsWith("update") || m.startsWith("change")) return "UPDATE";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Best-effort id: from the returned entity, else the first Long argument. */
|
||||
private static String extractId(Object result, Object[] args) {
|
||||
String fromResult = idOf(result);
|
||||
if (fromResult != null) return fromResult;
|
||||
if (args != null) {
|
||||
for (Object a : args) {
|
||||
if (a instanceof Long l) return String.valueOf(l);
|
||||
}
|
||||
for (Object a : args) {
|
||||
String id = idOf(a);
|
||||
if (id != null) return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String idOf(Object obj) {
|
||||
if (obj == null) return null;
|
||||
try {
|
||||
Method getId = obj.getClass().getMethod("getId");
|
||||
Object id = getId.invoke(obj);
|
||||
return id != null ? String.valueOf(id) : null;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import com.mvrent.carrental.service.AuditService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
|
||||
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Records login successes and failures in the audit trail. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthEventListener {
|
||||
|
||||
private final AuditService auditService;
|
||||
|
||||
@EventListener
|
||||
public void onSuccess(AuthenticationSuccessEvent event) {
|
||||
auditService.logAuth("LOGIN_SUCCESS", event.getAuthentication().getName());
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onFailure(AbstractAuthenticationFailureEvent event) {
|
||||
Object principal = event.getAuthentication() != null ? event.getAuthentication().getPrincipal() : null;
|
||||
auditService.logAuth("LOGIN_FAILURE", principal != null ? String.valueOf(principal) : "unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
/**
|
||||
* Exposes the configurable currency symbol/code (from application.properties) to
|
||||
* every server-rendered view as {@code currencySymbol} / {@code currencyCode}.
|
||||
*/
|
||||
@ControllerAdvice
|
||||
public class CurrencyAdvice {
|
||||
|
||||
private final String symbol;
|
||||
private final String code;
|
||||
|
||||
public CurrencyAdvice(@Value("${app.currency.symbol}") String symbol,
|
||||
@Value("${app.currency.code}") String code) {
|
||||
this.symbol = symbol;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
@ModelAttribute("currencySymbol")
|
||||
public String currencySymbol() {
|
||||
return symbol;
|
||||
}
|
||||
|
||||
@ModelAttribute("currencyCode")
|
||||
public String currencyCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import com.mvrent.carrental.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Exposes the logged-in user's display details to every view as {@code cu}
|
||||
* (used by the topbar avatar / profile dropdown).
|
||||
*/
|
||||
@ControllerAdvice
|
||||
@RequiredArgsConstructor
|
||||
public class CurrentUserAdvice {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@ModelAttribute("cu")
|
||||
public Map<String, String> currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
|
||||
return null;
|
||||
}
|
||||
return userRepository.findByUsername(auth.getName()).map(u -> {
|
||||
Map<String, String> m = new HashMap<>();
|
||||
String fullName = (u.getFullName() != null && !u.getFullName().isBlank())
|
||||
? u.getFullName() : u.getUsername();
|
||||
m.put("username", u.getUsername());
|
||||
m.put("fullName", fullName);
|
||||
m.put("email", u.getEmail());
|
||||
m.put("role", u.getRole().name());
|
||||
m.put("initials", initials(fullName));
|
||||
return m;
|
||||
}).orElse(null);
|
||||
}
|
||||
|
||||
private static String initials(String name) {
|
||||
String[] parts = name.trim().split("\\s+");
|
||||
if (parts.length >= 2 && !parts[0].isEmpty() && !parts[1].isEmpty()) {
|
||||
return ("" + parts[0].charAt(0) + parts[1].charAt(0)).toUpperCase();
|
||||
}
|
||||
return name.substring(0, Math.min(2, name.length())).toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import com.mvrent.carrental.model.*;
|
||||
import com.mvrent.carrental.repository.CarRepository;
|
||||
import com.mvrent.carrental.repository.DriverRepository;
|
||||
import com.mvrent.carrental.repository.MaintenanceRepository;
|
||||
import com.mvrent.carrental.repository.ReviewRepository;
|
||||
import com.mvrent.carrental.repository.UserRepository;
|
||||
import com.mvrent.carrental.repository.VehicleLocationRepository;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.VendorService;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.EnumSet;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final DriverRepository driverRepository;
|
||||
private final CarRepository carRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ConfigService configService;
|
||||
private final VendorService vendorService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final MaintenanceRepository maintenanceRepository;
|
||||
private final ReviewRepository reviewRepository;
|
||||
private final VehicleLocationRepository vehicleLocationRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
// Fill new fleet columns on rows created before Phase 1 (null-safe, SQL level).
|
||||
backfillCarDefaults();
|
||||
|
||||
// Organization + system config singletons (created on first access)
|
||||
configService.getOrganization();
|
||||
configService.getSystemConfig();
|
||||
seedCategories();
|
||||
|
||||
seedAdmin();
|
||||
seed("demo", "demo@mvrent.local", "demo1234", "Demo Customer", Role.CUSTOMER);
|
||||
seedDrivers();
|
||||
seedVendor();
|
||||
seedDemoCustomerCategory();
|
||||
seedCarFeatures();
|
||||
seedTelematics();
|
||||
seedMaintenance();
|
||||
seedReviews();
|
||||
}
|
||||
|
||||
/** Demo GPS positions across Kerala so the Live Tracking map isn't empty. */
|
||||
private void seedTelematics() {
|
||||
if (vehicleLocationRepository.count() > 0) return;
|
||||
double[][] coords = {
|
||||
{9.9312, 76.2673}, // Kochi
|
||||
{8.5241, 76.9366}, // Trivandrum
|
||||
{11.2588, 75.7804}, // Kozhikode
|
||||
{10.5276, 76.2144}, // Thrissur
|
||||
{9.5916, 76.5222} // Kottayam
|
||||
};
|
||||
java.util.List<Car> cars = carRepository.findAll();
|
||||
int i = 0;
|
||||
for (Car c : cars) {
|
||||
if (i >= coords.length) break;
|
||||
vehicleLocationRepository.save(VehicleLocation.builder()
|
||||
.car(c).latitude(coords[i][0]).longitude(coords[i][1])
|
||||
.speedKph(i % 2 == 0 ? 0.0 : 42.0).heading(90.0 * i)
|
||||
.odometer(38000 + i * 5200).ignitionOn(i % 2 != 0)
|
||||
.updatedAt(java.time.LocalDateTime.now().minusMinutes(i))
|
||||
.build());
|
||||
i++;
|
||||
}
|
||||
if (i > 0) log.info("Seeded {} demo vehicle locations", i);
|
||||
}
|
||||
|
||||
private void seedMaintenance() {
|
||||
if (maintenanceRepository.count() > 0) return;
|
||||
carRepository.findAll().stream().findFirst().ifPresent(c ->
|
||||
maintenanceRepository.save(MaintenanceRecord.builder()
|
||||
.car(c).type(MaintenanceType.SERVICE).status(MaintenanceStatus.COMPLETED)
|
||||
.serviceDate(java.time.LocalDate.now().minusMonths(2)).odometer(36500)
|
||||
.cost(new BigDecimal("4800")).garage("AutoCare Kochi")
|
||||
.notes("Engine oil, filter, brake pads")
|
||||
.nextServiceDate(java.time.LocalDate.now().plusMonths(4)).nextServiceOdometer(46500)
|
||||
.build()));
|
||||
log.info("Seeded a demo maintenance record");
|
||||
}
|
||||
|
||||
private void seedReviews() {
|
||||
if (reviewRepository.count() > 0) return;
|
||||
User demo = userRepository.findByUsername("demo").orElse(null);
|
||||
if (demo == null) return;
|
||||
carRepository.findAll().stream().findFirst().ifPresent(c -> {
|
||||
reviewRepository.save(Review.builder().car(c).user(demo).rating(5)
|
||||
.comment("Spotless car and a smooth booking experience. Highly recommend!")
|
||||
.approved(true).build());
|
||||
reviewRepository.save(Review.builder().car(c).user(demo).rating(4)
|
||||
.comment("Great value for money. Pickup was quick.")
|
||||
.approved(true).build());
|
||||
});
|
||||
log.info("Seeded demo reviews");
|
||||
}
|
||||
|
||||
private void seedCarFeatures() {
|
||||
java.util.List<Car> cars = carRepository.findAll();
|
||||
boolean anyTags = cars.stream().anyMatch(c -> !c.getFeatureTags().isEmpty());
|
||||
if (anyTags) return;
|
||||
int i = 0;
|
||||
for (Car c : cars) {
|
||||
java.util.Set<String> tags = new java.util.LinkedHashSet<>();
|
||||
tags.add("AC");
|
||||
if (i % 2 == 0) { tags.add("GPS"); tags.add("Bluetooth"); }
|
||||
if (c.getSeats() >= 7) tags.add("Spacious");
|
||||
if (c.getFuelType() == FuelType.ELECTRIC) tags.add("Fast Charging");
|
||||
c.setFeatureTags(tags);
|
||||
carRepository.save(c);
|
||||
i++;
|
||||
}
|
||||
log.info("Seeded feature tags on {} cars", cars.size());
|
||||
}
|
||||
|
||||
private void seedDemoCustomerCategory() {
|
||||
userRepository.findByUsername("demo").ifPresent(demo -> {
|
||||
if (demo.getCustomerCategory() == null) {
|
||||
configService.categoriesOfType(CategoryType.CUSTOMER).stream()
|
||||
.filter(c -> c.getName().equals("Individual")).findFirst()
|
||||
.ifPresent(cat -> {
|
||||
demo.setCustomerCategory(cat);
|
||||
userRepository.save(demo);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void seedVendor() {
|
||||
// Idempotent: get-or-create the demo vendor, then ensure portal user + attachments.
|
||||
Vendor vendor = vendorService.findAll().stream()
|
||||
.filter(v -> v.getName().equals("Kerala Cabs")).findFirst()
|
||||
.orElseGet(() -> vendorService.save(Vendor.builder()
|
||||
.name("Kerala Cabs").contactPerson("Joby Thomas")
|
||||
.phone("+91 9847000000").email("ops@keralacabs.local")
|
||||
.gstNumber("32ABCDE1234F1Z5").settlementDetails("A/C 00112233, IFSC SBIN0001234")
|
||||
.commissionPercent(new BigDecimal("12")).active(true).build()));
|
||||
|
||||
if (!userRepository.existsByUsername("vendor1")) {
|
||||
try {
|
||||
vendorService.createPortalUser(vendor.getId(), "vendor1", "vendor1@mvrent.local", "vendor123");
|
||||
log.info("Seeded vendor 'Kerala Cabs' + portal login vendor1/vendor123");
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// portal user already exists
|
||||
}
|
||||
}
|
||||
|
||||
// Attach one car + one driver to the vendor for demo (only if none yet attached)
|
||||
if (carRepository.countByVendorId(vendor.getId()) == 0) {
|
||||
carRepository.findAll().stream().findFirst().ifPresent(c -> {
|
||||
c.setOwnership(CarOwnership.VENDOR);
|
||||
c.setVendor(vendor);
|
||||
carRepository.save(c);
|
||||
});
|
||||
}
|
||||
driverRepository.findByVendorIdOrderByName(vendor.getId()).stream().findAny()
|
||||
.ifPresentOrElse(d -> { }, () ->
|
||||
driverRepository.findAll().stream()
|
||||
.filter(d -> d.getName().contains("Suresh")).findFirst().ifPresent(d -> {
|
||||
d.setEmploymentType(DriverEmploymentType.VENDOR);
|
||||
d.setVendor(vendor);
|
||||
driverRepository.save(d);
|
||||
}));
|
||||
}
|
||||
|
||||
private void seedDrivers() {
|
||||
if (driverRepository.count() > 0) return;
|
||||
Category standard = configService.categoriesOfType(CategoryType.DRIVER).stream()
|
||||
.filter(c -> c.getName().equals("Standard")).findFirst().orElse(null);
|
||||
Category premium = configService.categoriesOfType(CategoryType.DRIVER).stream()
|
||||
.filter(c -> c.getName().equals("Premium")).findFirst().orElse(null);
|
||||
|
||||
driverRepository.save(Driver.builder()
|
||||
.name("Rajesh Kumar").phone("+91 9847012345").licenceNumber("KL07-2018-0012345")
|
||||
.licenceExpiry(java.time.LocalDate.now().plusYears(2)).yearsExperience(6)
|
||||
.category(standard).employmentType(DriverEmploymentType.EMPLOYED)
|
||||
.dailyCharge(new java.math.BigDecimal("800")).status(DriverStatus.AVAILABLE).build());
|
||||
|
||||
driverRepository.save(Driver.builder()
|
||||
.name("Suresh Nair").phone("+91 9847098765").licenceNumber("KL01-2015-0098765")
|
||||
.licenceExpiry(java.time.LocalDate.now().plusYears(1)).yearsExperience(11)
|
||||
.category(premium).employmentType(DriverEmploymentType.EMPLOYED)
|
||||
.dailyCharge(new java.math.BigDecimal("1200")).status(DriverStatus.AVAILABLE).build());
|
||||
|
||||
log.info("Seeded 2 demo drivers");
|
||||
}
|
||||
|
||||
private void seedAdmin() {
|
||||
User admin = userRepository.findByUsername("admin").orElse(null);
|
||||
if (admin == null) {
|
||||
admin = User.builder()
|
||||
.username("admin")
|
||||
.email("admin@mvrent.local")
|
||||
.password(passwordEncoder.encode("admin123"))
|
||||
.fullName("MV Rent Admin")
|
||||
.role(Role.ADMIN)
|
||||
.permissions(EnumSet.allOf(Permission.class))
|
||||
.enabled(true)
|
||||
.build();
|
||||
userRepository.save(admin);
|
||||
log.info("Seeded ADMIN user 'admin' (password: admin123) with all permissions");
|
||||
} else if (admin.getRole() == Role.ADMIN && admin.getPermissions().isEmpty()) {
|
||||
// Backfill permissions for an admin created before the permission model existed.
|
||||
admin.setPermissions(EnumSet.allOf(Permission.class));
|
||||
userRepository.save(admin);
|
||||
log.info("Backfilled all permissions onto existing ADMIN user 'admin'");
|
||||
}
|
||||
}
|
||||
|
||||
private void seed(String username, String email, String rawPassword, String fullName, Role role) {
|
||||
if (userRepository.existsByUsername(username)) return;
|
||||
User user = User.builder()
|
||||
.username(username)
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(rawPassword))
|
||||
.fullName(fullName)
|
||||
.role(role)
|
||||
.enabled(true)
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
log.info("Seeded {} user '{}' (password: {})", role, username, rawPassword);
|
||||
}
|
||||
|
||||
private void seedCategories() {
|
||||
if (!configService.allCategories().isEmpty()) return;
|
||||
// Vehicle categories
|
||||
for (String n : new String[]{"Hatchback", "Sedan", "SUV", "MUV", "Luxury", "Bike"}) {
|
||||
safeAdd(CategoryType.VEHICLE, n);
|
||||
}
|
||||
// Driver categories
|
||||
for (String n : new String[]{"Standard", "Premium", "Chauffeur"}) {
|
||||
safeAdd(CategoryType.DRIVER, n);
|
||||
}
|
||||
// Customer categories
|
||||
for (String n : new String[]{"Individual", "Company", "Corporate"}) {
|
||||
safeAdd(CategoryType.CUSTOMER, n);
|
||||
}
|
||||
log.info("Seeded default vehicle/driver/customer categories");
|
||||
}
|
||||
|
||||
private void backfillCarDefaults() {
|
||||
try {
|
||||
// Drop the stale enum CHECK constraint Hibernate created when Role was
|
||||
// only {ADMIN, CUSTOMER}; ddl-auto=update never widens it for STAFF/VENDOR.
|
||||
jdbcTemplate.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check");
|
||||
|
||||
jdbcTemplate.update("UPDATE cars SET status = 'ACTIVE' WHERE status IS NULL");
|
||||
jdbcTemplate.update("UPDATE cars SET ownership = 'OWNED' WHERE ownership IS NULL");
|
||||
jdbcTemplate.update("UPDATE cars SET units_total = 1 WHERE units_total IS NULL");
|
||||
|
||||
// Phase 4: fill new booking columns on pre-existing rows.
|
||||
jdbcTemplate.update("UPDATE bookings SET with_driver = false WHERE with_driver IS NULL");
|
||||
jdbcTemplate.update("UPDATE bookings SET rental_unit = 'DAY' WHERE rental_unit IS NULL");
|
||||
jdbcTemplate.update("UPDATE bookings SET quantity = 1 WHERE quantity IS NULL");
|
||||
} catch (DataAccessException ex) {
|
||||
log.warn("Schema backfill skipped: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void safeAdd(CategoryType type, String name) {
|
||||
try {
|
||||
configService.addCategory(type, name, null);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// already present
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* Standalone so the {@link PasswordEncoder} has no dependency on SecurityConfig —
|
||||
* avoids a circular reference now that services (OAuth2LoginService) also need it.
|
||||
*/
|
||||
@Configuration
|
||||
public class PasswordConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import com.mvrent.carrental.service.CustomUserDetailsService;
|
||||
import com.mvrent.carrental.service.OAuth2LoginService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final OAuth2LoginService oAuth2LoginService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Bean
|
||||
public DaoAuthenticationProvider authenticationProvider() {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
|
||||
provider.setUserDetailsService(userDetailsService);
|
||||
provider.setPasswordEncoder(passwordEncoder);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telematics ingest chain — applies only to /api/telematics/**.
|
||||
* Stateless and CSRF-exempt; authenticated by an API key in the controller,
|
||||
* so GPS devices can POST pings directly without a session.
|
||||
*/
|
||||
@Bean
|
||||
@Order(0)
|
||||
public SecurityFilterChain telematicsSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/api/telematics/**")
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(
|
||||
org.springframework.security.config.http.SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin module chain — applies only to /admin/**.
|
||||
* Has its own login page (/admin/login) and logout (/admin/logout),
|
||||
* completely separate from the customer/citizen login flow below.
|
||||
*/
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/admin/**")
|
||||
.authenticationProvider(authenticationProvider())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/admin/login").permitAll()
|
||||
.anyRequest().hasAnyRole("ADMIN", "STAFF")
|
||||
)
|
||||
.formLogin(form -> form
|
||||
.loginPage("/admin/login")
|
||||
.loginProcessingUrl("/admin/login")
|
||||
.defaultSuccessUrl("/admin", true)
|
||||
.failureUrl("/admin/login?error")
|
||||
.permitAll()
|
||||
)
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/admin/logout")
|
||||
.logoutSuccessUrl("/admin/login?logout")
|
||||
.permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vendor portal chain — applies only to /vendor/**.
|
||||
* Own login (/vendor/login) and logout (/vendor/logout); ROLE_VENDOR only.
|
||||
*/
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain vendorSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/vendor/**")
|
||||
.authenticationProvider(authenticationProvider())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/vendor/login").permitAll()
|
||||
.anyRequest().hasRole("VENDOR")
|
||||
)
|
||||
.formLogin(form -> form
|
||||
.loginPage("/vendor/login")
|
||||
.loginProcessingUrl("/vendor/login")
|
||||
.defaultSuccessUrl("/vendor", true)
|
||||
.failureUrl("/vendor/login?error")
|
||||
.permitAll()
|
||||
)
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/vendor/logout")
|
||||
.logoutSuccessUrl("/vendor/login?logout")
|
||||
.permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver portal chain — applies only to /driver/**.
|
||||
* Own login (/driver/login) and logout (/driver/logout); ROLE_DRIVER only.
|
||||
*/
|
||||
@Bean
|
||||
@Order(3)
|
||||
public SecurityFilterChain driverSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/driver/**")
|
||||
.authenticationProvider(authenticationProvider())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/driver/login").permitAll()
|
||||
.anyRequest().hasRole("DRIVER")
|
||||
)
|
||||
.formLogin(form -> form
|
||||
.loginPage("/driver/login")
|
||||
.loginProcessingUrl("/driver/login")
|
||||
.defaultSuccessUrl("/driver", true)
|
||||
.failureUrl("/driver/login?error")
|
||||
.permitAll()
|
||||
)
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/driver/logout")
|
||||
.logoutSuccessUrl("/driver/login?logout")
|
||||
.permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer / citizen chain — handles everything outside /admin/**, /vendor/** and /driver/**.
|
||||
* Public browsing + the standard /login and /register flow.
|
||||
*/
|
||||
@Bean
|
||||
@Order(4)
|
||||
public SecurityFilterChain customerSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authenticationProvider(authenticationProvider())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/", "/cars", "/cars/**", "/login", "/register",
|
||||
"/oauth2/**", "/login/oauth2/**",
|
||||
"/css/**", "/js/**", "/images/**", "/uploads/**", "/webjars/**", "/error")
|
||||
.permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin(form -> form
|
||||
.loginPage("/login")
|
||||
.defaultSuccessUrl("/", true)
|
||||
.failureUrl("/login?error")
|
||||
.permitAll()
|
||||
)
|
||||
.oauth2Login(oauth -> oauth
|
||||
.loginPage("/login")
|
||||
.defaultSuccessUrl("/", true)
|
||||
.failureUrl("/login?error")
|
||||
.userInfoEndpoint(info -> info.userService(oAuth2LoginService))
|
||||
)
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/logout")
|
||||
.logoutSuccessUrl("/?logout")
|
||||
.permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.mvrent.carrental.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.multipart.support.MultipartFilter;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/** Serves uploaded files from the configurable storage directory. */
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.storage.location}")
|
||||
private String location;
|
||||
|
||||
@Value("${app.storage.url-prefix}")
|
||||
private String urlPrefix;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String base = urlPrefix.endsWith("/") ? urlPrefix : urlPrefix + "/";
|
||||
String fileUri = Paths.get(location).toAbsolutePath().normalize().toUri().toString();
|
||||
registry.addResourceHandler(base + "**").addResourceLocations(fileUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multipart requests before Spring Security so the CSRF token in a
|
||||
* multipart form body can be read (otherwise uploads fail with 403).
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<MultipartFilter> multipartFilterRegistration() {
|
||||
FilterRegistrationBean<MultipartFilter> registration =
|
||||
new FilterRegistrationBean<>(new MultipartFilter());
|
||||
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
registration.addUrlPatterns("/*");
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.AuditLog;
|
||||
import com.mvrent.carrental.service.AuditService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** JSON API backing the read-only audit log viewer. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/audit")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminAuditApiController {
|
||||
|
||||
private final AuditService auditService;
|
||||
|
||||
@GetMapping
|
||||
public List<AuditLog> list(@RequestParam(defaultValue = "500") int limit) {
|
||||
return auditService.recent(Math.min(Math.max(limit, 1), 2000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/audit")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminAuditController {
|
||||
|
||||
@GetMapping
|
||||
public String page() {
|
||||
return "admin/audit";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/**
|
||||
* Dedicated entry point for the admin module — separate from the
|
||||
* customer/citizen login handled by {@link AuthController}.
|
||||
*/
|
||||
@Controller
|
||||
public class AdminAuthController {
|
||||
|
||||
/** Admin-only login page, served at /admin/login (no public registration). */
|
||||
@GetMapping("/admin/login")
|
||||
public String login() {
|
||||
return "admin/login";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Booking;
|
||||
import com.mvrent.carrental.model.BookingStatus;
|
||||
import com.mvrent.carrental.model.RentalUnit;
|
||||
import com.mvrent.carrental.model.User;
|
||||
import com.mvrent.carrental.service.BookingService;
|
||||
import com.mvrent.carrental.service.PaymentService;
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import com.mvrent.carrental.web.AdminBookingForm;
|
||||
import com.mvrent.carrental.web.BookingDto;
|
||||
import com.mvrent.carrental.web.PaymentDto;
|
||||
import com.mvrent.carrental.web.PaymentForm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the AJAX Manage Bookings screen (inline status + counter bookings). */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/bookings")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_BOOKINGS')")
|
||||
public class AdminBookingApiController {
|
||||
|
||||
private final BookingService bookingService;
|
||||
private final UserService userService;
|
||||
private final PaymentService paymentService;
|
||||
|
||||
@GetMapping
|
||||
public List<BookingDto> list() {
|
||||
Map<Long, BigDecimal> paid = paymentService.paidByBooking();
|
||||
return bookingService.findAll().stream()
|
||||
.map(b -> BookingDto.from(b, paid.get(b.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/* ---------- Payments ---------- */
|
||||
|
||||
@GetMapping("/{id}/payments")
|
||||
public Map<String, Object> payments(@PathVariable Long id) {
|
||||
Booking b = bookingService.findById(id);
|
||||
BigDecimal total = b.getTotalAmount() != null ? b.getTotalAmount() : BigDecimal.ZERO;
|
||||
BigDecimal paid = paymentService.totalPaid(id);
|
||||
return Map.of(
|
||||
"total", total,
|
||||
"paid", paid,
|
||||
"balance", total.subtract(paid),
|
||||
"payments", paymentService.forBooking(id).stream().map(PaymentDto::from).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/payments")
|
||||
public ResponseEntity<?> addPayment(@PathVariable Long id, @RequestBody PaymentForm f) {
|
||||
try {
|
||||
paymentService.record(id, f.amount(), f.method(), f.reference(), f.paidAt(), f.notes());
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return bad(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/payments/{paymentId}/delete")
|
||||
public ResponseEntity<?> deletePayment(@PathVariable Long paymentId) {
|
||||
paymentService.delete(paymentId);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/status")
|
||||
public ResponseEntity<?> updateStatus(@PathVariable Long id, @RequestParam BookingStatus status) {
|
||||
bookingService.updateStatus(id, status);
|
||||
return ResponseEntity.ok(Map.of("ok", true, "id", id, "status", status.name()));
|
||||
}
|
||||
|
||||
/** Admin creates a booking on behalf of a customer (counter / phone booking). */
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody AdminBookingForm f) {
|
||||
if (f.carId() == null) return bad("Please select a car");
|
||||
if (f.pickupDate() == null || f.returnDate() == null) return bad("Pickup and return dates are required");
|
||||
if (isBlank(f.pickupLocation())) return bad("Pickup location is required");
|
||||
if (isBlank(f.dropLocation())) return bad("Drop location is required");
|
||||
|
||||
// Resolve customer: an existing one, or create a walk-in record.
|
||||
User customer;
|
||||
try {
|
||||
if (f.customerId() != null) {
|
||||
customer = userService.findById(f.customerId());
|
||||
} else if (!isBlank(f.customerName())) {
|
||||
customer = userService.createWalkInCustomer(f.customerName(), f.customerPhone(), f.customerEmail());
|
||||
} else {
|
||||
return bad("Select a customer or enter a walk-in customer name");
|
||||
}
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return bad(ex.getMessage());
|
||||
}
|
||||
|
||||
Booking form = new Booking();
|
||||
form.setPickupDate(f.pickupDate());
|
||||
form.setReturnDate(f.returnDate());
|
||||
form.setPickupLocation(f.pickupLocation().trim());
|
||||
form.setDropLocation(f.dropLocation().trim());
|
||||
form.setRentalUnit(f.rentalUnit() != null ? f.rentalUnit() : RentalUnit.DAY);
|
||||
form.setWithDriver(f.withDriver());
|
||||
|
||||
try {
|
||||
Booking saved = bookingService.createBooking(customer, f.carId(), form, f.driverId());
|
||||
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
|
||||
} catch (IllegalArgumentException | IllegalStateException ex) {
|
||||
return bad(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
|
||||
|
||||
private static ResponseEntity<Map<String, Object>> bad(String msg) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", msg == null ? "Could not create booking" : msg));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.*;
|
||||
import com.mvrent.carrental.service.CarService;
|
||||
import com.mvrent.carrental.service.FileStorageService;
|
||||
import com.mvrent.carrental.web.CarDto;
|
||||
import com.mvrent.carrental.web.CarForm;
|
||||
import com.mvrent.carrental.web.CarMediaDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** JSON API backing the AJAX Manage Cars screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/cars")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminCarApiController {
|
||||
|
||||
private final CarService carService;
|
||||
private final FileStorageService fileStorage;
|
||||
|
||||
@GetMapping
|
||||
public List<CarDto> list() {
|
||||
return carService.findAll().stream().map(CarDto::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public CarDto get(@PathVariable Long id) {
|
||||
return CarDto.from(carService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> save(@RequestBody CarForm f) {
|
||||
Map<String, String> errors = validate(f);
|
||||
if (!errors.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors));
|
||||
}
|
||||
|
||||
Car car = new Car();
|
||||
if (f.id() != null) car.setId(f.id());
|
||||
car.setMake(trim(f.make()));
|
||||
car.setModel(trim(f.model()));
|
||||
car.setRegistrationNumber(trim(f.registrationNumber()));
|
||||
car.setYearOfRegistration(f.yearOfRegistration());
|
||||
car.setColour(trim(f.colour()));
|
||||
car.setType(f.type());
|
||||
car.setSeats(f.seats() == null ? 0 : f.seats());
|
||||
car.setTransmission(f.transmission());
|
||||
car.setFuelType(f.fuelType());
|
||||
car.setHourlyRate(f.hourlyRate());
|
||||
car.setPricePerDay(f.pricePerDay());
|
||||
car.setMonthlyRate(f.monthlyRate());
|
||||
car.setOwnership(f.ownership() == null ? CarOwnership.OWNED : f.ownership());
|
||||
car.setCommissionPercentOverride(f.commissionPercentOverride());
|
||||
car.setUnitsTotal(f.unitsTotal() == null ? 1 : f.unitsTotal());
|
||||
car.setStatus(f.status() == null ? CarStatus.ACTIVE : f.status());
|
||||
car.setImageUrl(trim(f.imageUrl()));
|
||||
car.setAvailable(f.available() == null || f.available());
|
||||
|
||||
try {
|
||||
Car saved = carService.saveFromForm(car, parseTags(f.featureTagsCsv()), f.vendorId());
|
||||
return ResponseEntity.ok(Map.of("ok", true, "car", CarDto.from(saved)));
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false,
|
||||
"errors", Map.of("registrationNumber", "Registration number already exists")));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
try {
|
||||
carService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false,
|
||||
"message", "Cannot delete — this car has bookings. Set it to RETIRED instead."));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Documents & photos ---------- */
|
||||
|
||||
@GetMapping("/{id}/media")
|
||||
public CarMediaDto media(@PathVariable Long id) {
|
||||
return CarMediaDto.from(carService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/documents")
|
||||
public ResponseEntity<?> addDocument(@PathVariable Long id,
|
||||
@RequestParam CarDocumentType docType,
|
||||
@RequestParam(required = false) MultipartFile file,
|
||||
@RequestParam(required = false) String fileUrl,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate) {
|
||||
String stored = fileStorage.store(file);
|
||||
String url = stored != null ? stored : fileUrl;
|
||||
if (url == null || url.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a file or provide a URL"));
|
||||
}
|
||||
carService.addDocument(id, docType, url, issuedDate, expiryDate);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/documents/{docId}/delete")
|
||||
public ResponseEntity<?> deleteDocument(@PathVariable Long id, @PathVariable Long docId) {
|
||||
carService.deleteDocument(docId);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/photos")
|
||||
public ResponseEntity<?> addPhoto(@PathVariable Long id,
|
||||
@RequestParam(required = false) MultipartFile file,
|
||||
@RequestParam(required = false) String url,
|
||||
@RequestParam(defaultValue = "0") int sortOrder) {
|
||||
String stored = fileStorage.store(file);
|
||||
String finalUrl = stored != null ? stored : url;
|
||||
if (finalUrl == null || finalUrl.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a photo or provide a URL"));
|
||||
}
|
||||
carService.addPhoto(id, finalUrl, sortOrder);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/photos/{photoId}/delete")
|
||||
public ResponseEntity<?> deletePhoto(@PathVariable Long id, @PathVariable Long photoId) {
|
||||
carService.deletePhoto(photoId);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
private Map<String, String> validate(CarForm f) {
|
||||
Map<String, String> e = new LinkedHashMap<>();
|
||||
if (isBlank(f.make())) e.put("make", "Make is required");
|
||||
if (isBlank(f.model())) e.put("model", "Model is required");
|
||||
if (isBlank(f.registrationNumber())) e.put("registrationNumber", "Registration number is required");
|
||||
if (f.type() == null) e.put("type", "Type is required");
|
||||
if (f.pricePerDay() == null || f.pricePerDay().compareTo(BigDecimal.ZERO) < 0)
|
||||
e.put("pricePerDay", "Valid daily rate is required");
|
||||
if (f.seats() != null && (f.seats() < 2 || f.seats() > 15))
|
||||
e.put("seats", "Seats must be 2–15");
|
||||
return e;
|
||||
}
|
||||
|
||||
private Set<String> parseTags(String csv) {
|
||||
if (csv == null || csv.isBlank()) return new LinkedHashSet<>();
|
||||
return Arrays.stream(csv.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
|
||||
private static String trim(String s) { return s == null ? null : s.trim(); }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.web.CategoryDto;
|
||||
import com.mvrent.carrental.web.CategoryForm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the AJAX category master screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/categories")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')")
|
||||
public class AdminCategoryApiController {
|
||||
|
||||
private final ConfigService configService;
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryDto> list() {
|
||||
return configService.allCategories().stream().map(CategoryDto::from).toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> add(@RequestBody CategoryForm f) {
|
||||
if (f.type() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Type is required"));
|
||||
}
|
||||
if (f.name() == null || f.name().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Name is required"));
|
||||
}
|
||||
try {
|
||||
return ResponseEntity.ok(Map.of("ok", true,
|
||||
"category", CategoryDto.from(configService.addCategory(f.type(), f.name().trim(), f.description()))));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/toggle")
|
||||
public ResponseEntity<?> toggle(@PathVariable Long id) {
|
||||
configService.toggleCategory(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
try {
|
||||
configService.deleteCategory(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (Exception ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false,
|
||||
"message", "Cannot delete — category is in use. Disable it instead."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.BookingStatus;
|
||||
import com.mvrent.carrental.model.Car;
|
||||
import com.mvrent.carrental.model.CarDocumentType;
|
||||
import com.mvrent.carrental.model.Invoice;
|
||||
import com.mvrent.carrental.service.BookingService;
|
||||
import com.mvrent.carrental.service.CarService;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.DashboardService;
|
||||
import com.mvrent.carrental.service.DriverService;
|
||||
import com.mvrent.carrental.service.FileStorageService;
|
||||
import com.mvrent.carrental.service.PaymentService;
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import com.mvrent.carrental.service.PdfService;
|
||||
import com.mvrent.carrental.service.VendorService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final CarService carService;
|
||||
private final BookingService bookingService;
|
||||
private final VendorService vendorService;
|
||||
private final PdfService pdfService;
|
||||
private final ConfigService configService;
|
||||
private final FileStorageService fileStorage;
|
||||
private final DashboardService dashboardService;
|
||||
private final DriverService driverService;
|
||||
private final UserService userService;
|
||||
private final PaymentService paymentService;
|
||||
|
||||
@GetMapping
|
||||
public String dashboard(Model model) {
|
||||
model.addAttribute("d", dashboardService.build());
|
||||
return "admin/dashboard";
|
||||
}
|
||||
|
||||
/* ---------- Cars ---------- */
|
||||
|
||||
@GetMapping("/cars")
|
||||
public String cars(Model model) {
|
||||
model.addAttribute("cars", carService.findAll());
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/cars";
|
||||
}
|
||||
|
||||
@GetMapping("/cars/new")
|
||||
public String newCar(Model model) {
|
||||
if (!model.containsAttribute("car")) {
|
||||
model.addAttribute("car", new Car());
|
||||
}
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/car-form";
|
||||
}
|
||||
|
||||
@PostMapping("/cars")
|
||||
public String saveCar(@Valid @ModelAttribute("car") Car car,
|
||||
BindingResult result,
|
||||
@RequestParam(name = "featureTagsCsv", required = false) String featureTagsCsv,
|
||||
@RequestParam(name = "vendorId", required = false) Long vendorId,
|
||||
Model model,
|
||||
RedirectAttributes ra) {
|
||||
if (result.hasErrors()) {
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/car-form";
|
||||
}
|
||||
carService.saveFromForm(car, parseTags(featureTagsCsv), vendorId);
|
||||
ra.addFlashAttribute("success", "Car saved");
|
||||
return "redirect:/admin/cars";
|
||||
}
|
||||
|
||||
@GetMapping("/cars/{id}/edit")
|
||||
public String editCar(@PathVariable Long id, Model model) {
|
||||
model.addAttribute("car", carService.findById(id));
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/car-form";
|
||||
}
|
||||
|
||||
@PostMapping("/cars/{id}/delete")
|
||||
public String deleteCar(@PathVariable Long id, RedirectAttributes ra) {
|
||||
carService.delete(id);
|
||||
ra.addFlashAttribute("success", "Car deleted");
|
||||
return "redirect:/admin/cars";
|
||||
}
|
||||
|
||||
/* ---------- Car documents ---------- */
|
||||
|
||||
@PostMapping("/cars/{id}/documents")
|
||||
public String addDocument(@PathVariable Long id,
|
||||
@RequestParam CarDocumentType docType,
|
||||
@RequestParam(required = false) String fileUrl,
|
||||
@RequestParam(required = false) MultipartFile file,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate,
|
||||
RedirectAttributes ra) {
|
||||
String stored = fileStorage.store(file);
|
||||
carService.addDocument(id, docType, stored != null ? stored : fileUrl, issuedDate, expiryDate);
|
||||
ra.addFlashAttribute("success", docType + " document added");
|
||||
return "redirect:/admin/cars/" + id + "/edit";
|
||||
}
|
||||
|
||||
@PostMapping("/cars/{id}/documents/{docId}/delete")
|
||||
public String deleteDocument(@PathVariable Long id, @PathVariable Long docId,
|
||||
RedirectAttributes ra) {
|
||||
carService.deleteDocument(docId);
|
||||
ra.addFlashAttribute("success", "Document removed");
|
||||
return "redirect:/admin/cars/" + id + "/edit";
|
||||
}
|
||||
|
||||
/* ---------- Car photos ---------- */
|
||||
|
||||
@PostMapping("/cars/{id}/photos")
|
||||
public String addPhoto(@PathVariable Long id,
|
||||
@RequestParam(required = false) String url,
|
||||
@RequestParam(required = false) MultipartFile file,
|
||||
@RequestParam(defaultValue = "0") int sortOrder,
|
||||
RedirectAttributes ra) {
|
||||
String stored = fileStorage.store(file);
|
||||
String finalUrl = stored != null ? stored : url;
|
||||
if (finalUrl == null || finalUrl.isBlank()) {
|
||||
ra.addFlashAttribute("error", "Provide a photo file or URL");
|
||||
} else {
|
||||
carService.addPhoto(id, finalUrl, sortOrder);
|
||||
ra.addFlashAttribute("success", "Photo added");
|
||||
}
|
||||
return "redirect:/admin/cars/" + id + "/edit";
|
||||
}
|
||||
|
||||
@PostMapping("/cars/{id}/photos/{photoId}/delete")
|
||||
public String deletePhoto(@PathVariable Long id, @PathVariable Long photoId,
|
||||
RedirectAttributes ra) {
|
||||
carService.deletePhoto(photoId);
|
||||
ra.addFlashAttribute("success", "Photo removed");
|
||||
return "redirect:/admin/cars/" + id + "/edit";
|
||||
}
|
||||
|
||||
/* ---------- Bookings ---------- */
|
||||
|
||||
@GetMapping("/bookings")
|
||||
public String bookings(Model model) {
|
||||
model.addAttribute("bookings", bookingService.findAll());
|
||||
model.addAttribute("customers", userService.findCustomers());
|
||||
model.addAttribute("availableCars", carService.findAvailable());
|
||||
model.addAttribute("availableDrivers", driverService.findAvailable());
|
||||
return "admin/bookings";
|
||||
}
|
||||
|
||||
@PostMapping("/bookings/{id}/status")
|
||||
public String updateBookingStatus(@PathVariable Long id,
|
||||
@RequestParam BookingStatus status,
|
||||
RedirectAttributes ra) {
|
||||
bookingService.updateStatus(id, status);
|
||||
ra.addFlashAttribute("success", "Booking #" + id + " set to " + status);
|
||||
return "redirect:/admin/bookings";
|
||||
}
|
||||
|
||||
@GetMapping("/bookings/{id}/invoice")
|
||||
public String invoice(@PathVariable Long id, Model model) {
|
||||
var booking = bookingService.findById(id);
|
||||
java.math.BigDecimal paid = paymentService.totalPaid(id);
|
||||
java.math.BigDecimal total = booking.getTotalAmount() != null ? booking.getTotalAmount() : java.math.BigDecimal.ZERO;
|
||||
model.addAttribute("booking", booking);
|
||||
model.addAttribute("invoice", bookingService.invoiceFor(id));
|
||||
model.addAttribute("payments", paymentService.forBooking(id));
|
||||
model.addAttribute("amountPaid", paid);
|
||||
model.addAttribute("balance", total.subtract(paid));
|
||||
model.addAttribute("pdfUrl", "/admin/bookings/" + id + "/invoice/pdf");
|
||||
return "bookings/invoice";
|
||||
}
|
||||
|
||||
@GetMapping("/bookings/{id}/invoice/pdf")
|
||||
public ResponseEntity<byte[]> invoicePdf(@PathVariable Long id) {
|
||||
Invoice invoice = bookingService.invoiceFor(id);
|
||||
byte[] pdf = pdfService.render("pdf/invoice", Map.of(
|
||||
"booking", bookingService.findById(id), "invoice", invoice,
|
||||
"orgName", configService.getOrganization().getName()));
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + invoice.getInvoiceNumber() + ".pdf\"")
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private Set<String> parseTags(String csv) {
|
||||
if (csv == null || csv.isBlank()) return new LinkedHashSet<>();
|
||||
return Arrays.stream(csv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.*;
|
||||
import com.mvrent.carrental.service.DriverService;
|
||||
import com.mvrent.carrental.service.FileStorageService;
|
||||
import com.mvrent.carrental.web.DriverDto;
|
||||
import com.mvrent.carrental.web.DriverForm;
|
||||
import com.mvrent.carrental.web.DriverMediaDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the AJAX Manage Drivers screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/drivers")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_DRIVERS')")
|
||||
public class AdminDriverApiController {
|
||||
|
||||
private final DriverService driverService;
|
||||
private final FileStorageService fileStorage;
|
||||
|
||||
@GetMapping
|
||||
public List<DriverDto> list() {
|
||||
return driverService.findAll().stream().map(DriverDto::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public DriverDto get(@PathVariable Long id) {
|
||||
return DriverDto.from(driverService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> save(@RequestBody DriverForm f) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (isBlank(f.name())) errors.put("name", "Name is required");
|
||||
if (isBlank(f.licenceNumber())) errors.put("licenceNumber", "Licence number is required");
|
||||
if (!errors.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors));
|
||||
}
|
||||
|
||||
Driver d = new Driver();
|
||||
if (f.id() != null) d.setId(f.id());
|
||||
d.setName(trim(f.name()));
|
||||
d.setPhone(trim(f.phone()));
|
||||
d.setLicenceNumber(trim(f.licenceNumber()));
|
||||
d.setLicenceExpiry(f.licenceExpiry());
|
||||
d.setYearsExperience(f.yearsExperience() == null ? 0 : f.yearsExperience());
|
||||
d.setEmploymentType(f.employmentType() == null ? DriverEmploymentType.EMPLOYED : f.employmentType());
|
||||
d.setHourlyCharge(f.hourlyCharge());
|
||||
d.setDailyCharge(f.dailyCharge());
|
||||
d.setMonthlyCharge(f.monthlyCharge());
|
||||
d.setStatus(f.status() == null ? DriverStatus.AVAILABLE : f.status());
|
||||
d.setPhotoUrl(trim(f.photoUrl()));
|
||||
|
||||
try {
|
||||
Driver saved = driverService.saveFromForm(d, f.categoryId(), f.vendorId());
|
||||
return ResponseEntity.ok(Map.of("ok", true, "driver", DriverDto.from(saved)));
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false,
|
||||
"errors", Map.of("licenceNumber", "Licence number already exists")));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Documents ---------- */
|
||||
|
||||
@GetMapping("/{id}/documents")
|
||||
public DriverMediaDto documents(@PathVariable Long id) {
|
||||
return DriverMediaDto.from(driverService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/documents")
|
||||
public ResponseEntity<?> addDocument(@PathVariable Long id,
|
||||
@RequestParam DriverDocumentType docType,
|
||||
@RequestParam(required = false) MultipartFile file,
|
||||
@RequestParam(required = false) String fileUrl,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate) {
|
||||
String stored = fileStorage.store(file);
|
||||
String url = stored != null ? stored : fileUrl;
|
||||
if (url == null || url.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a file or provide a URL"));
|
||||
}
|
||||
driverService.addDocument(id, docType, url, issuedDate, expiryDate);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/documents/{docId}/delete")
|
||||
public ResponseEntity<?> deleteDocument(@PathVariable Long id, @PathVariable Long docId) {
|
||||
driverService.deleteDocument(docId);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
try {
|
||||
driverService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false,
|
||||
"message", "Cannot delete — this driver has trips. Set status to OFF instead."));
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline activate/deactivate: flips a driver between AVAILABLE and OFF. */
|
||||
@PostMapping("/{id}/status")
|
||||
public ResponseEntity<?> setStatus(@PathVariable Long id, @RequestParam DriverStatus status) {
|
||||
Driver saved = driverService.updateStatus(id, status);
|
||||
return ResponseEntity.ok(Map.of("ok", true, "id", id, "status", saved.getStatus().name()));
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
|
||||
private static String trim(String s) { return s == null ? null : s.trim(); }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.CategoryType;
|
||||
import com.mvrent.carrental.model.Driver;
|
||||
import com.mvrent.carrental.model.DriverDocumentType;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.DriverService;
|
||||
import com.mvrent.carrental.service.FileStorageService;
|
||||
import com.mvrent.carrental.service.VendorService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/drivers")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_DRIVERS')")
|
||||
public class AdminDriverController {
|
||||
|
||||
private final DriverService driverService;
|
||||
private final ConfigService configService;
|
||||
private final VendorService vendorService;
|
||||
private final FileStorageService fileStorage;
|
||||
|
||||
@GetMapping
|
||||
public String list(Model model) {
|
||||
model.addAttribute("drivers", driverService.findAll());
|
||||
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/drivers";
|
||||
}
|
||||
|
||||
@GetMapping("/new")
|
||||
public String newDriver(Model model) {
|
||||
if (!model.containsAttribute("driver")) {
|
||||
model.addAttribute("driver", new Driver());
|
||||
}
|
||||
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/driver-form";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String save(@Valid @ModelAttribute("driver") Driver driver,
|
||||
BindingResult result,
|
||||
@RequestParam(required = false) Long categoryId,
|
||||
@RequestParam(required = false) Long vendorId,
|
||||
Model model,
|
||||
RedirectAttributes ra) {
|
||||
if (result.hasErrors()) {
|
||||
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/driver-form";
|
||||
}
|
||||
driverService.saveFromForm(driver, categoryId, vendorId);
|
||||
ra.addFlashAttribute("success", "Driver saved");
|
||||
return "redirect:/admin/drivers";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public String profile(@PathVariable Long id, Model model) {
|
||||
Driver driver = driverService.findById(id);
|
||||
model.addAttribute("driver", driver);
|
||||
model.addAttribute("trips", driverService.tripHistory(driver));
|
||||
model.addAttribute("totalTrips", driverService.totalTrips(driver));
|
||||
model.addAttribute("initials", initials(driver.getName()));
|
||||
return "admin/driver-profile";
|
||||
}
|
||||
|
||||
private static String initials(String name) {
|
||||
if (name == null || name.isBlank()) return "?";
|
||||
String[] p = name.trim().split("\\s+");
|
||||
if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) {
|
||||
return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase();
|
||||
}
|
||||
return name.substring(0, Math.min(2, name.length())).toUpperCase();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/edit")
|
||||
public String edit(@PathVariable Long id, Model model) {
|
||||
model.addAttribute("driver", driverService.findById(id));
|
||||
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
|
||||
model.addAttribute("vendors", vendorService.findActive());
|
||||
return "admin/driver-form";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public String delete(@PathVariable Long id, RedirectAttributes ra) {
|
||||
driverService.delete(id);
|
||||
ra.addFlashAttribute("success", "Driver deleted");
|
||||
return "redirect:/admin/drivers";
|
||||
}
|
||||
|
||||
/** Create a /driver portal login for this driver. */
|
||||
@PostMapping("/{id}/portal-user")
|
||||
public String createPortalUser(@PathVariable Long id,
|
||||
@RequestParam String username,
|
||||
@RequestParam String email,
|
||||
@RequestParam String password,
|
||||
RedirectAttributes ra) {
|
||||
try {
|
||||
driverService.createPortalUser(id, username, email, password);
|
||||
ra.addFlashAttribute("success", "Driver portal login created for '" + username + "'");
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ra.addFlashAttribute("error", ex.getMessage());
|
||||
}
|
||||
return "redirect:/admin/drivers/" + id;
|
||||
}
|
||||
|
||||
/* ---------- Documents ---------- */
|
||||
|
||||
@PostMapping("/{id}/documents")
|
||||
public String addDocument(@PathVariable Long id,
|
||||
@RequestParam DriverDocumentType docType,
|
||||
@RequestParam(required = false) String fileUrl,
|
||||
@RequestParam(required = false) MultipartFile file,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate,
|
||||
RedirectAttributes ra) {
|
||||
String stored = fileStorage.store(file);
|
||||
driverService.addDocument(id, docType, stored != null ? stored : fileUrl, issuedDate, expiryDate);
|
||||
ra.addFlashAttribute("success", docType + " document added");
|
||||
return "redirect:/admin/drivers/" + id + "/edit";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/documents/{docId}/delete")
|
||||
public String deleteDocument(@PathVariable Long id, @PathVariable Long docId,
|
||||
RedirectAttributes ra) {
|
||||
driverService.deleteDocument(docId);
|
||||
ra.addFlashAttribute("success", "Document removed");
|
||||
return "redirect:/admin/drivers/" + id + "/edit";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.InspectionCondition;
|
||||
import com.mvrent.carrental.model.InspectionRecord;
|
||||
import com.mvrent.carrental.service.InspectionService;
|
||||
import com.mvrent.carrental.web.InspectionDto;
|
||||
import com.mvrent.carrental.web.InspectionForm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the AJAX Inspections screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/inspections")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminInspectionApiController {
|
||||
|
||||
private final InspectionService inspectionService;
|
||||
|
||||
@GetMapping
|
||||
public List<InspectionDto> list(@RequestParam(required = false) Long carId) {
|
||||
var records = carId != null ? inspectionService.findByCar(carId) : inspectionService.findAll();
|
||||
return records.stream().map(InspectionDto::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public InspectionDto get(@PathVariable Long id) {
|
||||
return InspectionDto.from(inspectionService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> save(@RequestBody InspectionForm f) {
|
||||
if (f.carId() == null) return bad("Please select a car");
|
||||
if (f.type() == null) return bad("Please select an inspection type");
|
||||
|
||||
InspectionRecord r = f.id() != null ? inspectionService.findById(f.id()) : new InspectionRecord();
|
||||
r.setType(f.type());
|
||||
r.setCondition(f.condition() != null ? f.condition() : InspectionCondition.GOOD);
|
||||
r.setInspectionDate(f.inspectionDate());
|
||||
r.setInspector(trim(f.inspector()));
|
||||
r.setOdometer(f.odometer());
|
||||
r.setFuelLevel(f.fuelLevel());
|
||||
r.setDamageNotes(trim(f.damageNotes()));
|
||||
r.setPhotoUrl(trim(f.photoUrl()));
|
||||
|
||||
InspectionRecord saved = inspectionService.save(r, f.carId(), f.bookingId());
|
||||
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
inspectionService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
private static String trim(String s) { return s == null || s.isBlank() ? null : s.trim(); }
|
||||
|
||||
private static ResponseEntity<Map<String, Object>> bad(String m) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.CarService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/inspections")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminInspectionController {
|
||||
|
||||
private final CarService carService;
|
||||
|
||||
@GetMapping
|
||||
public String page(Model model) {
|
||||
model.addAttribute("cars", carService.findAll());
|
||||
return "admin/inspections";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.MaintenanceRecord;
|
||||
import com.mvrent.carrental.model.MaintenanceStatus;
|
||||
import com.mvrent.carrental.service.MaintenanceService;
|
||||
import com.mvrent.carrental.web.MaintenanceDto;
|
||||
import com.mvrent.carrental.web.MaintenanceForm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the AJAX Maintenance screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/maintenance")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminMaintenanceApiController {
|
||||
|
||||
private final MaintenanceService maintenanceService;
|
||||
|
||||
@GetMapping
|
||||
public List<MaintenanceDto> list(@RequestParam(required = false) Long carId) {
|
||||
var records = carId != null ? maintenanceService.findByCar(carId) : maintenanceService.findAll();
|
||||
return records.stream().map(MaintenanceDto::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public MaintenanceDto get(@PathVariable Long id) {
|
||||
return MaintenanceDto.from(maintenanceService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> save(@RequestBody MaintenanceForm f) {
|
||||
if (f.carId() == null) return bad("Please select a car");
|
||||
if (f.type() == null) return bad("Please select a work type");
|
||||
|
||||
MaintenanceRecord r = f.id() != null ? maintenanceService.findById(f.id()) : new MaintenanceRecord();
|
||||
r.setType(f.type());
|
||||
r.setStatus(f.status() != null ? f.status() : MaintenanceStatus.COMPLETED);
|
||||
r.setServiceDate(f.serviceDate());
|
||||
r.setOdometer(f.odometer());
|
||||
r.setCost(f.cost());
|
||||
r.setGarage(trim(f.garage()));
|
||||
r.setNotes(trim(f.notes()));
|
||||
r.setNextServiceDate(f.nextServiceDate());
|
||||
r.setNextServiceOdometer(f.nextServiceOdometer());
|
||||
|
||||
MaintenanceRecord saved = maintenanceService.save(r, f.carId());
|
||||
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
maintenanceService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
private static String trim(String s) { return s == null || s.isBlank() ? null : s.trim(); }
|
||||
|
||||
private static ResponseEntity<Map<String, Object>> bad(String m) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.CarService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/maintenance")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminMaintenanceController {
|
||||
|
||||
private final CarService carService;
|
||||
|
||||
@GetMapping
|
||||
public String page(Model model) {
|
||||
model.addAttribute("cars", carService.findAll());
|
||||
return "admin/maintenance";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Admin profile page + AJAX password change. */
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class AdminProfileController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping("/admin/profile")
|
||||
public String profile(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
model.addAttribute("user", userService.findByUsername(principal.getUsername()));
|
||||
return "admin/profile";
|
||||
}
|
||||
|
||||
@PostMapping("/admin/api/profile/password")
|
||||
@ResponseBody
|
||||
public ResponseEntity<?> changePassword(@AuthenticationPrincipal UserDetails principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
try {
|
||||
userService.changePassword(principal.getUsername(),
|
||||
body.get("currentPassword"), body.get("newPassword"));
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.*;
|
||||
import com.mvrent.carrental.service.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/reports")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('VIEW_REPORTS')")
|
||||
public class AdminReportController {
|
||||
|
||||
private final ReportService reportService;
|
||||
private final CarService carService;
|
||||
private final DriverService driverService;
|
||||
private final VendorService vendorService;
|
||||
private final ConfigService configService;
|
||||
private final ExcelService excelService;
|
||||
private final PdfService pdfService;
|
||||
|
||||
/** Build a filter from raw query params, ignoring blanks (avoids empty-enum binding errors). */
|
||||
private BookingFilter parse(Map<String, String> p) {
|
||||
return new BookingFilter(
|
||||
date(p.get("from")), date(p.get("to")),
|
||||
lng(p.get("carId")), lng(p.get("driverId")), lng(p.get("vendorId")),
|
||||
blank(p.get("ownership")) ? null : CarOwnership.valueOf(p.get("ownership")),
|
||||
blank(p.get("status")) ? null : BookingStatus.valueOf(p.get("status")),
|
||||
lng(p.get("customerCategoryId")));
|
||||
}
|
||||
|
||||
private void addLookups(Model model) {
|
||||
model.addAttribute("cars", carService.findAll());
|
||||
model.addAttribute("drivers", driverService.findAll());
|
||||
model.addAttribute("vendors", vendorService.findAll());
|
||||
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
|
||||
model.addAttribute("ownerships", CarOwnership.values());
|
||||
model.addAttribute("statuses", BookingStatus.values());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String bookingsReport(@RequestParam Map<String, String> params, Model model) {
|
||||
BookingFilter f = parse(params);
|
||||
List<Booking> bookings = reportService.bookings(f);
|
||||
model.addAttribute("bookings", bookings);
|
||||
model.addAttribute("summary", reportService.summarize(bookings));
|
||||
model.addAttribute("p", params); // echo filters back into the form + export links
|
||||
addLookups(model);
|
||||
return "admin/reports";
|
||||
}
|
||||
|
||||
@GetMapping("/excel")
|
||||
public ResponseEntity<byte[]> bookingsExcel(@RequestParam Map<String, String> params) {
|
||||
List<Booking> bookings = reportService.bookings(parse(params));
|
||||
byte[] xlsx = excelService.bookingsWorkbook(bookings, reportService.summarize(bookings));
|
||||
return download(xlsx, AdminSettlementController.XLSX, "attachment", "bookings-report.xlsx");
|
||||
}
|
||||
|
||||
@GetMapping("/pdf")
|
||||
public ResponseEntity<byte[]> bookingsPdf(@RequestParam Map<String, String> params) {
|
||||
List<Booking> bookings = reportService.bookings(parse(params));
|
||||
byte[] pdf = pdfService.render("pdf/report-bookings", Map.of(
|
||||
"bookings", bookings,
|
||||
"summary", reportService.summarize(bookings),
|
||||
"orgName", configService.getOrganization().getName()));
|
||||
return download(pdf, MediaType.APPLICATION_PDF, "inline", "bookings-report.pdf");
|
||||
}
|
||||
|
||||
/* ---------- Vendor-wise ---------- */
|
||||
|
||||
@GetMapping("/vendors")
|
||||
public String vendorReport(Model model) {
|
||||
model.addAttribute("rows", reportService.vendorReport());
|
||||
return "admin/report-vendors";
|
||||
}
|
||||
|
||||
@GetMapping("/vendors/excel")
|
||||
public ResponseEntity<byte[]> vendorReportExcel() {
|
||||
return download(excelService.vendorReportWorkbook(reportService.vendorReport()),
|
||||
AdminSettlementController.XLSX, "attachment", "vendor-report.xlsx");
|
||||
}
|
||||
|
||||
/* ---------- Driver-wise ---------- */
|
||||
|
||||
@GetMapping("/drivers")
|
||||
public String driverReport(Model model) {
|
||||
model.addAttribute("rows", reportService.driverReport());
|
||||
return "admin/report-drivers";
|
||||
}
|
||||
|
||||
@GetMapping("/drivers/excel")
|
||||
public ResponseEntity<byte[]> driverReportExcel() {
|
||||
return download(excelService.driverReportWorkbook(reportService.driverReport()),
|
||||
AdminSettlementController.XLSX, "attachment", "driver-report.xlsx");
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private ResponseEntity<byte[]> download(byte[] body, MediaType type, String disposition, String filename) {
|
||||
return ResponseEntity.ok().contentType(type)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition + "; filename=\"" + filename + "\"")
|
||||
.body(body);
|
||||
}
|
||||
|
||||
private static boolean blank(String s) { return s == null || s.isBlank(); }
|
||||
private static Long lng(String s) { return blank(s) ? null : Long.valueOf(s.trim()); }
|
||||
private static LocalDate date(String s) { return blank(s) ? null : LocalDate.parse(s.trim()); }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.ReviewService;
|
||||
import com.mvrent.carrental.web.ReviewDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the admin review moderation screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/reviews")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminReviewApiController {
|
||||
|
||||
private final ReviewService reviewService;
|
||||
|
||||
@GetMapping
|
||||
public List<ReviewDto> list() {
|
||||
return reviewService.findAll().stream().map(ReviewDto::from).toList();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<?> approve(@PathVariable Long id, @RequestParam boolean approved) {
|
||||
reviewService.setApproved(id, approved);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
reviewService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/reviews")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminReviewController {
|
||||
|
||||
@GetMapping
|
||||
public String page() {
|
||||
return "admin/reviews";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Organization;
|
||||
import com.mvrent.carrental.model.SystemConfig;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.web.ConfigForm;
|
||||
import com.mvrent.carrental.web.OrgForm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the AJAX Settings forms. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/settings")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')")
|
||||
public class AdminSettingsApiController {
|
||||
|
||||
private final ConfigService configService;
|
||||
|
||||
@PostMapping("/organization")
|
||||
public ResponseEntity<?> saveOrganization(@RequestBody OrgForm f) {
|
||||
if (f.name() == null || f.name().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Organization name is required"));
|
||||
}
|
||||
Organization org = Organization.builder()
|
||||
.name(f.name().trim())
|
||||
.legalName(f.legalName())
|
||||
.logoUrl(f.logoUrl())
|
||||
.address(f.address())
|
||||
.gstNumber(f.gstNumber())
|
||||
.contactEmail(f.contactEmail())
|
||||
.phone(f.phone())
|
||||
.currencyCode(blankToDefault(f.currencyCode(), "INR"))
|
||||
.build();
|
||||
configService.saveOrganization(org);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/config")
|
||||
public ResponseEntity<?> saveConfig(@RequestBody ConfigForm f) {
|
||||
SystemConfig cfg = SystemConfig.builder()
|
||||
.currencyCode(blankToDefault(f.currencyCode(), "INR"))
|
||||
.defaultTaxPercent(f.defaultTaxPercent())
|
||||
.defaultCommissionPercent(f.defaultCommissionPercent())
|
||||
.defaultDriverDailyCharge(f.defaultDriverDailyCharge())
|
||||
.invoicePrefix(blankToDefault(f.invoicePrefix(), "INV"))
|
||||
.settlementCycle(f.settlementCycle())
|
||||
.build();
|
||||
configService.saveSystemConfig(cfg);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
private static String blankToDefault(String s, String def) {
|
||||
return (s == null || s.isBlank()) ? def : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.CategoryType;
|
||||
import com.mvrent.carrental.model.Organization;
|
||||
import com.mvrent.carrental.model.SystemConfig;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.NotificationService;
|
||||
import com.mvrent.carrental.service.SmsService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/settings")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')")
|
||||
public class AdminSettingsController {
|
||||
|
||||
private final ConfigService configService;
|
||||
private final NotificationService notificationService;
|
||||
private final SmsService smsService;
|
||||
|
||||
/* ---------- Organization + System config ---------- */
|
||||
|
||||
@GetMapping
|
||||
public String settings(Model model) {
|
||||
if (!model.containsAttribute("organization")) {
|
||||
model.addAttribute("organization", configService.getOrganization());
|
||||
}
|
||||
if (!model.containsAttribute("config")) {
|
||||
model.addAttribute("config", configService.getSystemConfig());
|
||||
}
|
||||
model.addAttribute("mailReady", notificationService.isReady());
|
||||
model.addAttribute("smsEnabled", smsService.isEnabled());
|
||||
model.addAttribute("adminEmail", notificationService.adminEmail());
|
||||
return "admin/settings";
|
||||
}
|
||||
|
||||
@PostMapping("/test-email")
|
||||
@ResponseBody
|
||||
public ResponseEntity<?> testEmail(@RequestParam String to) {
|
||||
try {
|
||||
notificationService.sendTest(to);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (RuntimeException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/organization")
|
||||
public String saveOrganization(@Valid @ModelAttribute("organization") Organization organization,
|
||||
BindingResult result,
|
||||
Model model,
|
||||
RedirectAttributes ra) {
|
||||
if (result.hasErrors()) {
|
||||
model.addAttribute("config", configService.getSystemConfig());
|
||||
return "admin/settings";
|
||||
}
|
||||
configService.saveOrganization(organization);
|
||||
ra.addFlashAttribute("success", "Organization profile saved");
|
||||
return "redirect:/admin/settings";
|
||||
}
|
||||
|
||||
@PostMapping("/config")
|
||||
public String saveConfig(@Valid @ModelAttribute("config") SystemConfig config,
|
||||
BindingResult result,
|
||||
Model model,
|
||||
RedirectAttributes ra) {
|
||||
if (result.hasErrors()) {
|
||||
model.addAttribute("organization", configService.getOrganization());
|
||||
return "admin/settings";
|
||||
}
|
||||
configService.saveSystemConfig(config);
|
||||
ra.addFlashAttribute("success", "System configuration saved");
|
||||
return "redirect:/admin/settings";
|
||||
}
|
||||
|
||||
/* ---------- Category masters ---------- */
|
||||
|
||||
@GetMapping("/categories")
|
||||
public String categories(Model model) {
|
||||
model.addAttribute("categories", configService.allCategories());
|
||||
model.addAttribute("types", CategoryType.values());
|
||||
return "admin/categories";
|
||||
}
|
||||
|
||||
@PostMapping("/categories")
|
||||
public String addCategory(@RequestParam CategoryType type,
|
||||
@RequestParam String name,
|
||||
@RequestParam(required = false) String description,
|
||||
RedirectAttributes ra) {
|
||||
try {
|
||||
configService.addCategory(type, name.trim(), description);
|
||||
ra.addFlashAttribute("success", type + " category '" + name + "' added");
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ra.addFlashAttribute("error", ex.getMessage());
|
||||
}
|
||||
return "redirect:/admin/settings/categories";
|
||||
}
|
||||
|
||||
@PostMapping("/categories/{id}/toggle")
|
||||
public String toggleCategory(@PathVariable Long id, RedirectAttributes ra) {
|
||||
configService.toggleCategory(id);
|
||||
ra.addFlashAttribute("success", "Category updated");
|
||||
return "redirect:/admin/settings/categories";
|
||||
}
|
||||
|
||||
@PostMapping("/categories/{id}/delete")
|
||||
public String deleteCategory(@PathVariable Long id, RedirectAttributes ra) {
|
||||
configService.deleteCategory(id);
|
||||
ra.addFlashAttribute("success", "Category deleted");
|
||||
return "redirect:/admin/settings/categories";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.SettlementStatus;
|
||||
import com.mvrent.carrental.model.VendorSettlement;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.ExcelService;
|
||||
import com.mvrent.carrental.service.PdfService;
|
||||
import com.mvrent.carrental.service.SettlementService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_BILLING')")
|
||||
public class AdminSettlementController {
|
||||
|
||||
static final MediaType XLSX =
|
||||
MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
|
||||
private final SettlementService settlementService;
|
||||
private final PdfService pdfService;
|
||||
private final ExcelService excelService;
|
||||
private final ConfigService configService;
|
||||
|
||||
@PostMapping("/admin/vendors/{vendorId}/settlements")
|
||||
public String generate(@PathVariable Long vendorId,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodStart,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodEnd,
|
||||
RedirectAttributes ra) {
|
||||
try {
|
||||
VendorSettlement s = settlementService.generate(vendorId, periodStart, periodEnd);
|
||||
ra.addFlashAttribute("success", "Settlement generated — net payable Rs. " + s.getNetPayable());
|
||||
return "redirect:/admin/settlements/" + s.getId();
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ra.addFlashAttribute("error", ex.getMessage());
|
||||
return "redirect:/admin/vendors/" + vendorId;
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/admin/settlements")
|
||||
public String list(Model model) {
|
||||
model.addAttribute("settlements", settlementService.findAll());
|
||||
return "admin/settlements";
|
||||
}
|
||||
|
||||
@GetMapping("/admin/settlements/{id}")
|
||||
public String detail(@PathVariable Long id, Model model) {
|
||||
VendorSettlement s = settlementService.findById(id);
|
||||
model.addAttribute("settlement", s);
|
||||
model.addAttribute("trips", settlementService.tripsFor(s));
|
||||
return "admin/settlement-detail";
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settlements/{id}/status")
|
||||
public String status(@PathVariable Long id, @RequestParam SettlementStatus status, RedirectAttributes ra) {
|
||||
settlementService.updateStatus(id, status);
|
||||
ra.addFlashAttribute("success", "Settlement set to " + status);
|
||||
return "redirect:/admin/settlements/" + id;
|
||||
}
|
||||
|
||||
@GetMapping("/admin/settlements/{id}/pdf")
|
||||
public ResponseEntity<byte[]> pdf(@PathVariable Long id) {
|
||||
VendorSettlement s = settlementService.findById(id);
|
||||
byte[] pdf = pdfService.render("pdf/settlement", Map.of(
|
||||
"settlement", s, "trips", settlementService.tripsFor(s),
|
||||
"orgName", configService.getOrganization().getName()));
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"settlement-" + s.getId() + ".pdf\"")
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
@GetMapping("/admin/settlements/{id}/excel")
|
||||
public ResponseEntity<byte[]> excel(@PathVariable Long id) {
|
||||
VendorSettlement s = settlementService.findById(id);
|
||||
byte[] xlsx = excelService.settlementWorkbook(s, settlementService.tripsFor(s));
|
||||
return ResponseEntity.ok().contentType(XLSX)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"settlement-" + s.getId() + ".xlsx\"")
|
||||
.body(xlsx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.TelematicsService;
|
||||
import com.mvrent.carrental.web.VehicleLocationDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/tracking")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
|
||||
public class AdminTelematicsController {
|
||||
|
||||
private final TelematicsService telematicsService;
|
||||
|
||||
@GetMapping
|
||||
public String page(Model model) {
|
||||
return "admin/tracking";
|
||||
}
|
||||
|
||||
@GetMapping(value = "/data", produces = "application/json")
|
||||
@ResponseBody
|
||||
public List<VehicleLocationDto> data() {
|
||||
return telematicsService.findAll().stream().map(VehicleLocationDto::from).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.FileStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Generic single-file upload — stores the file and returns its public URL. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/upload")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUploadApiController {
|
||||
|
||||
private final FileStorageService fileStorage;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> upload(@RequestParam MultipartFile file) {
|
||||
String url = fileStorage.store(file);
|
||||
if (url == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "No file uploaded"));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("ok", true, "url", url));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import com.mvrent.carrental.web.StaffDto;
|
||||
import com.mvrent.carrental.web.StaffForm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the staff / user management screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/users")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_USERS')")
|
||||
public class AdminUserApiController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public List<StaffDto> list() {
|
||||
return userService.findStaff().stream().map(StaffDto::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public StaffDto get(@PathVariable Long id) {
|
||||
return StaffDto.from(userService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> save(@RequestBody StaffForm f) {
|
||||
try {
|
||||
var saved = (f.id() != null) ? userService.updateStaff(f) : userService.createStaff(f);
|
||||
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return bad(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/enabled")
|
||||
public ResponseEntity<?> setEnabled(@PathVariable Long id, @RequestParam boolean enabled) {
|
||||
try {
|
||||
userService.setEnabled(id, enabled);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return bad(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/password")
|
||||
public ResponseEntity<?> resetPassword(@PathVariable Long id, @RequestParam String password) {
|
||||
try {
|
||||
userService.resetPassword(id, password);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return bad(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
try {
|
||||
userService.deleteStaff(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return bad(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static ResponseEntity<Map<String, Object>> bad(String m) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m == null ? "Operation failed" : m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Permission;
|
||||
import com.mvrent.carrental.model.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_USERS')")
|
||||
public class AdminUserController {
|
||||
|
||||
@GetMapping
|
||||
public String page(Model model) {
|
||||
model.addAttribute("permissions", Permission.values());
|
||||
model.addAttribute("roles", new Role[]{Role.STAFF, Role.ADMIN});
|
||||
return "admin/users";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Vendor;
|
||||
import com.mvrent.carrental.service.VendorService;
|
||||
import com.mvrent.carrental.web.VendorDto;
|
||||
import com.mvrent.carrental.web.VendorForm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** JSON API backing the AJAX Manage Vendors screen. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/api/vendors")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_VENDORS')")
|
||||
public class AdminVendorApiController {
|
||||
|
||||
private final VendorService vendorService;
|
||||
|
||||
@GetMapping
|
||||
public List<VendorDto> list() {
|
||||
return vendorService.findAll().stream().map(VendorDto::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public VendorDto get(@PathVariable Long id) {
|
||||
return VendorDto.from(vendorService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> save(@RequestBody VendorForm f) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (isBlank(f.name())) errors.put("name", "Name is required");
|
||||
if (!errors.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors));
|
||||
}
|
||||
|
||||
// Load existing on edit so we don't null out anything outside the form.
|
||||
Vendor v = f.id() != null ? vendorService.findById(f.id()) : new Vendor();
|
||||
v.setName(trim(f.name()));
|
||||
v.setContactPerson(trim(f.contactPerson()));
|
||||
v.setPhone(trim(f.phone()));
|
||||
v.setEmail(trim(f.email()));
|
||||
v.setAddress(trim(f.address()));
|
||||
v.setGstNumber(trim(f.gstNumber()));
|
||||
v.setSettlementDetails(trim(f.settlementDetails()));
|
||||
v.setCommissionPercent(f.commissionPercent());
|
||||
v.setActive(f.active() == null || f.active());
|
||||
|
||||
Vendor saved = vendorService.save(v);
|
||||
return ResponseEntity.ok(Map.of("ok", true, "vendor", VendorDto.from(saved)));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
try {
|
||||
vendorService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false,
|
||||
"message", "Cannot delete — vendor has cars, drivers or a portal login. Deactivate instead."));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
|
||||
private static String trim(String s) { return s == null ? null : s.trim(); }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Vendor;
|
||||
import com.mvrent.carrental.service.SettlementService;
|
||||
import com.mvrent.carrental.service.VendorService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/vendors")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_VENDORS')")
|
||||
public class AdminVendorController {
|
||||
|
||||
private final VendorService vendorService;
|
||||
private final SettlementService settlementService;
|
||||
|
||||
@GetMapping
|
||||
public String list(Model model) {
|
||||
model.addAttribute("vendors", vendorService.findAll());
|
||||
return "admin/vendors";
|
||||
}
|
||||
|
||||
@GetMapping("/new")
|
||||
public String newVendor(Model model) {
|
||||
if (!model.containsAttribute("vendor")) {
|
||||
model.addAttribute("vendor", new Vendor());
|
||||
}
|
||||
return "admin/vendor-form";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String save(@Valid @ModelAttribute("vendor") Vendor vendor,
|
||||
BindingResult result,
|
||||
RedirectAttributes ra) {
|
||||
if (result.hasErrors()) {
|
||||
return "admin/vendor-form";
|
||||
}
|
||||
Vendor saved = vendorService.save(vendor);
|
||||
ra.addFlashAttribute("success", "Vendor saved");
|
||||
return "redirect:/admin/vendors/" + saved.getId();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public String detail(@PathVariable Long id, Model model) {
|
||||
Vendor vendor = vendorService.findById(id);
|
||||
model.addAttribute("vendor", vendor);
|
||||
model.addAttribute("cars", vendorService.cars(id));
|
||||
model.addAttribute("drivers", vendorService.drivers(id));
|
||||
model.addAttribute("revenue", vendorService.revenue(id));
|
||||
model.addAttribute("settlements", settlementService.findByVendor(id));
|
||||
model.addAttribute("initials", initials(vendor.getName()));
|
||||
return "admin/vendor-detail";
|
||||
}
|
||||
|
||||
private static String initials(String name) {
|
||||
if (name == null || name.isBlank()) return "?";
|
||||
String[] p = name.trim().split("\\s+");
|
||||
if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) {
|
||||
return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase();
|
||||
}
|
||||
return name.substring(0, Math.min(2, name.length())).toUpperCase();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/edit")
|
||||
public String edit(@PathVariable Long id, Model model) {
|
||||
model.addAttribute("vendor", vendorService.findById(id));
|
||||
return "admin/vendor-form";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public String delete(@PathVariable Long id, RedirectAttributes ra) {
|
||||
vendorService.delete(id);
|
||||
ra.addFlashAttribute("success", "Vendor deleted");
|
||||
return "redirect:/admin/vendors";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/portal-user")
|
||||
public String createPortalUser(@PathVariable Long id,
|
||||
@RequestParam String username,
|
||||
@RequestParam String email,
|
||||
@RequestParam String password,
|
||||
RedirectAttributes ra) {
|
||||
try {
|
||||
vendorService.createPortalUser(id, username, email, password);
|
||||
ra.addFlashAttribute("success", "Vendor portal login created for '" + username + "'");
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ra.addFlashAttribute("error", ex.getMessage());
|
||||
}
|
||||
return "redirect:/admin/vendors/" + id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.CategoryType;
|
||||
import com.mvrent.carrental.model.Role;
|
||||
import com.mvrent.carrental.model.User;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final UserService userService;
|
||||
private final ConfigService configService;
|
||||
|
||||
@GetMapping("/login")
|
||||
public String login() {
|
||||
return "auth/login";
|
||||
}
|
||||
|
||||
@GetMapping("/register")
|
||||
public String registerForm(Model model) {
|
||||
if (!model.containsAttribute("user")) {
|
||||
model.addAttribute("user", new User());
|
||||
}
|
||||
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
|
||||
return "auth/register";
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public String register(@Valid @ModelAttribute("user") User user,
|
||||
BindingResult result,
|
||||
@RequestParam String rawPassword,
|
||||
@RequestParam(required = false) Long customerCategoryId,
|
||||
RedirectAttributes ra,
|
||||
Model model) {
|
||||
if (result.hasErrors()) {
|
||||
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
|
||||
return "auth/register";
|
||||
}
|
||||
try {
|
||||
user.setRole(Role.CUSTOMER);
|
||||
user.setCustomerCategory(configService.findCategory(customerCategoryId));
|
||||
userService.register(user, rawPassword);
|
||||
ra.addFlashAttribute("success", "Account created — please log in");
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException ex) {
|
||||
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
|
||||
model.addAttribute("error", ex.getMessage());
|
||||
return "auth/register";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Booking;
|
||||
import com.mvrent.carrental.model.Invoice;
|
||||
import com.mvrent.carrental.model.User;
|
||||
import com.mvrent.carrental.service.BookingService;
|
||||
import com.mvrent.carrental.service.CarService;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.DriverService;
|
||||
import com.mvrent.carrental.service.PaymentService;
|
||||
import com.mvrent.carrental.service.PdfService;
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class BookingController {
|
||||
|
||||
private final BookingService bookingService;
|
||||
private final CarService carService;
|
||||
private final DriverService driverService;
|
||||
private final UserService userService;
|
||||
private final PdfService pdfService;
|
||||
private final ConfigService configService;
|
||||
private final PaymentService paymentService;
|
||||
|
||||
@GetMapping("/cars/{carId}/book")
|
||||
public String newBooking(@PathVariable Long carId,
|
||||
@RequestParam(required = false) Boolean withDriver,
|
||||
@RequestParam(required = false)
|
||||
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate pickup,
|
||||
@RequestParam(required = false)
|
||||
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate ret,
|
||||
Model model) {
|
||||
model.addAttribute("car", carService.findById(carId));
|
||||
if (!model.containsAttribute("booking")) {
|
||||
Booking booking = new Booking();
|
||||
if (withDriver != null) booking.setWithDriver(withDriver);
|
||||
if (pickup != null) booking.setPickupDate(pickup);
|
||||
if (ret != null) booking.setReturnDate(ret);
|
||||
model.addAttribute("booking", booking);
|
||||
}
|
||||
model.addAttribute("drivers", driverService.findAvailable());
|
||||
return "bookings/form";
|
||||
}
|
||||
|
||||
@PostMapping("/cars/{carId}/book")
|
||||
public String createBooking(@PathVariable Long carId,
|
||||
@Valid @ModelAttribute("booking") Booking booking,
|
||||
BindingResult result,
|
||||
@RequestParam(required = false) Long driverId,
|
||||
@AuthenticationPrincipal UserDetails principal,
|
||||
RedirectAttributes ra,
|
||||
Model model) {
|
||||
if (result.hasErrors()) {
|
||||
model.addAttribute("car", carService.findById(carId));
|
||||
model.addAttribute("drivers", driverService.findAvailable());
|
||||
return "bookings/form";
|
||||
}
|
||||
try {
|
||||
User user = userService.findByUsername(principal.getUsername());
|
||||
Booking saved = bookingService.createBooking(user, carId, booking, driverId);
|
||||
ra.addFlashAttribute("success", "Booking confirmed (id #" + saved.getId() + ")");
|
||||
return "redirect:/my-bookings";
|
||||
} catch (RuntimeException ex) {
|
||||
model.addAttribute("car", carService.findById(carId));
|
||||
model.addAttribute("drivers", driverService.findAvailable());
|
||||
model.addAttribute("error", ex.getMessage());
|
||||
return "bookings/form";
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/my-bookings")
|
||||
public String myBookings(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
User user = userService.findByUsername(principal.getUsername());
|
||||
model.addAttribute("bookings", bookingService.findByUser(user));
|
||||
return "bookings/my";
|
||||
}
|
||||
|
||||
@GetMapping("/my-bookings/{id}/invoice")
|
||||
public String invoice(@PathVariable Long id,
|
||||
@AuthenticationPrincipal UserDetails principal,
|
||||
Model model) {
|
||||
User user = userService.findByUsername(principal.getUsername());
|
||||
Booking booking = bookingService.findById(id);
|
||||
if (!booking.getUser().getId().equals(user.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your booking");
|
||||
}
|
||||
java.math.BigDecimal paid = paymentService.totalPaid(id);
|
||||
java.math.BigDecimal total = booking.getTotalAmount() != null ? booking.getTotalAmount() : java.math.BigDecimal.ZERO;
|
||||
model.addAttribute("booking", booking);
|
||||
model.addAttribute("invoice", bookingService.invoiceFor(id));
|
||||
model.addAttribute("amountPaid", paid);
|
||||
model.addAttribute("balance", total.subtract(paid));
|
||||
model.addAttribute("pdfUrl", "/my-bookings/" + id + "/invoice/pdf");
|
||||
return "bookings/invoice";
|
||||
}
|
||||
|
||||
@GetMapping("/my-bookings/{id}/invoice/pdf")
|
||||
public ResponseEntity<byte[]> invoicePdf(@PathVariable Long id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
User user = userService.findByUsername(principal.getUsername());
|
||||
Booking booking = bookingService.findById(id);
|
||||
if (!booking.getUser().getId().equals(user.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your booking");
|
||||
}
|
||||
Invoice invoice = bookingService.invoiceFor(id);
|
||||
byte[] pdf = pdfService.render("pdf/invoice", Map.of(
|
||||
"booking", booking, "invoice", invoice,
|
||||
"orgName", configService.getOrganization().getName()));
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"inline; filename=\"" + invoice.getInvoiceNumber() + ".pdf\"")
|
||||
.body(pdf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Car;
|
||||
import com.mvrent.carrental.model.CarType;
|
||||
import com.mvrent.carrental.model.Transmission;
|
||||
import com.mvrent.carrental.service.CarService;
|
||||
import com.mvrent.carrental.service.DriverService;
|
||||
import com.mvrent.carrental.service.ReviewService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/cars")
|
||||
@RequiredArgsConstructor
|
||||
public class CarController {
|
||||
|
||||
private final CarService carService;
|
||||
private final DriverService driverService;
|
||||
private final ReviewService reviewService;
|
||||
|
||||
@GetMapping
|
||||
public String list(@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pickup,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate ret,
|
||||
@RequestParam(required = false) CarType type,
|
||||
@RequestParam(required = false) Integer minSeats,
|
||||
@RequestParam(required = false) Transmission transmission,
|
||||
@RequestParam(required = false) BigDecimal maxPrice,
|
||||
@RequestParam(required = false) Boolean withDriver,
|
||||
@RequestParam(required = false) String pickupLocation,
|
||||
@RequestParam(required = false) String dropLocation,
|
||||
@RequestParam(required = false) String promo,
|
||||
Model model) {
|
||||
|
||||
List<Car> cars;
|
||||
Long days = null;
|
||||
if (pickup != null && ret != null && ret.isAfter(pickup)) {
|
||||
cars = carService.findAvailableBetween(pickup, ret);
|
||||
days = Math.max(1, ChronoUnit.DAYS.between(pickup, ret));
|
||||
} else {
|
||||
cars = carService.findAvailable();
|
||||
}
|
||||
|
||||
// In-memory refine by the optional attribute filters.
|
||||
cars = cars.stream()
|
||||
.filter(c -> type == null || c.getType() == type)
|
||||
.filter(c -> minSeats == null || c.getSeats() >= minSeats)
|
||||
.filter(c -> transmission == null || c.getTransmission() == transmission)
|
||||
.filter(c -> maxPrice == null || c.getPricePerDay().compareTo(maxPrice) <= 0)
|
||||
.toList();
|
||||
|
||||
model.addAttribute("cars", cars);
|
||||
model.addAttribute("days", days);
|
||||
model.addAttribute("availableDrivers", driverService.findAvailable().size());
|
||||
// echo filters back into the form
|
||||
model.addAttribute("pickup", pickup);
|
||||
model.addAttribute("ret", ret);
|
||||
model.addAttribute("type", type);
|
||||
model.addAttribute("minSeats", minSeats);
|
||||
model.addAttribute("transmission", transmission);
|
||||
model.addAttribute("maxPrice", maxPrice);
|
||||
model.addAttribute("withDriver", withDriver);
|
||||
model.addAttribute("pickupLocation", pickupLocation);
|
||||
model.addAttribute("dropLocation", dropLocation);
|
||||
model.addAttribute("promo", promo);
|
||||
model.addAttribute("carTypes", CarType.values());
|
||||
model.addAttribute("transmissions", Transmission.values());
|
||||
return "cars/list";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public String detail(@PathVariable Long id,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pickup,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate ret,
|
||||
Model model) {
|
||||
model.addAttribute("car", carService.findById(id));
|
||||
Long days = (pickup != null && ret != null && ret.isAfter(pickup))
|
||||
? Math.max(1, ChronoUnit.DAYS.between(pickup, ret)) : null;
|
||||
model.addAttribute("days", days);
|
||||
model.addAttribute("pickup", pickup);
|
||||
model.addAttribute("ret", ret);
|
||||
model.addAttribute("availableDrivers", driverService.findAvailable().size());
|
||||
model.addAttribute("reviews", reviewService.approvedForCar(id));
|
||||
model.addAttribute("avgRating", reviewService.averageForCar(id));
|
||||
model.addAttribute("reviewCount", reviewService.countForCar(id));
|
||||
return "cars/detail";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/** Dedicated login entry point for the driver portal. */
|
||||
@Controller
|
||||
public class DriverAuthController {
|
||||
|
||||
@GetMapping("/driver/login")
|
||||
public String login() {
|
||||
return "driver/login";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Booking;
|
||||
import com.mvrent.carrental.model.BookingStatus;
|
||||
import com.mvrent.carrental.model.Driver;
|
||||
import com.mvrent.carrental.model.DriverStatus;
|
||||
import com.mvrent.carrental.model.User;
|
||||
import com.mvrent.carrental.service.DriverService;
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** Self-service portal scoped to the logged-in driver's own record and trips. */
|
||||
@Controller
|
||||
@RequestMapping("/driver")
|
||||
@RequiredArgsConstructor
|
||||
public class DriverPortalController {
|
||||
|
||||
private final UserService userService;
|
||||
private final DriverService driverService;
|
||||
|
||||
private Driver currentDriver(UserDetails principal) {
|
||||
User user = userService.findByUsername(principal.getUsername());
|
||||
Driver driver = user.getDriver();
|
||||
if (driver == null) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No driver linked to this account");
|
||||
}
|
||||
return driver;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String dashboard(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Driver driver = currentDriver(principal);
|
||||
List<Booking> trips = driverService.tripHistory(driver);
|
||||
LocalDate today = LocalDate.now();
|
||||
List<Booking> upcoming = trips.stream()
|
||||
.filter(b -> b.getReturnDate() != null && !b.getReturnDate().isBefore(today)
|
||||
&& (b.getStatus() == BookingStatus.PENDING || b.getStatus() == BookingStatus.CONFIRMED))
|
||||
.toList();
|
||||
model.addAttribute("driver", driver);
|
||||
model.addAttribute("initials", initials(driver.getName()));
|
||||
model.addAttribute("totalTrips", driverService.totalTrips(driver));
|
||||
model.addAttribute("upcoming", upcoming);
|
||||
model.addAttribute("recentTrips", trips.stream().limit(6).toList());
|
||||
return "driver/dashboard";
|
||||
}
|
||||
|
||||
@GetMapping("/trips")
|
||||
public String trips(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Driver driver = currentDriver(principal);
|
||||
model.addAttribute("driver", driver);
|
||||
model.addAttribute("initials", initials(driver.getName()));
|
||||
model.addAttribute("trips", driverService.tripHistory(driver));
|
||||
return "driver/trips";
|
||||
}
|
||||
|
||||
@GetMapping("/profile")
|
||||
public String profile(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Driver driver = currentDriver(principal);
|
||||
model.addAttribute("driver", driver);
|
||||
model.addAttribute("initials", initials(driver.getName()));
|
||||
return "driver/profile";
|
||||
}
|
||||
|
||||
/** Driver toggles their own availability (AVAILABLE ↔ OFF). */
|
||||
@PostMapping("/availability")
|
||||
public String availability(@AuthenticationPrincipal UserDetails principal,
|
||||
@RequestParam DriverStatus status,
|
||||
RedirectAttributes ra) {
|
||||
Driver driver = currentDriver(principal);
|
||||
// A driver may only mark themselves available or off (not ON_TRIP).
|
||||
DriverStatus target = (status == DriverStatus.OFF) ? DriverStatus.OFF : DriverStatus.AVAILABLE;
|
||||
driverService.updateStatus(driver.getId(), target);
|
||||
ra.addFlashAttribute("success", "You are now " + (target == DriverStatus.OFF ? "off duty" : "available"));
|
||||
return "redirect:/driver";
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public String changePassword(@AuthenticationPrincipal UserDetails principal,
|
||||
@RequestParam String currentPassword,
|
||||
@RequestParam String newPassword,
|
||||
RedirectAttributes ra) {
|
||||
try {
|
||||
userService.changePassword(principal.getUsername(), currentPassword, newPassword);
|
||||
ra.addFlashAttribute("success", "Password updated");
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ra.addFlashAttribute("error", ex.getMessage());
|
||||
}
|
||||
return "redirect:/driver/profile";
|
||||
}
|
||||
|
||||
private static String initials(String name) {
|
||||
if (name == null || name.isBlank()) return "?";
|
||||
String[] p = name.trim().split("\\s+");
|
||||
if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) {
|
||||
return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase();
|
||||
}
|
||||
return name.substring(0, Math.min(2, name.length())).toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.Car;
|
||||
import com.mvrent.carrental.service.CarService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class HomeController {
|
||||
|
||||
private final CarService carService;
|
||||
|
||||
@GetMapping("/")
|
||||
public String home(Model model) {
|
||||
List<Car> available = carService.findAvailable();
|
||||
BigDecimal minRate = available.stream()
|
||||
.map(Car::getPricePerDay)
|
||||
.min(Comparator.naturalOrder())
|
||||
.orElse(BigDecimal.valueOf(800));
|
||||
model.addAttribute("featuredCars", available.stream().limit(8).toList());
|
||||
model.addAttribute("minRate", minRate);
|
||||
return "index";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.User;
|
||||
import com.mvrent.carrental.service.ReviewService;
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/** Customer-facing endpoint for leaving a car review. */
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class ReviewController {
|
||||
|
||||
private final ReviewService reviewService;
|
||||
private final UserService userService;
|
||||
|
||||
@PostMapping("/reviews")
|
||||
public String submit(@RequestParam Long carId,
|
||||
@RequestParam(required = false) Long bookingId,
|
||||
@RequestParam int rating,
|
||||
@RequestParam(required = false) String comment,
|
||||
@AuthenticationPrincipal UserDetails principal,
|
||||
RedirectAttributes ra) {
|
||||
try {
|
||||
User user = userService.findByUsername(principal.getUsername());
|
||||
reviewService.addReview(user, carId, bookingId, rating, comment);
|
||||
ra.addFlashAttribute("reviewSuccess", "Thanks! Your review has been posted.");
|
||||
} catch (RuntimeException ex) {
|
||||
ra.addFlashAttribute("reviewError", ex.getMessage());
|
||||
}
|
||||
return "redirect:/cars/" + carId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.service.TelematicsService;
|
||||
import com.mvrent.carrental.web.TelematicsPing;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Device-facing ingest endpoint for GPS pings. Authenticated by an API key
|
||||
* header ({@code X-API-KEY}) rather than a session, so trackers can post directly.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/telematics")
|
||||
@RequiredArgsConstructor
|
||||
public class TelematicsApiController {
|
||||
|
||||
private final TelematicsService telematicsService;
|
||||
|
||||
@PostMapping("/ingest")
|
||||
public ResponseEntity<?> ingest(@RequestHeader(value = "X-API-KEY", required = false) String apiKey,
|
||||
@RequestBody TelematicsPing ping) {
|
||||
if (!telematicsService.apiKeyValid(apiKey)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("ok", false, "message", "Invalid API key"));
|
||||
}
|
||||
try {
|
||||
telematicsService.ingest(ping);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/** Dedicated login entry point for the vendor portal. */
|
||||
@Controller
|
||||
public class VendorAuthController {
|
||||
|
||||
@GetMapping("/vendor/login")
|
||||
public String login() {
|
||||
return "vendor/login";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.mvrent.carrental.controller;
|
||||
|
||||
import com.mvrent.carrental.model.User;
|
||||
import com.mvrent.carrental.model.Vendor;
|
||||
import com.mvrent.carrental.model.VendorSettlement;
|
||||
import com.mvrent.carrental.service.ConfigService;
|
||||
import com.mvrent.carrental.service.ExcelService;
|
||||
import com.mvrent.carrental.service.PdfService;
|
||||
import com.mvrent.carrental.service.SettlementService;
|
||||
import com.mvrent.carrental.service.UserService;
|
||||
import com.mvrent.carrental.service.VendorService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Self-service portal scoped to the logged-in vendor's own data. */
|
||||
@Controller
|
||||
@RequestMapping("/vendor")
|
||||
@RequiredArgsConstructor
|
||||
public class VendorPortalController {
|
||||
|
||||
private final UserService userService;
|
||||
private final VendorService vendorService;
|
||||
private final SettlementService settlementService;
|
||||
private final PdfService pdfService;
|
||||
private final ExcelService excelService;
|
||||
private final ConfigService configService;
|
||||
|
||||
private Vendor currentVendor(UserDetails principal) {
|
||||
User user = userService.findByUsername(principal.getUsername());
|
||||
Vendor vendor = user.getVendor();
|
||||
if (vendor == null) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No vendor linked to this account");
|
||||
}
|
||||
return vendor;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String dashboard(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Vendor vendor = currentVendor(principal);
|
||||
model.addAttribute("vendor", vendor);
|
||||
model.addAttribute("carCount", vendorService.cars(vendor.getId()).size());
|
||||
model.addAttribute("driverCount", vendorService.drivers(vendor.getId()).size());
|
||||
model.addAttribute("revenue", vendorService.revenue(vendor.getId()));
|
||||
model.addAttribute("recentTrips", vendorService.trips(vendor.getId()).stream().limit(5).toList());
|
||||
return "vendor/dashboard";
|
||||
}
|
||||
|
||||
@GetMapping("/cars")
|
||||
public String cars(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Vendor vendor = currentVendor(principal);
|
||||
model.addAttribute("vendor", vendor);
|
||||
model.addAttribute("cars", vendorService.cars(vendor.getId()));
|
||||
return "vendor/cars";
|
||||
}
|
||||
|
||||
@GetMapping("/drivers")
|
||||
public String drivers(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Vendor vendor = currentVendor(principal);
|
||||
model.addAttribute("vendor", vendor);
|
||||
model.addAttribute("drivers", vendorService.drivers(vendor.getId()));
|
||||
return "vendor/drivers";
|
||||
}
|
||||
|
||||
@GetMapping("/trips")
|
||||
public String trips(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Vendor vendor = currentVendor(principal);
|
||||
model.addAttribute("vendor", vendor);
|
||||
model.addAttribute("trips", vendorService.trips(vendor.getId()));
|
||||
model.addAttribute("revenue", vendorService.revenue(vendor.getId()));
|
||||
return "vendor/trips";
|
||||
}
|
||||
|
||||
@GetMapping("/settlements")
|
||||
public String settlements(@AuthenticationPrincipal UserDetails principal, Model model) {
|
||||
Vendor vendor = currentVendor(principal);
|
||||
model.addAttribute("vendor", vendor);
|
||||
model.addAttribute("settlements", settlementService.findByVendor(vendor.getId()));
|
||||
return "vendor/settlements";
|
||||
}
|
||||
|
||||
private VendorSettlement ownSettlement(UserDetails principal, Long id) {
|
||||
Vendor vendor = currentVendor(principal);
|
||||
VendorSettlement s = settlementService.findById(id);
|
||||
if (!s.getVendor().getId().equals(vendor.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your settlement");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@GetMapping("/settlements/{id}/pdf")
|
||||
public ResponseEntity<byte[]> settlementPdf(@AuthenticationPrincipal UserDetails principal,
|
||||
@PathVariable Long id) {
|
||||
VendorSettlement s = ownSettlement(principal, id);
|
||||
byte[] pdf = pdfService.render("pdf/settlement", Map.of(
|
||||
"settlement", s, "trips", settlementService.tripsFor(s),
|
||||
"orgName", configService.getOrganization().getName()));
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"settlement-" + s.getId() + ".pdf\"")
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
@GetMapping("/settlements/{id}/excel")
|
||||
public ResponseEntity<byte[]> settlementExcel(@AuthenticationPrincipal UserDetails principal,
|
||||
@PathVariable Long id) {
|
||||
VendorSettlement s = ownSettlement(principal, id);
|
||||
byte[] xlsx = excelService.settlementWorkbook(s, settlementService.tripsFor(s));
|
||||
return ResponseEntity.ok().contentType(AdminSettlementController.XLSX)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"settlement-" + s.getId() + ".xlsx\"")
|
||||
.body(xlsx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** An immutable record of a significant action — who did what, when. */
|
||||
@Entity
|
||||
@Table(name = "audit_logs", indexes = {
|
||||
@Index(name = "idx_audit_created", columnList = "createdAt"),
|
||||
@Index(name = "idx_audit_username", columnList = "username")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class AuditLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private String username;
|
||||
|
||||
private String role;
|
||||
|
||||
/** CREATE, UPDATE, DELETE, GENERATE, LOGIN_SUCCESS, LOGIN_FAILURE, … */
|
||||
private String action;
|
||||
|
||||
/** The kind of thing acted on (Car, Driver, Booking, …). */
|
||||
private String entityType;
|
||||
|
||||
private String entityId;
|
||||
|
||||
@Column(length = 500)
|
||||
private String detail;
|
||||
|
||||
private String ip;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "bookings")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Booking {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "car_id")
|
||||
private Car car;
|
||||
|
||||
/** Assigned driver for with-driver bookings. */
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "driver_id")
|
||||
private Driver driver;
|
||||
|
||||
@ColumnDefault("false")
|
||||
@Builder.Default
|
||||
private boolean withDriver = false;
|
||||
|
||||
@NotNull
|
||||
private LocalDate pickupDate;
|
||||
|
||||
@NotNull
|
||||
private LocalDate returnDate;
|
||||
|
||||
@NotBlank
|
||||
private String pickupLocation;
|
||||
|
||||
@NotBlank
|
||||
private String dropLocation;
|
||||
|
||||
/* ---------- Period & charge breakdown ---------- */
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@ColumnDefault("'DAY'")
|
||||
@Builder.Default
|
||||
private RentalUnit rentalUnit = RentalUnit.DAY;
|
||||
|
||||
/** Number of billed periods (days or months). */
|
||||
@ColumnDefault("1")
|
||||
@Builder.Default
|
||||
private int quantity = 1;
|
||||
|
||||
private BigDecimal vehicleCharge;
|
||||
private BigDecimal driverCharge;
|
||||
private BigDecimal taxPercent;
|
||||
private BigDecimal taxAmount;
|
||||
|
||||
@Column(nullable = false)
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
/* ---------- Revenue attribution ---------- */
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private CarOwnership revenueOwner;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "vendor_id")
|
||||
private Vendor vendor;
|
||||
|
||||
private BigDecimal commissionPercent;
|
||||
private BigDecimal commissionAmount;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private BookingStatus status;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (status == null) status = BookingStatus.PENDING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
public enum BookingStatus {
|
||||
PENDING, CONFIRMED, CANCELLED, COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "cars")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Car {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank
|
||||
private String make;
|
||||
|
||||
@NotBlank
|
||||
private String model;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
@NotBlank
|
||||
private String registrationNumber;
|
||||
|
||||
/** Year the vehicle was registered, e.g. 2022. */
|
||||
private Integer yearOfRegistration;
|
||||
|
||||
private String colour;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
private CarType type;
|
||||
|
||||
@Min(2)
|
||||
@Max(15)
|
||||
private int seats;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Transmission transmission;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private FuelType fuelType;
|
||||
|
||||
/** Feature tags such as AC, GPS, Sunroof, Bluetooth. */
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "car_feature_tags", joinColumns = @JoinColumn(name = "car_id"))
|
||||
@Column(name = "tag")
|
||||
@Builder.Default
|
||||
private Set<String> featureTags = new HashSet<>();
|
||||
|
||||
/* ---------- Rates ---------- */
|
||||
|
||||
private BigDecimal hourlyRate;
|
||||
|
||||
@NotNull
|
||||
@DecimalMin("0.0")
|
||||
private BigDecimal pricePerDay; // daily rate
|
||||
|
||||
private BigDecimal monthlyRate;
|
||||
|
||||
/* ---------- Ownership & availability ---------- */
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@ColumnDefault("'OWNED'")
|
||||
@Builder.Default
|
||||
private CarOwnership ownership = CarOwnership.OWNED;
|
||||
|
||||
/** Set when ownership = VENDOR. */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "vendor_id")
|
||||
private Vendor vendor;
|
||||
|
||||
/** Per-vehicle commission override; falls back to vendor/global when null. */
|
||||
private BigDecimal commissionPercentOverride;
|
||||
|
||||
@ColumnDefault("1")
|
||||
@Builder.Default
|
||||
private int unitsTotal = 1;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@ColumnDefault("'ACTIVE'")
|
||||
@Builder.Default
|
||||
private CarStatus status = CarStatus.ACTIVE;
|
||||
|
||||
private String imageUrl;
|
||||
|
||||
@Builder.Default
|
||||
private boolean available = true;
|
||||
|
||||
/* ---------- Documents & photos ---------- */
|
||||
|
||||
@OneToMany(mappedBy = "car", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("expiryDate ASC")
|
||||
@Builder.Default
|
||||
private List<CarDocument> documents = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "car", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("sortOrder ASC")
|
||||
@Builder.Default
|
||||
private List<CarPhoto> photos = new ArrayList<>();
|
||||
|
||||
/** Bookable only when listed and operationally active. */
|
||||
@Transient
|
||||
public boolean isBookable() {
|
||||
return available && status == CarStatus.ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* A compliance document (RC, insurance, permit, …) for a car, with an expiry
|
||||
* date so the fleet can flag documents that are expired or expiring soon.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "car_documents")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CarDocument {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "car_id")
|
||||
private Car car;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
private CarDocumentType docType;
|
||||
|
||||
/** Path or URL to the stored file (local filesystem path for v1). */
|
||||
private String fileUrl;
|
||||
|
||||
private LocalDate issuedDate;
|
||||
|
||||
private LocalDate expiryDate;
|
||||
|
||||
@Transient
|
||||
public boolean isExpired() {
|
||||
return expiryDate != null && expiryDate.isBefore(LocalDate.now());
|
||||
}
|
||||
|
||||
@Transient
|
||||
public boolean isExpiringSoon() {
|
||||
return expiryDate != null
|
||||
&& !expiryDate.isBefore(LocalDate.now())
|
||||
&& expiryDate.isBefore(LocalDate.now().plusDays(30));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Type of compliance document attached to a car. */
|
||||
public enum CarDocumentType {
|
||||
RC,
|
||||
INSURANCE,
|
||||
PERMIT,
|
||||
POLLUTION,
|
||||
FITNESS,
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Whether a car belongs to the organization or is attached by a vendor. */
|
||||
public enum CarOwnership {
|
||||
OWNED,
|
||||
VENDOR
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/** A gallery photo for a car (URL/path based for v1). */
|
||||
@Entity
|
||||
@Table(name = "car_photos")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CarPhoto {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "car_id")
|
||||
private Car car;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String url;
|
||||
|
||||
@Builder.Default
|
||||
private int sortOrder = 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Operational state of a car. Only ACTIVE cars can be booked. */
|
||||
public enum CarStatus {
|
||||
ACTIVE,
|
||||
MAINTENANCE,
|
||||
RETIRED
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
public enum CarType {
|
||||
HATCHBACK, SEDAN, SUV, MUV, LUXURY, BIKE
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* Admin-configurable master category for vehicles, drivers or customers.
|
||||
* Replaces hard-coded enums going forward; consumed by the Fleet/Driver/Customer
|
||||
* modules in their respective phases.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "categories",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"type", "name"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Category {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
@Column(nullable = false)
|
||||
private CategoryType type;
|
||||
|
||||
@NotBlank
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
private String description;
|
||||
|
||||
@Builder.Default
|
||||
private boolean active = true;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Which domain a {@link Category} master record applies to. */
|
||||
public enum CategoryType {
|
||||
VEHICLE,
|
||||
DRIVER,
|
||||
CUSTOMER
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "drivers")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Driver {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank
|
||||
private String name;
|
||||
|
||||
private String phone;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
@NotBlank
|
||||
private String licenceNumber;
|
||||
|
||||
private LocalDate licenceExpiry;
|
||||
|
||||
@Min(0)
|
||||
@ColumnDefault("0")
|
||||
@Builder.Default
|
||||
private int yearsExperience = 0;
|
||||
|
||||
/** Driver category (master record of type DRIVER). */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "category_id")
|
||||
private Category category;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@ColumnDefault("'EMPLOYED'")
|
||||
@Builder.Default
|
||||
private DriverEmploymentType employmentType = DriverEmploymentType.EMPLOYED;
|
||||
|
||||
/** Set when employmentType = VENDOR. */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "vendor_id")
|
||||
private Vendor vendor;
|
||||
|
||||
/* ---------- Charges ---------- */
|
||||
|
||||
private BigDecimal hourlyCharge;
|
||||
private BigDecimal dailyCharge;
|
||||
private BigDecimal monthlyCharge;
|
||||
|
||||
/* ---------- Availability ---------- */
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@ColumnDefault("'AVAILABLE'")
|
||||
@Builder.Default
|
||||
private DriverStatus status = DriverStatus.AVAILABLE;
|
||||
|
||||
private String photoUrl;
|
||||
|
||||
@OneToMany(mappedBy = "driver", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("expiryDate ASC")
|
||||
@Builder.Default
|
||||
private List<DriverDocument> documents = new ArrayList<>();
|
||||
|
||||
@Transient
|
||||
public boolean isLicenceExpired() {
|
||||
return licenceExpiry != null && licenceExpiry.isBefore(LocalDate.now());
|
||||
}
|
||||
|
||||
@Transient
|
||||
public boolean isAssignable() {
|
||||
return status == DriverStatus.AVAILABLE && !isLicenceExpired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** A document for a driver (licence, ID proof, …) with expiry tracking. */
|
||||
@Entity
|
||||
@Table(name = "driver_documents")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DriverDocument {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "driver_id")
|
||||
private Driver driver;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
private DriverDocumentType docType;
|
||||
|
||||
private String fileUrl;
|
||||
|
||||
private LocalDate issuedDate;
|
||||
|
||||
private LocalDate expiryDate;
|
||||
|
||||
@Transient
|
||||
public boolean isExpired() {
|
||||
return expiryDate != null && expiryDate.isBefore(LocalDate.now());
|
||||
}
|
||||
|
||||
@Transient
|
||||
public boolean isExpiringSoon() {
|
||||
return expiryDate != null
|
||||
&& !expiryDate.isBefore(LocalDate.now())
|
||||
&& expiryDate.isBefore(LocalDate.now().plusDays(30));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Type of document attached to a driver. */
|
||||
public enum DriverDocumentType {
|
||||
LICENCE,
|
||||
ID_PROOF,
|
||||
POLICE_VERIFICATION,
|
||||
MEDICAL,
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Whether a driver is employed by the organization or supplied by a vendor. */
|
||||
public enum DriverEmploymentType {
|
||||
EMPLOYED,
|
||||
VENDOR
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Availability state of a driver. Only AVAILABLE drivers can be assigned. */
|
||||
public enum DriverStatus {
|
||||
AVAILABLE,
|
||||
ON_TRIP,
|
||||
OFF
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
public enum FuelType {
|
||||
PETROL, DIESEL, ELECTRIC, HYBRID, CNG
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Overall condition recorded at an inspection. */
|
||||
public enum InspectionCondition {
|
||||
GOOD,
|
||||
MINOR_DAMAGE,
|
||||
MAJOR_DAMAGE
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** A pre/post-trip or periodic condition & damage inspection of a car. */
|
||||
@Entity
|
||||
@Table(name = "inspection_records")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class InspectionRecord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "car_id")
|
||||
private Car car;
|
||||
|
||||
/** Optional link to the trip this inspection belongs to. */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "booking_id")
|
||||
private Booking booking;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
private InspectionType type;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
@Builder.Default
|
||||
private InspectionCondition condition = InspectionCondition.GOOD;
|
||||
|
||||
private LocalDate inspectionDate;
|
||||
|
||||
private String inspector;
|
||||
|
||||
private Integer odometer;
|
||||
|
||||
/** Fuel level as a percentage 0–100. */
|
||||
private Integer fuelLevel;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String damageNotes;
|
||||
|
||||
/** Path/URL to an uploaded photo of the damage / condition. */
|
||||
private String photoUrl;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Transient
|
||||
public boolean hasDamage() {
|
||||
return condition != null && condition != InspectionCondition.GOOD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** When a vehicle inspection was carried out. */
|
||||
public enum InspectionType {
|
||||
PRE_TRIP,
|
||||
POST_TRIP,
|
||||
PERIODIC,
|
||||
INCIDENT
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** A customer invoice generated for a booking. */
|
||||
@Entity
|
||||
@Table(name = "invoices")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Invoice {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@OneToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "booking_id", unique = true)
|
||||
private Booking booking;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
private String invoiceNumber;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime issuedAt;
|
||||
|
||||
private BigDecimal subtotal;
|
||||
private BigDecimal taxAmount;
|
||||
private BigDecimal total;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (issuedAt == null) issuedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** A service / repair entry in a car's maintenance history. */
|
||||
@Entity
|
||||
@Table(name = "maintenance_records")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class MaintenanceRecord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "car_id")
|
||||
private Car car;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
private MaintenanceType type;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
@Builder.Default
|
||||
private MaintenanceStatus status = MaintenanceStatus.COMPLETED;
|
||||
|
||||
private LocalDate serviceDate;
|
||||
|
||||
/** Odometer reading at the time of service (km). */
|
||||
private Integer odometer;
|
||||
|
||||
private BigDecimal cost;
|
||||
|
||||
/** Garage / workshop that did the work. */
|
||||
private String garage;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String notes;
|
||||
|
||||
private LocalDate nextServiceDate;
|
||||
|
||||
private Integer nextServiceOdometer;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Transient
|
||||
public boolean isOpen() {
|
||||
return status != MaintenanceStatus.COMPLETED;
|
||||
}
|
||||
|
||||
@Transient
|
||||
public boolean isDueSoon() {
|
||||
return nextServiceDate != null
|
||||
&& !nextServiceDate.isBefore(LocalDate.now())
|
||||
&& nextServiceDate.isBefore(LocalDate.now().plusDays(30));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Lifecycle of a maintenance job. An open job (SCHEDULED/IN_PROGRESS) takes the car off the road. */
|
||||
public enum MaintenanceStatus {
|
||||
SCHEDULED,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Kind of work done on a vehicle. */
|
||||
public enum MaintenanceType {
|
||||
SERVICE,
|
||||
REPAIR,
|
||||
TYRE,
|
||||
BATTERY,
|
||||
BODY_WORK,
|
||||
CLEANING,
|
||||
INSURANCE_RENEWAL,
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* The single rental company that owns this installation. One row only (id = 1).
|
||||
* Branding here is applied to the customer page and exported reports.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "organization")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Organization {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank
|
||||
private String name;
|
||||
|
||||
private String legalName;
|
||||
|
||||
private String logoUrl;
|
||||
|
||||
private String address;
|
||||
|
||||
private String gstNumber;
|
||||
|
||||
@Email
|
||||
private String contactEmail;
|
||||
|
||||
private String phone;
|
||||
|
||||
@NotBlank
|
||||
@Builder.Default
|
||||
private String currencyCode = "INR";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** A payment collected against a booking (counter cash, UPI, card, gateway, …). */
|
||||
@Entity
|
||||
@Table(name = "payments")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Payment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "booking_id")
|
||||
private Booking booking;
|
||||
|
||||
@NotNull
|
||||
private BigDecimal amount;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
@Builder.Default
|
||||
private PaymentMethod method = PaymentMethod.CASH;
|
||||
|
||||
/** Transaction / cheque / UPI reference. */
|
||||
private String reference;
|
||||
|
||||
private LocalDate paidAt;
|
||||
|
||||
private String notes;
|
||||
|
||||
/** Username of the staff member who recorded it. */
|
||||
private String recordedBy;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** How a payment was collected. */
|
||||
public enum PaymentMethod {
|
||||
CASH,
|
||||
UPI,
|
||||
CARD,
|
||||
BANK_TRANSFER,
|
||||
ONLINE,
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/**
|
||||
* Granular capabilities assignable per staff user (see {@link User#getPermissions()}).
|
||||
* ADMIN implicitly has all of these; STAFF gets a configurable subset.
|
||||
*/
|
||||
public enum Permission {
|
||||
MANAGE_FLEET,
|
||||
MANAGE_DRIVERS,
|
||||
MANAGE_VENDORS,
|
||||
MANAGE_BOOKINGS,
|
||||
MANAGE_BILLING,
|
||||
VIEW_REPORTS,
|
||||
MANAGE_CONFIG,
|
||||
MANAGE_USERS
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Billing period for a booking. HOUR is reserved for a future release. */
|
||||
public enum RentalUnit {
|
||||
HOUR,
|
||||
DAY,
|
||||
MONTH
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** A customer's star rating and comment for a car. */
|
||||
@Entity
|
||||
@Table(name = "reviews")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Review {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "car_id")
|
||||
private Car car;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
/** The trip this review relates to (optional). */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "booking_id")
|
||||
private Booking booking;
|
||||
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
private int rating;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String comment;
|
||||
|
||||
/** Published reviews are shown publicly; admins can unpublish. */
|
||||
@Builder.Default
|
||||
private boolean approved = true;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/**
|
||||
* Top-level account type. ADMIN/STAFF use the /admin chain, VENDOR the /vendor
|
||||
* portal, DRIVER the /driver portal, CUSTOMER the public site. Fine-grained
|
||||
* access for staff is controlled by {@link Permission}.
|
||||
*/
|
||||
public enum Role {
|
||||
ADMIN,
|
||||
STAFF,
|
||||
VENDOR,
|
||||
DRIVER,
|
||||
CUSTOMER
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** How often vendor settlements are aggregated. */
|
||||
public enum SettlementCycle {
|
||||
WEEKLY,
|
||||
MONTHLY
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
/** Lifecycle of a vendor settlement statement. */
|
||||
public enum SettlementStatus {
|
||||
DRAFT,
|
||||
FINALISED,
|
||||
PAID
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* Master configuration singleton (id = 1): currency, tax, default charges and
|
||||
* commission rules used across booking, billing and settlement calculations.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "system_config")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SystemConfig {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Builder.Default
|
||||
private String currencyCode = "INR";
|
||||
|
||||
/** Default GST/tax percent applied to bookings, e.g. 18.00 */
|
||||
@Builder.Default
|
||||
private BigDecimal defaultTaxPercent = new BigDecimal("18.00");
|
||||
|
||||
/** Default organization commission on vendor revenue, e.g. 15.00 */
|
||||
@Builder.Default
|
||||
private BigDecimal defaultCommissionPercent = new BigDecimal("15.00");
|
||||
|
||||
/** Fallback driver day charge when a driver has none set. */
|
||||
@Builder.Default
|
||||
private BigDecimal defaultDriverDailyCharge = new BigDecimal("800.00");
|
||||
|
||||
@Builder.Default
|
||||
private String invoicePrefix = "INV";
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Builder.Default
|
||||
private SettlementCycle settlementCycle = SettlementCycle.MONTHLY;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
public enum Transmission {
|
||||
MANUAL, AUTOMATIC
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 50)
|
||||
private String username;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
@Email
|
||||
@NotBlank
|
||||
private String email;
|
||||
|
||||
// Set programmatically (encoded) by UserService/registration, not bound from the form.
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
|
||||
@NotBlank
|
||||
private String fullName;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String licenseNumber;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Role role;
|
||||
|
||||
/** Set for ROLE_VENDOR portal users — links the login to its vendor. */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "vendor_id")
|
||||
private Vendor vendor;
|
||||
|
||||
/** Set for ROLE_DRIVER portal users — links the login to its driver record. */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "driver_id")
|
||||
private Driver driver;
|
||||
|
||||
/** Customer segment (master category of type CUSTOMER); optional. */
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "customer_category_id")
|
||||
private Category customerCategory;
|
||||
|
||||
/** Granular staff capabilities; empty for customers/vendors. */
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "user_permissions", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "permission")
|
||||
@Builder.Default
|
||||
private Set<Permission> permissions = new HashSet<>();
|
||||
|
||||
@Builder.Default
|
||||
private boolean enabled = true;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** The latest known GPS position / telematics state for a car (one row per car). */
|
||||
@Entity
|
||||
@Table(name = "vehicle_locations")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class VehicleLocation {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@OneToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "car_id", unique = true)
|
||||
private Car car;
|
||||
|
||||
private double latitude;
|
||||
|
||||
private double longitude;
|
||||
|
||||
/** Speed in km/h. */
|
||||
private Double speedKph;
|
||||
|
||||
/** Heading in degrees (0–360). */
|
||||
private Double heading;
|
||||
|
||||
private Integer odometer;
|
||||
|
||||
private Boolean ignitionOn;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Considered "live" if a ping arrived in the last 5 minutes. */
|
||||
@Transient
|
||||
public boolean isLive() {
|
||||
return updatedAt != null && updatedAt.isAfter(LocalDateTime.now().minusMinutes(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* A third party that attaches cars/drivers to the fleet. Vendors get a scoped
|
||||
* /vendor portal login and earn revenue minus the organization's commission.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "vendors")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Vendor {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank
|
||||
private String name;
|
||||
|
||||
private String contactPerson;
|
||||
|
||||
private String phone;
|
||||
|
||||
@Email
|
||||
private String email;
|
||||
|
||||
private String address;
|
||||
|
||||
private String gstNumber;
|
||||
|
||||
/** Free-text bank / settlement details. */
|
||||
private String settlementDetails;
|
||||
|
||||
/** Per-vendor commission percent; overrides the global default when set. */
|
||||
private BigDecimal commissionPercent;
|
||||
|
||||
@ColumnDefault("true")
|
||||
@Builder.Default
|
||||
private boolean active = true;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.mvrent.carrental.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* An aggregated settlement statement for a vendor over a period:
|
||||
* gross rental revenue, organization commission, vendor driver charges and the
|
||||
* resulting net payable.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "vendor_settlements")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class VendorSettlement {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "vendor_id")
|
||||
private Vendor vendor;
|
||||
|
||||
private LocalDate periodStart;
|
||||
private LocalDate periodEnd;
|
||||
|
||||
@Builder.Default
|
||||
private int tripCount = 0;
|
||||
|
||||
private BigDecimal grossRevenue;
|
||||
private BigDecimal commissionAmount;
|
||||
private BigDecimal driverCharges;
|
||||
private BigDecimal netPayable;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private SettlementStatus status = SettlementStatus.DRAFT;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime generatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (generatedAt == null) generatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.mvrent.carrental.repository;
|
||||
|
||||
import com.mvrent.carrental.model.AuditLog;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
||||
|
||||
List<AuditLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.mvrent.carrental.repository;
|
||||
|
||||
import com.mvrent.carrental.model.Booking;
|
||||
import com.mvrent.carrental.model.BookingStatus;
|
||||
import com.mvrent.carrental.model.CarOwnership;
|
||||
import com.mvrent.carrental.model.Driver;
|
||||
import com.mvrent.carrental.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public interface BookingRepository extends JpaRepository<Booking, Long>,
|
||||
JpaSpecificationExecutor<Booking> {
|
||||
|
||||
List<Booking> findByUserOrderByCreatedAtDesc(User user);
|
||||
|
||||
List<Booking> findByDriverOrderByCreatedAtDesc(Driver driver);
|
||||
|
||||
long countByDriver(Driver driver);
|
||||
|
||||
List<Booking> findByCarVendorIdOrderByCreatedAtDesc(Long vendorId);
|
||||
|
||||
long countByCarVendorId(Long vendorId);
|
||||
|
||||
List<Booking> findByCarVendorIdAndStatusInAndPickupDateBetweenOrderByPickupDate(
|
||||
Long vendorId, List<BookingStatus> statuses, LocalDate start, LocalDate end);
|
||||
|
||||
List<Booking> findByRevenueOwnerAndStatusIn(CarOwnership owner, List<BookingStatus> statuses);
|
||||
|
||||
List<Booking> findAllByOrderByCreatedAtDesc();
|
||||
|
||||
boolean existsByCarIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual(
|
||||
Long carId,
|
||||
List<BookingStatus> statuses,
|
||||
LocalDate returnDate,
|
||||
LocalDate pickupDate
|
||||
);
|
||||
|
||||
boolean existsByDriverIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual(
|
||||
Long driverId,
|
||||
List<BookingStatus> statuses,
|
||||
LocalDate returnDate,
|
||||
LocalDate pickupDate
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.mvrent.carrental.repository;
|
||||
|
||||
import com.mvrent.carrental.model.CarDocument;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public interface CarDocumentRepository extends JpaRepository<CarDocument, Long> {
|
||||
|
||||
/** Documents already expired or expiring on/before the given cut-off date. */
|
||||
@Query("""
|
||||
SELECT d FROM CarDocument d
|
||||
WHERE d.expiryDate IS NOT NULL AND d.expiryDate <= :cutoff
|
||||
ORDER BY d.expiryDate ASC
|
||||
""")
|
||||
List<CarDocument> findExpiringOrExpired(@Param("cutoff") LocalDate cutoff);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.mvrent.carrental.repository;
|
||||
|
||||
import com.mvrent.carrental.model.CarPhoto;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface CarPhotoRepository extends JpaRepository<CarPhoto, Long> {
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.mvrent.carrental.repository;
|
||||
|
||||
import com.mvrent.carrental.model.Car;
|
||||
import com.mvrent.carrental.model.CarStatus;
|
||||
import com.mvrent.carrental.model.CarType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public interface CarRepository extends JpaRepository<Car, Long> {
|
||||
|
||||
List<Car> findByAvailableTrueAndStatus(CarStatus status);
|
||||
|
||||
List<Car> findByType(CarType type);
|
||||
|
||||
List<Car> findByVendorIdOrderByMake(Long vendorId);
|
||||
|
||||
long countByVendorId(Long vendorId);
|
||||
|
||||
@Query("""
|
||||
SELECT c FROM Car c
|
||||
WHERE c.available = true
|
||||
AND c.status = com.mvrent.carrental.model.CarStatus.ACTIVE
|
||||
AND c.id NOT IN (
|
||||
SELECT b.car.id FROM Booking b
|
||||
WHERE b.status IN (com.mvrent.carrental.model.BookingStatus.PENDING,
|
||||
com.mvrent.carrental.model.BookingStatus.CONFIRMED)
|
||||
AND b.pickupDate <= :returnDate
|
||||
AND b.returnDate >= :pickupDate
|
||||
)
|
||||
""")
|
||||
List<Car> findAvailableBetween(@Param("pickupDate") LocalDate pickupDate,
|
||||
@Param("returnDate") LocalDate returnDate);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user