feat: add fuel management system backend

- FuelPurchase entity: record fuel purchases with cost tracking
- FuelConsumption entity: monthly aggregation of fuel metrics
- FuelService: record purchases, calculate stats, efficiency
- FuelController: REST API for fuel operations
- FuelModule: integrated into app
- Migration: create fuel_purchases and fuel_consumption tables

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
natib21
2026-06-30 10:07:13 +00:00
parent 360e61ac15
commit df6e417a56
9 changed files with 425 additions and 0 deletions

View File

@@ -69,6 +69,7 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { FuelModule } from './modules/fuel/fuel.module';
import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module';
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
@@ -133,6 +134,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
OverviewModule,
VehiclesModule,
DriversModule,
FuelModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,

View File

@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateFuelTables1840000000000 implements MigrationInterface {
name = "CreateFuelTables1840000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const fuelPurchasesExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`,
);
if (!fuelPurchasesExists.length) {
await queryRunner.query(`
CREATE TABLE freight.fuel_purchases (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
purchase_date timestamptz NOT NULL,
liters numeric(10, 2) NOT NULL,
cost_per_liter numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
fuel_station varchar(255) NULL,
payment_method varchar(50) DEFAULT 'CASH',
odometer_reading numeric(10, 2) NULL,
driver_id uuid NULL,
receipt_number varchar(255) NULL,
notes text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`,
);
}
const fuelConsumptionExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`,
);
if (!fuelConsumptionExists.length) {
await queryRunner.query(`
CREATE TABLE freight.fuel_consumption (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
month date NOT NULL,
total_liters numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
total_distance_km numeric(10, 2) NOT NULL,
fuel_efficiency_km_per_l numeric(10, 2) NULL,
number_of_purchases integer DEFAULT 0,
average_cost_per_liter numeric(10, 2) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
);
`);
await queryRunner.query(
`CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`);
}
}

View File

@@ -0,0 +1,40 @@
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
import { PaymentMethod } from '../entities/fuel-purchase.entity';
export class CreateFuelPurchaseDto {
@IsUUID()
vehicleId!: string;
@IsDateString()
purchaseDate!: string;
@IsNumber()
liters!: number;
@IsNumber()
costPerLiter!: number;
@IsOptional()
@IsString()
fuelStation?: string;
@IsEnum(PaymentMethod)
@IsOptional()
paymentMethod?: PaymentMethod;
@IsOptional()
@IsNumber()
odometerReading?: number;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsString()
receiptNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,35 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'fuel_consumption', schema: 'freight' })
@Index(['vehicleId', 'month'])
export class FuelConsumption extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'month', type: 'date' })
month!: Date;
@Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 })
totalLiters!: number;
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
totalCost!: number;
@Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2 })
totalDistanceKm!: number;
@Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true })
fuelEfficiencyKmPerL?: number;
@Column({ name: 'number_of_purchases', type: 'integer', default: 0 })
numberOfPurchases!: number;
@Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true })
averageCostPerLiter?: number;
}

View File

@@ -0,0 +1,51 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum PaymentMethod {
CASH = 'CASH',
CARD = 'CARD',
FUEL_CARD = 'FUEL_CARD',
TRANSFER = 'TRANSFER',
CHEQUE = 'CHEQUE',
}
@Entity({ name: 'fuel_purchases', schema: 'freight' })
export class FuelPurchase extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'purchase_date', type: 'timestamptz' })
purchaseDate!: Date;
@Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 })
liters!: number;
@Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 })
costPerLiter!: number;
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
totalCost!: number;
@Column({ name: 'fuel_station', nullable: true })
fuelStation?: string;
@Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH })
paymentMethod!: PaymentMethod;
@Column({ name: 'odometer_reading', type: 'numeric', nullable: true })
odometerReading?: number;
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string;
@Column({ name: 'receipt_number', nullable: true })
receiptNumber?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,48 @@
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { FuelService } from './fuel.service';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
@ApiTags('Fuel Management')
@Controller('fuel')
export class FuelController {
constructor(private readonly fuelService: FuelService) {}
@Post('purchases')
@ApiOperation({ summary: 'Record fuel purchase' })
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
return this.fuelService.recordFuelPurchase(dto);
}
@Get('purchases/:vehicleId')
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
async getFuelPurchases(
@Param('vehicleId') vehicleId: string,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.fuelService.getFuelPurchases(
vehicleId,
new Date(startDate),
new Date(endDate),
);
}
@Get('consumption/:vehicleId/:month')
@ApiOperation({ summary: 'Get monthly fuel consumption' })
async getMonthlyConsumption(
@Param('vehicleId') vehicleId: string,
@Param('month') month: string,
) {
return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month));
}
@Get('stats/:vehicleId')
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
async getVehicleFuelStats(
@Param('vehicleId') vehicleId: string,
@Query('months') months: number = 12,
) {
return this.fuelService.getVehicleFuelStats(vehicleId, months);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FuelController } from './fuel.controller';
import { FuelService } from './fuel.service';
import { FuelRepository } from './fuel.repository';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
@Module({
imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])],
controllers: [FuelController],
providers: [FuelService, FuelRepository],
exports: [FuelService],
})
export class FuelModule {}

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { BaseRepository } from '@edr/api-common';
import { DataSource } from 'typeorm';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
@Injectable()
export class FuelRepository extends BaseRepository<FuelPurchase> {
constructor(dataSource: DataSource) {
super(FuelPurchase, dataSource.createEntityManager());
}
async findByVehicleAndDateRange(
vehicleId: string,
startDate: Date,
endDate: Date,
): Promise<FuelPurchase[]> {
return this.find({
where: {
vehicleId,
purchaseDate: {
$gte: startDate,
$lte: endDate,
},
},
order: { purchaseDate: 'DESC' },
});
}
async getMonthlyConsumption(
vehicleId: string,
month: Date,
): Promise<FuelConsumption | null> {
const consumptionRepository = this.manager.getRepository(FuelConsumption);
return consumptionRepository.findOne({
where: {
vehicleId,
month,
},
});
}
async updateMonthlyConsumption(
vehicleId: string,
month: Date,
data: Partial<FuelConsumption>,
): Promise<FuelConsumption> {
const consumptionRepository = this.manager.getRepository(FuelConsumption);
let consumption = await consumptionRepository.findOne({
where: {
vehicleId,
month,
},
});
if (!consumption) {
consumption = consumptionRepository.create({
vehicleId,
month,
...data,
});
} else {
Object.assign(consumption, data);
}
return consumptionRepository.save(consumption);
}
}

View File

@@ -0,0 +1,87 @@
import { Injectable } from '@nestjs/common';
import { FuelRepository } from './fuel.repository';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
@Injectable()
export class FuelService {
constructor(private readonly fuelRepository: FuelRepository) {}
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
const totalCost = dto.liters * dto.costPerLiter;
const purchase = this.fuelRepository.create({
...dto,
totalCost,
});
const saved = await this.fuelRepository.save(purchase);
// Update monthly consumption
await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate));
return saved;
}
async getFuelPurchases(
vehicleId: string,
startDate: Date,
endDate: Date,
): Promise<FuelPurchase[]> {
return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate);
}
async getMonthlyConsumption(
vehicleId: string,
month: Date,
): Promise<FuelConsumption | null> {
return this.fuelRepository.getMonthlyConsumption(vehicleId, month);
}
async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate);
const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0);
const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0;
return {
vehicleId,
totalPurchases: purchases.length,
totalLiters,
totalCost,
averagePricePerLiter: averagePrice,
dateRange: { startDate, endDate },
};
}
private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise<void> {
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
const purchases = await this.fuelRepository.find({
where: {
vehicleId,
purchaseDate: {
$gte: monthStart,
$lt: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1),
},
},
});
const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0);
const numberOfPurchases = purchases.length;
const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0;
await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, {
totalLiters,
totalCost,
numberOfPurchases,
averageCostPerLiter,
} as Partial<FuelConsumption>);
}
}