mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
48
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal file
48
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal file
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal 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 {}
|
||||
68
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal file
68
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
87
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal file
87
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal 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>);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user