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:
rckrdmrd 2026-01-18 03:42:34 -06:00
parent 75e881e1cc
commit 16e5713c60
9 changed files with 1016 additions and 0 deletions

View File

@ -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 {}

View 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);
}
}

View 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 {}

View 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'],
});
}
}

View 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;
}

View File

@ -0,0 +1 @@
export * from './delivery.dto';

View 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;
}

View 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;
}

View File

@ -0,0 +1,2 @@
export * from './delivery-zone.entity';
export * from './delivery.entity';