From 16e5713c60f28ad2309efe741f95ac62859b24d0 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 03:42:34 -0600 Subject: [PATCH] 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 --- src/app.module.ts | 2 + src/modules/delivery/delivery.controller.ts | 135 ++++++ src/modules/delivery/delivery.module.ts | 14 + src/modules/delivery/delivery.service.ts | 456 ++++++++++++++++++ src/modules/delivery/dto/delivery.dto.ts | 234 +++++++++ src/modules/delivery/dto/index.ts | 1 + .../delivery/entities/delivery-zone.entity.ts | 61 +++ .../delivery/entities/delivery.entity.ts | 111 +++++ src/modules/delivery/entities/index.ts | 2 + 9 files changed, 1016 insertions(+) create mode 100644 src/modules/delivery/delivery.controller.ts create mode 100644 src/modules/delivery/delivery.module.ts create mode 100644 src/modules/delivery/delivery.service.ts create mode 100644 src/modules/delivery/dto/delivery.dto.ts create mode 100644 src/modules/delivery/dto/index.ts create mode 100644 src/modules/delivery/entities/delivery-zone.entity.ts create mode 100644 src/modules/delivery/entities/delivery.entity.ts create mode 100644 src/modules/delivery/entities/index.ts diff --git a/src/app.module.ts b/src/app.module.ts index f42c8ac..dc3a2c6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/modules/delivery/delivery.controller.ts b/src/modules/delivery/delivery.controller.ts new file mode 100644 index 0000000..7465e22 --- /dev/null +++ b/src/modules/delivery/delivery.controller.ts @@ -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); + } +} diff --git a/src/modules/delivery/delivery.module.ts b/src/modules/delivery/delivery.module.ts new file mode 100644 index 0000000..c11248c --- /dev/null +++ b/src/modules/delivery/delivery.module.ts @@ -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 {} diff --git a/src/modules/delivery/delivery.service.ts b/src/modules/delivery/delivery.service.ts new file mode 100644 index 0000000..4936eee --- /dev/null +++ b/src/modules/delivery/delivery.service.ts @@ -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, + @InjectRepository(DeliveryZone) + private readonly zoneRepository: Repository, + ) {} + + // ============================================ + // Delivery Zone Management + // ============================================ + + async createZone(tenantId: string, dto: CreateDeliveryZoneDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + return this.deliveryRepository.findOne({ + where: { orderId, tenantId }, + relations: ['order'], + }); + } + + async assignDelivery(tenantId: string, deliveryId: string, dto: AssignDeliveryDto): Promise { + 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 { + 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.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 { + 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 { + 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 { + 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 { + return this.deliveryRepository.find({ + where: { tenantId, status: DeliveryStatus.PENDING }, + order: { createdAt: 'ASC' }, + relations: ['order'], + }); + } +} diff --git a/src/modules/delivery/dto/delivery.dto.ts b/src/modules/delivery/dto/delivery.dto.ts new file mode 100644 index 0000000..e7ea6db --- /dev/null +++ b/src/modules/delivery/dto/delivery.dto.ts @@ -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; +} diff --git a/src/modules/delivery/dto/index.ts b/src/modules/delivery/dto/index.ts new file mode 100644 index 0000000..c913653 --- /dev/null +++ b/src/modules/delivery/dto/index.ts @@ -0,0 +1 @@ +export * from './delivery.dto'; diff --git a/src/modules/delivery/entities/delivery-zone.entity.ts b/src/modules/delivery/entities/delivery-zone.entity.ts new file mode 100644 index 0000000..f8ae0c2 --- /dev/null +++ b/src/modules/delivery/entities/delivery-zone.entity.ts @@ -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; +} diff --git a/src/modules/delivery/entities/delivery.entity.ts b/src/modules/delivery/entities/delivery.entity.ts new file mode 100644 index 0000000..3eb08ad --- /dev/null +++ b/src/modules/delivery/entities/delivery.entity.ts @@ -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; +} diff --git a/src/modules/delivery/entities/index.ts b/src/modules/delivery/entities/index.ts new file mode 100644 index 0000000..853e1f4 --- /dev/null +++ b/src/modules/delivery/entities/index.ts @@ -0,0 +1,2 @@ +export * from './delivery-zone.entity'; +export * from './delivery.entity';