feat(MCH-016): Implement delivery module for home delivery management
- Add DeliveryZone entity with radius/polygon zone types - Add Delivery entity with status tracking and proof of delivery - Implement DeliveryService with zone management and coverage check - Add Haversine distance calculation for radius zones - Add ray casting algorithm for polygon zone containment - Create DeliveryController with REST endpoints - Register DeliveryModule in app.module.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
75e881e1cc
commit
16e5713c60
@ -19,6 +19,7 @@ import { WidgetsModule } from './modules/widgets/widgets.module';
|
||||
import { InvoicesModule } from './modules/invoices/invoices.module';
|
||||
import { MarketplaceModule } from './modules/marketplace/marketplace.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { DeliveryModule } from './modules/delivery/delivery.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -66,6 +67,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
||||
InvoicesModule,
|
||||
MarketplaceModule,
|
||||
NotificationsModule,
|
||||
DeliveryModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
135
src/modules/delivery/delivery.controller.ts
Normal file
135
src/modules/delivery/delivery.controller.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { DeliveryService } from './delivery.service';
|
||||
import { DeliveryStatus } from './entities/delivery.entity';
|
||||
import {
|
||||
CreateDeliveryDto,
|
||||
CreateDeliveryZoneDto,
|
||||
UpdateDeliveryZoneDto,
|
||||
AssignDeliveryDto,
|
||||
UpdateDeliveryStatusDto,
|
||||
ProofOfDeliveryDto,
|
||||
CheckCoverageDto,
|
||||
CheckCoverageByAddressDto,
|
||||
} from './dto/delivery.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('delivery')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DeliveryController {
|
||||
constructor(private readonly deliveryService: DeliveryService) {}
|
||||
|
||||
// ============================================
|
||||
// Zone Endpoints
|
||||
// ============================================
|
||||
|
||||
@Post('zones')
|
||||
async createZone(@Request() req: any, @Body() dto: CreateDeliveryZoneDto) {
|
||||
return this.deliveryService.createZone(req.tenantId, dto);
|
||||
}
|
||||
|
||||
@Get('zones')
|
||||
async getZones(@Request() req: any, @Query('active') active?: string) {
|
||||
const activeOnly = active === 'true';
|
||||
return this.deliveryService.getZones(req.tenantId, activeOnly);
|
||||
}
|
||||
|
||||
@Get('zones/:id')
|
||||
async getZone(@Request() req: any, @Param('id') id: string) {
|
||||
return this.deliveryService.getZoneById(req.tenantId, id);
|
||||
}
|
||||
|
||||
@Put('zones/:id')
|
||||
async updateZone(@Request() req: any, @Param('id') id: string, @Body() dto: UpdateDeliveryZoneDto) {
|
||||
return this.deliveryService.updateZone(req.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('zones/:id')
|
||||
async deleteZone(@Request() req: any, @Param('id') id: string) {
|
||||
await this.deliveryService.deleteZone(req.tenantId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Coverage Check Endpoints
|
||||
// ============================================
|
||||
|
||||
@Post('check-coverage')
|
||||
async checkCoverage(@Request() req: any, @Body() dto: CheckCoverageDto) {
|
||||
return this.deliveryService.checkCoverage(req.tenantId, dto);
|
||||
}
|
||||
|
||||
@Post('check-coverage/address')
|
||||
async checkCoverageByAddress(@Request() req: any, @Body() dto: CheckCoverageByAddressDto) {
|
||||
return this.deliveryService.checkCoverageByAddress(req.tenantId, dto);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Delivery Endpoints
|
||||
// ============================================
|
||||
|
||||
@Post()
|
||||
async createDelivery(@Request() req: any, @Body() dto: CreateDeliveryDto) {
|
||||
return this.deliveryService.createDelivery(req.tenantId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getDeliveries(
|
||||
@Request() req: any,
|
||||
@Query('status') status?: DeliveryStatus,
|
||||
@Query('assignedTo') assignedTo?: string,
|
||||
) {
|
||||
return this.deliveryService.getDeliveries(req.tenantId, { status, assignedTo });
|
||||
}
|
||||
|
||||
@Get('pending')
|
||||
async getPendingDeliveries(@Request() req: any) {
|
||||
return this.deliveryService.getPendingDeliveries(req.tenantId);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats(@Request() req: any, @Query('date') date?: string) {
|
||||
const dateObj = date ? new Date(date) : undefined;
|
||||
return this.deliveryService.getStats(req.tenantId, dateObj);
|
||||
}
|
||||
|
||||
@Get('driver/:driverId')
|
||||
async getDriverDeliveries(@Request() req: any, @Param('driverId') driverId: string) {
|
||||
return this.deliveryService.getDriverDeliveries(req.tenantId, driverId);
|
||||
}
|
||||
|
||||
@Get('order/:orderId')
|
||||
async getDeliveryByOrder(@Request() req: any, @Param('orderId') orderId: string) {
|
||||
return this.deliveryService.getDeliveryByOrderId(req.tenantId, orderId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getDelivery(@Request() req: any, @Param('id') id: string) {
|
||||
return this.deliveryService.getDeliveryById(req.tenantId, id);
|
||||
}
|
||||
|
||||
@Put(':id/assign')
|
||||
async assignDelivery(@Request() req: any, @Param('id') id: string, @Body() dto: AssignDeliveryDto) {
|
||||
return this.deliveryService.assignDelivery(req.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
async updateStatus(@Request() req: any, @Param('id') id: string, @Body() dto: UpdateDeliveryStatusDto) {
|
||||
return this.deliveryService.updateStatus(req.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/proof')
|
||||
async addProofOfDelivery(@Request() req: any, @Param('id') id: string, @Body() dto: ProofOfDeliveryDto) {
|
||||
return this.deliveryService.addProofOfDelivery(req.tenantId, id, dto);
|
||||
}
|
||||
}
|
||||
14
src/modules/delivery/delivery.module.ts
Normal file
14
src/modules/delivery/delivery.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DeliveryController } from './delivery.controller';
|
||||
import { DeliveryService } from './delivery.service';
|
||||
import { Delivery } from './entities/delivery.entity';
|
||||
import { DeliveryZone } from './entities/delivery-zone.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Delivery, DeliveryZone])],
|
||||
controllers: [DeliveryController],
|
||||
providers: [DeliveryService],
|
||||
exports: [DeliveryService],
|
||||
})
|
||||
export class DeliveryModule {}
|
||||
456
src/modules/delivery/delivery.service.ts
Normal file
456
src/modules/delivery/delivery.service.ts
Normal file
@ -0,0 +1,456 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { Delivery, DeliveryStatus } from './entities/delivery.entity';
|
||||
import { DeliveryZone, DeliveryZoneType } from './entities/delivery-zone.entity';
|
||||
import {
|
||||
CreateDeliveryDto,
|
||||
CreateDeliveryZoneDto,
|
||||
UpdateDeliveryZoneDto,
|
||||
AssignDeliveryDto,
|
||||
UpdateDeliveryStatusDto,
|
||||
ProofOfDeliveryDto,
|
||||
CheckCoverageDto,
|
||||
CheckCoverageByAddressDto,
|
||||
CoverageResultDto,
|
||||
DeliveryStatsDto,
|
||||
CoordinatesDto,
|
||||
} from './dto/delivery.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DeliveryService {
|
||||
constructor(
|
||||
@InjectRepository(Delivery)
|
||||
private readonly deliveryRepository: Repository<Delivery>,
|
||||
@InjectRepository(DeliveryZone)
|
||||
private readonly zoneRepository: Repository<DeliveryZone>,
|
||||
) {}
|
||||
|
||||
// ============================================
|
||||
// Delivery Zone Management
|
||||
// ============================================
|
||||
|
||||
async createZone(tenantId: string, dto: CreateDeliveryZoneDto): Promise<DeliveryZone> {
|
||||
const zone = this.zoneRepository.create({
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
coordinates: dto.coordinates,
|
||||
deliveryFee: dto.deliveryFee,
|
||||
minOrder: dto.minOrder || 0,
|
||||
estimatedTimeMinutes: dto.estimatedTimeMinutes || 30,
|
||||
priority: dto.priority || 0,
|
||||
active: dto.active !== false,
|
||||
});
|
||||
|
||||
return this.zoneRepository.save(zone);
|
||||
}
|
||||
|
||||
async updateZone(tenantId: string, zoneId: string, dto: UpdateDeliveryZoneDto): Promise<DeliveryZone> {
|
||||
const zone = await this.zoneRepository.findOne({
|
||||
where: { id: zoneId, tenantId },
|
||||
});
|
||||
|
||||
if (!zone) {
|
||||
throw new NotFoundException('Zona de entrega no encontrada');
|
||||
}
|
||||
|
||||
Object.assign(zone, dto);
|
||||
return this.zoneRepository.save(zone);
|
||||
}
|
||||
|
||||
async deleteZone(tenantId: string, zoneId: string): Promise<void> {
|
||||
const result = await this.zoneRepository.delete({ id: zoneId, tenantId });
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException('Zona de entrega no encontrada');
|
||||
}
|
||||
}
|
||||
|
||||
async getZones(tenantId: string, activeOnly = false): Promise<DeliveryZone[]> {
|
||||
const where: any = { tenantId };
|
||||
if (activeOnly) {
|
||||
where.active = true;
|
||||
}
|
||||
return this.zoneRepository.find({
|
||||
where,
|
||||
order: { priority: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getZoneById(tenantId: string, zoneId: string): Promise<DeliveryZone> {
|
||||
const zone = await this.zoneRepository.findOne({
|
||||
where: { id: zoneId, tenantId },
|
||||
});
|
||||
|
||||
if (!zone) {
|
||||
throw new NotFoundException('Zona de entrega no encontrada');
|
||||
}
|
||||
|
||||
return zone;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Coverage Check
|
||||
// ============================================
|
||||
|
||||
async checkCoverage(tenantId: string, dto: CheckCoverageDto): Promise<CoverageResultDto> {
|
||||
const zones = await this.zoneRepository.find({
|
||||
where: { tenantId, active: true },
|
||||
order: { priority: 'ASC' },
|
||||
});
|
||||
|
||||
for (const zone of zones) {
|
||||
if (this.isPointInZone(dto.coordinates, zone)) {
|
||||
return {
|
||||
covered: true,
|
||||
zone: {
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
deliveryFee: Number(zone.deliveryFee),
|
||||
estimatedTimeMinutes: zone.estimatedTimeMinutes,
|
||||
minOrder: Number(zone.minOrder),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
covered: false,
|
||||
message: 'La dirección está fuera de nuestra zona de cobertura',
|
||||
};
|
||||
}
|
||||
|
||||
async checkCoverageByAddress(tenantId: string, dto: CheckCoverageByAddressDto): Promise<CoverageResultDto> {
|
||||
// If colonia is provided, check against colonia-based zones
|
||||
if (dto.colonia) {
|
||||
const zones = await this.zoneRepository.find({
|
||||
where: { tenantId, active: true },
|
||||
order: { priority: 'ASC' },
|
||||
});
|
||||
|
||||
for (const zone of zones) {
|
||||
if (zone.coordinates?.colonias?.includes(dto.colonia.toLowerCase())) {
|
||||
return {
|
||||
covered: true,
|
||||
zone: {
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
deliveryFee: Number(zone.deliveryFee),
|
||||
estimatedTimeMinutes: zone.estimatedTimeMinutes,
|
||||
minOrder: Number(zone.minOrder),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For geocoding-based check, would need external service
|
||||
// For now, return not covered if no colonia match
|
||||
return {
|
||||
covered: false,
|
||||
message: 'No pudimos verificar la cobertura. Por favor proporciona tu colonia.',
|
||||
};
|
||||
}
|
||||
|
||||
private isPointInZone(point: CoordinatesDto, zone: DeliveryZone): boolean {
|
||||
if (!zone.coordinates) return false;
|
||||
|
||||
if (zone.type === DeliveryZoneType.RADIUS) {
|
||||
if (!zone.coordinates.center || !zone.coordinates.radiusKm) return false;
|
||||
const distance = this.calculateDistance(
|
||||
point.lat,
|
||||
point.lng,
|
||||
zone.coordinates.center.lat,
|
||||
zone.coordinates.center.lng,
|
||||
);
|
||||
return distance <= zone.coordinates.radiusKm;
|
||||
}
|
||||
|
||||
if (zone.type === DeliveryZoneType.POLYGON) {
|
||||
if (!zone.coordinates.polygon || zone.coordinates.polygon.length < 3) return false;
|
||||
return this.isPointInPolygon(point, zone.coordinates.polygon);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
// Haversine formula
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = this.toRad(lat2 - lat1);
|
||||
const dLng = this.toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
private toRad(deg: number): number {
|
||||
return deg * (Math.PI / 180);
|
||||
}
|
||||
|
||||
private isPointInPolygon(point: CoordinatesDto, polygon: CoordinatesDto[]): boolean {
|
||||
// Ray casting algorithm
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].lng;
|
||||
const yi = polygon[i].lat;
|
||||
const xj = polygon[j].lng;
|
||||
const yj = polygon[j].lat;
|
||||
|
||||
const intersect =
|
||||
yi > point.lat !== yj > point.lat && point.lng < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi;
|
||||
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Delivery Management
|
||||
// ============================================
|
||||
|
||||
async createDelivery(tenantId: string, dto: CreateDeliveryDto): Promise<Delivery> {
|
||||
// Calculate fee from zone if not provided
|
||||
let deliveryFee = dto.deliveryFee;
|
||||
let estimatedMinutes = dto.estimatedMinutes;
|
||||
let zoneId = dto.zoneId;
|
||||
|
||||
if (dto.deliveryCoordinates && !zoneId) {
|
||||
const coverage = await this.checkCoverage(tenantId, { coordinates: dto.deliveryCoordinates });
|
||||
if (coverage.covered && coverage.zone) {
|
||||
zoneId = coverage.zone.id;
|
||||
if (deliveryFee === undefined) {
|
||||
deliveryFee = coverage.zone.deliveryFee;
|
||||
}
|
||||
if (!estimatedMinutes) {
|
||||
estimatedMinutes = coverage.zone.estimatedTimeMinutes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const delivery = this.deliveryRepository.create({
|
||||
tenantId,
|
||||
orderId: dto.orderId,
|
||||
zoneId,
|
||||
deliveryAddress: dto.deliveryAddress,
|
||||
deliveryCoordinates: dto.deliveryCoordinates,
|
||||
deliveryNotes: dto.deliveryNotes,
|
||||
customerPhone: dto.customerPhone,
|
||||
deliveryFee: deliveryFee || 0,
|
||||
estimatedMinutes: estimatedMinutes || 30,
|
||||
status: DeliveryStatus.PENDING,
|
||||
});
|
||||
|
||||
return this.deliveryRepository.save(delivery);
|
||||
}
|
||||
|
||||
async getDeliveries(
|
||||
tenantId: string,
|
||||
options?: {
|
||||
status?: DeliveryStatus | DeliveryStatus[];
|
||||
assignedTo?: string;
|
||||
date?: Date;
|
||||
},
|
||||
): Promise<Delivery[]> {
|
||||
const where: any = { tenantId };
|
||||
|
||||
if (options?.status) {
|
||||
where.status = Array.isArray(options.status) ? In(options.status) : options.status;
|
||||
}
|
||||
|
||||
if (options?.assignedTo) {
|
||||
where.assignedTo = options.assignedTo;
|
||||
}
|
||||
|
||||
return this.deliveryRepository.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: ['order'],
|
||||
});
|
||||
}
|
||||
|
||||
async getDeliveryById(tenantId: string, deliveryId: string): Promise<Delivery> {
|
||||
const delivery = await this.deliveryRepository.findOne({
|
||||
where: { id: deliveryId, tenantId },
|
||||
relations: ['order'],
|
||||
});
|
||||
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Entrega no encontrada');
|
||||
}
|
||||
|
||||
return delivery;
|
||||
}
|
||||
|
||||
async getDeliveryByOrderId(tenantId: string, orderId: string): Promise<Delivery | null> {
|
||||
return this.deliveryRepository.findOne({
|
||||
where: { orderId, tenantId },
|
||||
relations: ['order'],
|
||||
});
|
||||
}
|
||||
|
||||
async assignDelivery(tenantId: string, deliveryId: string, dto: AssignDeliveryDto): Promise<Delivery> {
|
||||
const delivery = await this.getDeliveryById(tenantId, deliveryId);
|
||||
|
||||
if (delivery.status !== DeliveryStatus.PENDING) {
|
||||
throw new BadRequestException('Solo se pueden asignar entregas pendientes');
|
||||
}
|
||||
|
||||
delivery.assignedTo = dto.assignedTo;
|
||||
delivery.assignedToName = dto.assignedToName;
|
||||
delivery.status = DeliveryStatus.ASSIGNED;
|
||||
delivery.assignedAt = new Date();
|
||||
|
||||
return this.deliveryRepository.save(delivery);
|
||||
}
|
||||
|
||||
async updateStatus(tenantId: string, deliveryId: string, dto: UpdateDeliveryStatusDto): Promise<Delivery> {
|
||||
const delivery = await this.getDeliveryById(tenantId, deliveryId);
|
||||
|
||||
// Validate status transitions
|
||||
this.validateStatusTransition(delivery.status, dto.status);
|
||||
|
||||
delivery.status = dto.status;
|
||||
|
||||
// Set timestamps based on status
|
||||
const now = new Date();
|
||||
switch (dto.status) {
|
||||
case DeliveryStatus.PICKED_UP:
|
||||
delivery.pickedUpAt = now;
|
||||
break;
|
||||
case DeliveryStatus.IN_TRANSIT:
|
||||
delivery.inTransitAt = now;
|
||||
break;
|
||||
case DeliveryStatus.ARRIVED:
|
||||
delivery.arrivedAt = now;
|
||||
break;
|
||||
case DeliveryStatus.DELIVERED:
|
||||
delivery.deliveredAt = now;
|
||||
break;
|
||||
case DeliveryStatus.FAILED:
|
||||
delivery.failureReason = dto.failureReason;
|
||||
delivery.retryCount += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.deliveryRepository.save(delivery);
|
||||
}
|
||||
|
||||
private validateStatusTransition(current: DeliveryStatus, next: DeliveryStatus): void {
|
||||
const validTransitions: Record<DeliveryStatus, DeliveryStatus[]> = {
|
||||
[DeliveryStatus.PENDING]: [DeliveryStatus.ASSIGNED, DeliveryStatus.CANCELLED],
|
||||
[DeliveryStatus.ASSIGNED]: [DeliveryStatus.PICKED_UP, DeliveryStatus.CANCELLED],
|
||||
[DeliveryStatus.PICKED_UP]: [DeliveryStatus.IN_TRANSIT, DeliveryStatus.FAILED],
|
||||
[DeliveryStatus.IN_TRANSIT]: [DeliveryStatus.ARRIVED, DeliveryStatus.FAILED],
|
||||
[DeliveryStatus.ARRIVED]: [DeliveryStatus.DELIVERED, DeliveryStatus.FAILED],
|
||||
[DeliveryStatus.DELIVERED]: [],
|
||||
[DeliveryStatus.FAILED]: [DeliveryStatus.ASSIGNED],
|
||||
[DeliveryStatus.CANCELLED]: [],
|
||||
};
|
||||
|
||||
if (!validTransitions[current].includes(next)) {
|
||||
throw new BadRequestException(`Transición de estado no válida: ${current} -> ${next}`);
|
||||
}
|
||||
}
|
||||
|
||||
async addProofOfDelivery(tenantId: string, deliveryId: string, dto: ProofOfDeliveryDto): Promise<Delivery> {
|
||||
const delivery = await this.getDeliveryById(tenantId, deliveryId);
|
||||
|
||||
if (dto.proofPhotoUrl) {
|
||||
delivery.proofPhotoUrl = dto.proofPhotoUrl;
|
||||
}
|
||||
if (dto.signatureUrl) {
|
||||
delivery.signatureUrl = dto.signatureUrl;
|
||||
}
|
||||
if (dto.recipientName) {
|
||||
delivery.recipientName = dto.recipientName;
|
||||
}
|
||||
|
||||
return this.deliveryRepository.save(delivery);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Statistics
|
||||
// ============================================
|
||||
|
||||
async getStats(tenantId: string, date?: Date): Promise<DeliveryStatsDto> {
|
||||
const queryBuilder = this.deliveryRepository.createQueryBuilder('d').where('d.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (date) {
|
||||
queryBuilder.andWhere('DATE(d.created_at) = DATE(:date)', { date });
|
||||
}
|
||||
|
||||
const deliveries = await queryBuilder.getMany();
|
||||
|
||||
const stats: DeliveryStatsDto = {
|
||||
pending: 0,
|
||||
assigned: 0,
|
||||
inTransit: 0,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
averageDeliveryTime: 0,
|
||||
};
|
||||
|
||||
let totalDeliveryTime = 0;
|
||||
let deliveredCount = 0;
|
||||
|
||||
for (const delivery of deliveries) {
|
||||
switch (delivery.status) {
|
||||
case DeliveryStatus.PENDING:
|
||||
stats.pending++;
|
||||
break;
|
||||
case DeliveryStatus.ASSIGNED:
|
||||
case DeliveryStatus.PICKED_UP:
|
||||
stats.assigned++;
|
||||
break;
|
||||
case DeliveryStatus.IN_TRANSIT:
|
||||
case DeliveryStatus.ARRIVED:
|
||||
stats.inTransit++;
|
||||
break;
|
||||
case DeliveryStatus.DELIVERED:
|
||||
stats.delivered++;
|
||||
if (delivery.assignedAt && delivery.deliveredAt) {
|
||||
totalDeliveryTime += delivery.deliveredAt.getTime() - delivery.assignedAt.getTime();
|
||||
deliveredCount++;
|
||||
}
|
||||
break;
|
||||
case DeliveryStatus.FAILED:
|
||||
case DeliveryStatus.CANCELLED:
|
||||
stats.failed++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveredCount > 0) {
|
||||
stats.averageDeliveryTime = Math.round(totalDeliveryTime / deliveredCount / 60000); // minutes
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Driver Operations
|
||||
// ============================================
|
||||
|
||||
async getDriverDeliveries(tenantId: string, driverId: string): Promise<Delivery[]> {
|
||||
return this.deliveryRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
assignedTo: driverId,
|
||||
status: In([DeliveryStatus.ASSIGNED, DeliveryStatus.PICKED_UP, DeliveryStatus.IN_TRANSIT, DeliveryStatus.ARRIVED]),
|
||||
},
|
||||
order: { assignedAt: 'ASC' },
|
||||
relations: ['order'],
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingDeliveries(tenantId: string): Promise<Delivery[]> {
|
||||
return this.deliveryRepository.find({
|
||||
where: { tenantId, status: DeliveryStatus.PENDING },
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: ['order'],
|
||||
});
|
||||
}
|
||||
}
|
||||
234
src/modules/delivery/dto/delivery.dto.ts
Normal file
234
src/modules/delivery/dto/delivery.dto.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { IsString, IsOptional, IsNumber, IsEnum, IsArray, IsBoolean, ValidateNested, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { DeliveryStatus } from '../entities/delivery.entity';
|
||||
import { DeliveryZoneType } from '../entities/delivery-zone.entity';
|
||||
|
||||
// ============================================
|
||||
// Coordinate DTOs
|
||||
// ============================================
|
||||
|
||||
export class CoordinatesDto {
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
lat: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
lng: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Delivery Zone DTOs
|
||||
// ============================================
|
||||
|
||||
export class ZoneCoordinatesDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CoordinatesDto)
|
||||
center?: CoordinatesDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.1)
|
||||
@Max(50)
|
||||
radiusKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CoordinatesDto)
|
||||
polygon?: CoordinatesDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
colonias?: string[];
|
||||
}
|
||||
|
||||
export class CreateDeliveryZoneDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsEnum(DeliveryZoneType)
|
||||
type: DeliveryZoneType;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ZoneCoordinatesDto)
|
||||
coordinates: ZoneCoordinatesDto;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
deliveryFee: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
estimatedTimeMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateDeliveryZoneDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(DeliveryZoneType)
|
||||
type?: DeliveryZoneType;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ZoneCoordinatesDto)
|
||||
coordinates?: ZoneCoordinatesDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
deliveryFee?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
estimatedTimeMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Delivery DTOs
|
||||
// ============================================
|
||||
|
||||
export class CreateDeliveryDto {
|
||||
@IsString()
|
||||
orderId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
zoneId?: string;
|
||||
|
||||
@IsString()
|
||||
deliveryAddress: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CoordinatesDto)
|
||||
deliveryCoordinates?: CoordinatesDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deliveryNotes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
deliveryFee?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
estimatedMinutes?: number;
|
||||
}
|
||||
|
||||
export class AssignDeliveryDto {
|
||||
@IsString()
|
||||
assignedTo: string;
|
||||
|
||||
@IsString()
|
||||
assignedToName: string;
|
||||
}
|
||||
|
||||
export class UpdateDeliveryStatusDto {
|
||||
@IsEnum(DeliveryStatus)
|
||||
status: DeliveryStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
failureReason?: string;
|
||||
}
|
||||
|
||||
export class ProofOfDeliveryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
proofPhotoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
signatureUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recipientName?: string;
|
||||
}
|
||||
|
||||
export class CheckCoverageDto {
|
||||
@ValidateNested()
|
||||
@Type(() => CoordinatesDto)
|
||||
coordinates: CoordinatesDto;
|
||||
}
|
||||
|
||||
export class CheckCoverageByAddressDto {
|
||||
@IsString()
|
||||
address: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colonia?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Response DTOs
|
||||
// ============================================
|
||||
|
||||
export class CoverageResultDto {
|
||||
covered: boolean;
|
||||
zone?: {
|
||||
id: string;
|
||||
name: string;
|
||||
deliveryFee: number;
|
||||
estimatedTimeMinutes: number;
|
||||
minOrder: number;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class DeliveryStatsDto {
|
||||
pending: number;
|
||||
assigned: number;
|
||||
inTransit: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
averageDeliveryTime: number;
|
||||
}
|
||||
1
src/modules/delivery/dto/index.ts
Normal file
1
src/modules/delivery/dto/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './delivery.dto';
|
||||
61
src/modules/delivery/entities/delivery-zone.entity.ts
Normal file
61
src/modules/delivery/entities/delivery-zone.entity.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum DeliveryZoneType {
|
||||
RADIUS = 'radius',
|
||||
POLYGON = 'polygon',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'delivery', name: 'delivery_zones' })
|
||||
@Index(['tenantId', 'active'])
|
||||
export class DeliveryZone {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: DeliveryZoneType.RADIUS })
|
||||
type: DeliveryZoneType;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
coordinates: {
|
||||
// For radius type
|
||||
center?: { lat: number; lng: number };
|
||||
radiusKm?: number;
|
||||
// For polygon type
|
||||
polygon?: Array<{ lat: number; lng: number }>;
|
||||
// For simple colonia-based
|
||||
colonias?: string[];
|
||||
};
|
||||
|
||||
@Column({ name: 'delivery_fee', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
deliveryFee: number;
|
||||
|
||||
@Column({ name: 'min_order', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
minOrder: number;
|
||||
|
||||
@Column({ name: 'estimated_time_minutes', type: 'int', default: 30 })
|
||||
estimatedTimeMinutes: number;
|
||||
|
||||
@Column({ default: true })
|
||||
active: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priority: number; // Lower = higher priority for overlapping zones
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
111
src/modules/delivery/entities/delivery.entity.ts
Normal file
111
src/modules/delivery/entities/delivery.entity.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Order } from '../../orders/entities/order.entity';
|
||||
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending', // Waiting for assignment
|
||||
ASSIGNED = 'assigned', // Assigned to driver
|
||||
PICKED_UP = 'picked_up', // Driver picked up order
|
||||
IN_TRANSIT = 'in_transit', // On the way
|
||||
ARRIVED = 'arrived', // Arrived at destination
|
||||
DELIVERED = 'delivered', // Successfully delivered
|
||||
FAILED = 'failed', // Delivery failed
|
||||
CANCELLED = 'cancelled', // Cancelled
|
||||
}
|
||||
|
||||
@Entity({ schema: 'delivery', name: 'deliveries' })
|
||||
@Index(['tenantId', 'status'])
|
||||
@Index(['orderId'])
|
||||
@Index(['assignedTo', 'status'])
|
||||
export class Delivery {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'order_id' })
|
||||
orderId: string;
|
||||
|
||||
@Column({ name: 'zone_id', nullable: true })
|
||||
zoneId: string;
|
||||
|
||||
@Column({ name: 'assigned_to', nullable: true })
|
||||
assignedTo: string; // User ID of driver
|
||||
|
||||
@Column({ name: 'assigned_to_name', length: 100, nullable: true })
|
||||
assignedToName: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: DeliveryStatus.PENDING })
|
||||
status: DeliveryStatus;
|
||||
|
||||
@Column({ name: 'delivery_address', type: 'text' })
|
||||
deliveryAddress: string;
|
||||
|
||||
@Column({ name: 'delivery_coordinates', type: 'jsonb', nullable: true })
|
||||
deliveryCoordinates: { lat: number; lng: number };
|
||||
|
||||
@Column({ name: 'delivery_notes', type: 'text', nullable: true })
|
||||
deliveryNotes: string;
|
||||
|
||||
@Column({ name: 'customer_phone', length: 20, nullable: true })
|
||||
customerPhone: string;
|
||||
|
||||
@Column({ name: 'delivery_fee', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
deliveryFee: number;
|
||||
|
||||
@Column({ name: 'estimated_minutes', type: 'int', nullable: true })
|
||||
estimatedMinutes: number;
|
||||
|
||||
// Timestamps for tracking
|
||||
@Column({ name: 'assigned_at', type: 'timestamptz', nullable: true })
|
||||
assignedAt: Date;
|
||||
|
||||
@Column({ name: 'picked_up_at', type: 'timestamptz', nullable: true })
|
||||
pickedUpAt: Date;
|
||||
|
||||
@Column({ name: 'in_transit_at', type: 'timestamptz', nullable: true })
|
||||
inTransitAt: Date;
|
||||
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt: Date;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt: Date;
|
||||
|
||||
// Proof of delivery
|
||||
@Column({ name: 'proof_photo_url', type: 'text', nullable: true })
|
||||
proofPhotoUrl: string;
|
||||
|
||||
@Column({ name: 'signature_url', type: 'text', nullable: true })
|
||||
signatureUrl: string;
|
||||
|
||||
@Column({ name: 'recipient_name', length: 100, nullable: true })
|
||||
recipientName: string;
|
||||
|
||||
// Failure info
|
||||
@Column({ name: 'failure_reason', type: 'text', nullable: true })
|
||||
failureReason: string;
|
||||
|
||||
@Column({ name: 'retry_count', type: 'int', default: 0 })
|
||||
retryCount: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Order)
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order: Order;
|
||||
}
|
||||
2
src/modules/delivery/entities/index.ts
Normal file
2
src/modules/delivery/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './delivery-zone.entity';
|
||||
export * from './delivery.entity';
|
||||
Loading…
Reference in New Issue
Block a user