85 lines
3.0 KiB
TypeScript
85 lines
3.0 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { RedisService } from '../redis/redis.service';
|
|
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
|
|
import { haversineKm } from '../freight/freight-calculator';
|
|
|
|
@Injectable()
|
|
export class GeoService {
|
|
constructor(private readonly redis: RedisService) {}
|
|
|
|
async placeFromZip(zip: string): Promise<ZipPlace> {
|
|
const cep = digitsZip(zip);
|
|
if (!/^\d{8}$/.test(cep)) {
|
|
throw new BadRequestException('CEP inválido.');
|
|
}
|
|
|
|
const cached = await this.redis.getJson<ZipPlace>(`geo:cep:${cep}`);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const viaCep = await this.lookupViaCep(cep);
|
|
const coords = await this.lookupNominatim(viaCep.city, viaCep.state);
|
|
const place: ZipPlace = {
|
|
zip: cep,
|
|
city: viaCep.city,
|
|
state: viaCep.state,
|
|
label: `${viaCep.city}/${viaCep.state}`,
|
|
lat: coords?.lat ?? 0,
|
|
lon: coords?.lon ?? 0,
|
|
};
|
|
await this.redis.setJson(`geo:cep:${cep}`, place, 60 * 60 * 24 * 7);
|
|
return place;
|
|
}
|
|
|
|
async distanceKm(originZip: string, destinationZip: string): Promise<{
|
|
origin: ZipPlace;
|
|
destination: ZipPlace;
|
|
km: number;
|
|
source: 'nominatim' | 'fallback';
|
|
}> {
|
|
const origin = await this.placeFromZip(originZip);
|
|
const destination = await this.placeFromZip(destinationZip);
|
|
if (origin.lat && origin.lon && destination.lat && destination.lon) {
|
|
return { origin, destination, km: Math.max(haversineKm(origin, destination), 1), source: 'nominatim' };
|
|
}
|
|
return { origin, destination, km: fallbackDistanceKm(origin, destination), source: 'fallback' };
|
|
}
|
|
|
|
private async lookupViaCep(cep: string): Promise<{ city: string; state: string }> {
|
|
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`, {
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
if (!response.ok) {
|
|
throw new BadRequestException('Não foi possível consultar o CEP.');
|
|
}
|
|
const data = (await response.json()) as { erro?: boolean; localidade?: string; uf?: string };
|
|
if (data.erro || !data.localidade || !data.uf) {
|
|
throw new BadRequestException('CEP não encontrado.');
|
|
}
|
|
return { city: data.localidade, state: data.uf };
|
|
}
|
|
|
|
private async lookupNominatim(city: string, state: string): Promise<{ lat: number; lon: number } | null> {
|
|
const query = new URLSearchParams({
|
|
format: 'json',
|
|
limit: '1',
|
|
countrycodes: 'br',
|
|
q: `${city}, ${state}, Brasil`,
|
|
});
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
|
|
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const rows = (await response.json()) as Array<{ lat: string; lon: string }>;
|
|
const first = rows[0];
|
|
if (!first) {
|
|
return null;
|
|
}
|
|
return { lat: Number(first.lat), lon: Number(first.lon) };
|
|
}
|
|
}
|