@horizon-integrations/jetimob-crm
Version:
Adapter de integração com o CRM Jetimob — padrão Airbyte (@horizon-js/integrations-core)
1,402 lines (1,387 loc) • 52.3 kB
JavaScript
"use strict";
// src/manifest.ts
import { SyncMode } from "@horizon-js/integrations-core";
// src/spec.ts
var jetimobSpec = {
name: "jetimob",
title: "Jetimob",
documentationUrl: "https://api.jetimob.com",
configSchema: {
type: "object",
required: ["webserviceKey"],
properties: {
webserviceKey: {
type: "string",
title: "Webservice Key",
description: "Chave webservice da conta Jetimob. Vai no path da URL (/webservice/{key}/imoveis). Obtida no painel Jetimob da imobili\xE1ria.",
airbyteSecret: true
}
}
}
};
// package.json
var package_default = {
name: "@horizon-integrations/jetimob-crm",
version: "3.1.4",
description: "Adapter de integra\xE7\xE3o com o CRM Jetimob \u2014 padr\xE3o Airbyte (@horizon-js/integrations-core)",
main: "dist/index.js",
module: "dist/index.mjs",
types: "dist/index.d.ts",
files: [
"dist",
"docs",
"examples"
],
scripts: {
build: "tsup",
dev: "tsup --watch",
test: "vitest",
"test:coverage": "vitest --coverage",
typecheck: "tsc --noEmit"
},
keywords: [
"imobiliario",
"jetimob",
"crm",
"integration",
"airbyte",
"converter",
"horizon"
],
author: "Horizon Modules",
license: "MIT",
dependencies: {
"@horizon-js/integrations-core": "^0.5.1",
"@horizon-js/property-domain-schema": "^3.17.0",
zod: "^3.25.67"
},
devDependencies: {
"@types/node": "^20.0.0",
tsup: "^8.0.0",
tsx: "^4.0.0",
typescript: "^5.0.0",
vitest: "^1.0.0"
},
exports: {
".": {
types: "./dist/index.d.ts",
import: "./dist/index.mjs",
require: "./dist/index.js",
default: "./dist/index.js"
}
},
repository: {
type: "git",
url: "https://github.com/imobland/horizon-integrations.git",
directory: "horizon-integrations/jetimob"
},
publishConfig: {
access: "public"
}
};
// src/manifest.ts
var jetimobManifest = {
metadata: {
name: "jetimob",
displayName: "Jetimob",
type: "crm",
vendor: {
company: "Jetimob",
website: "https://jetimob.com",
docsUrl: "https://api.jetimob.com"
},
supportTier: "community",
adapterVersion: package_default.version
},
spec: jetimobSpec,
capabilities: {
// API v4 pagina de verdade — ?page=&pageSize=, resposta traz totalPages.
pagination: true,
// ?start/&end (Unix timestamp), ?codigos, ?id — todos testados e funcionando.
filters: true,
// Cada imóvel tem data_update / updated_at — melhor que SI9 (que não tem).
nativeTimestamp: true,
// ?start={unix} filtra por data de atualização server-side. Testado: funciona.
incrementalByTimestamp: true,
// Busca individual via ?id={id_imovel} (ou ?codigos={codigo}).
individualFetch: true,
// /imoveis-ativos devolve todos os id_imovel ativos numa tacada (~0.3s, ~9KB).
lightweightListing: true,
webhooks: false,
rateLimit: null,
customFields: false,
writes: {
// API de leads existe (par chave-pública/privada) — writer ainda não implementado.
sendLead: false,
createProperty: false,
updateProperty: false,
deleteProperty: false
},
softDelete: { enabled: false }
},
streams: [
{
name: "properties",
displayName: "Im\xF3veis",
resourceType: "property",
supportedSyncModes: [SyncMode.FULL_REFRESH, SyncMode.INCREMENTAL],
cursorField: "source_updated_at",
primaryKey: ["reference"],
hasListing: true,
hasIndividualFetch: true
}
],
writers: [],
knownIssues: [
{
id: "JET-001",
severity: "medium",
summary: "Doc interna antiga afirmava que GET /imoveis retorna array direto \u2014 na verdade retorna envelope { total, page, pageSize, totalPages, data: [...] }",
workaround: "JetimobPropertyStream.parseResponse() l\xEA o envelope `data`. Doc API_JETIMOB.md corrigida com base na doc oficial (docs.jetimob.com).",
category: "documentation",
reportedToVendor: false,
discoveredAt: "2026-05-14"
},
{
id: "JET-002",
severity: "low",
summary: "/imoveis-ativos e /imoveis usam identificadores diferentes: /imoveis-ativos devolve `id_imovel`; ?codigos= filtra por `codigo` (que N\xC3O \xE9 o id). ?id= filtra por `id_imovel`.",
workaround: "getListing() usa /imoveis-ativos \u2192 ListingEntry.ref = id_imovel. fetchByRef() tenta ?id= primeiro e cai pra ?codigos= como fallback, aceitando os dois. O converter exp\xF5e `id_imovel` como campo extra pra correla\xE7\xE3o (reference continua sendo o `codigo`).",
category: "documentation",
reportedToVendor: false,
discoveredAt: "2026-05-14"
},
{
id: "JET-003",
severity: "low",
summary: "Medidas de terreno (terreno_total, terreno_frente, etc.) \xE0s vezes v\xEAm como string em vez de number",
workaround: "Schema raw (jetimob-property-schema.zod.ts) aceita string|number nesses campos; o conversor faz Number().",
category: "api_bug",
reportedToVendor: false,
discoveredAt: "2026-05-14"
},
{
id: "JET-004",
severity: "low",
summary: "API retorna null (n\xE3o undefined) em praticamente todos os campos opcionais",
workaround: "Schema raw usa .nullish() em todos os campos opcionais (aceita null | undefined).",
category: "api_bug",
reportedToVendor: false,
discoveredAt: "2026-05-14"
},
{
id: "JET-005",
severity: "low",
summary: "/imoveis n\xE3o tem par\xE2metro de sele\xE7\xE3o de campos (testado: fields, campos, field, select, only \u2014 todos ignorados). Toda resposta de /imoveis traz os ~96 campos completos (~9.5KB por im\xF3vel).",
workaround: "Pra listagem leve usar /imoveis-ativos (s\xF3 id_imovel). Pra delta eficiente usar ?start={unix} (incremental server-side). N\xE3o h\xE1 como pedir menos campos do /imoveis.",
category: "missing_feature",
reportedToVendor: false,
discoveredAt: "2026-05-14"
},
{
id: "JET-006",
severity: "low",
summary: "API Jetimob devolve 502 Bad Gateway transiente espor\xE1dico (nginx). Em pagina\xE7\xE3o de muitas p\xE1ginas, um 502 isolado derrubaria o sync inteiro.",
workaround: "JetimobPropertyStream usa retryOptions (httpRetryPolicy, 4 tentativas, backoff exponencial). 5xx/429/timeout s\xE3o re-tentados automaticamente.",
category: "performance",
reportedToVendor: false,
discoveredAt: "2026-05-14"
},
{
id: "JET-007",
severity: "high",
summary: "`generateApproximateCoords` usava Math.random() \u2192 o campo geo_aproximado mudava a cada convers\xE3o \u2192 sync_hash inst\xE1vel \u2192 o delta re-upsertava ~60% dos im\xF3veis (os com geoposicionamento_visivel=2) em TODO sync.",
workaround: "Corrigido em 3.1.0: o offset agora \xE9 determin\xEDstico (FNV-1a hash do codigo/id do im\xF3vel). Mesmo im\xF3vel \u2192 mesmo geo_aproximado \u2192 sync_hash est\xE1vel.",
category: "breaking_change",
reportedToVendor: false,
discoveredAt: "2026-05-14",
resolvedAt: "2026-05-14"
}
],
client: {
packageName: "@horizon-integrations/jetimob-crm-client",
className: "JetimobClient",
apiVersion: "v1",
endpoints: {
properties: "/api/providers/jetimob/v1/properties",
"properties.index": "/api/providers/jetimob/v1/properties/index",
"properties.byRef": "/api/providers/jetimob/v1/properties/{ref}"
}
}
};
// src/streams/JetimobPropertyStream.ts
import {
HttpStream,
SyncMode as SyncMode2,
SyncError,
fetchWithTimeout,
retry,
httpRetryPolicy
} from "@horizon-js/integrations-core";
// src/services/PropertyConverter/index.ts
import { hashObject } from "@horizon-js/integrations-core";
// src/schemas/jetimob-property-schema.zod.ts
import { z } from "zod";
var ruralSchema = z.object({
atividade_rural: z.string().nullish(),
rural_sedes: z.union([z.string(), z.number()]).nullish(),
rural_area_aravel: z.number().nullish(),
medida_total_area_aravel: z.string().nullish()
}).nullish();
var imagemSchema = z.object({
link: z.string(),
titulo: z.string().nullish(),
link_thumb: z.string().nullish()
}).passthrough();
var JetimobPropertySchemaSchema = z.object({
codigo: z.string(),
titulo_anuncio: z.string().max(200).nullish(),
observacoes: z.string().nullish(),
descricao_anuncio: z.string().nullish(),
contrato: z.string().nullish(),
tipo: z.string().nullish(),
subtipo: z.string().nullish(),
status: z.string().nullish(),
situacao: z.string().nullish(),
destaque: z.string().nullish(),
destaque_fim: z.string().nullish(),
exclusividade: z.union([z.boolean(), z.number()]).nullish(),
financiavel: z.union([z.boolean(), z.number()]).nullish(),
permuta: z.union([z.boolean(), z.number()]).nullish(),
seguro_fianca: z.union([z.boolean(), z.number()]).nullish(),
mobiliado: z.union([z.boolean(), z.number()]).nullish(),
// Valores monetários
valor_venda: z.number().nullish(),
valor_venda_visivel: z.union([z.boolean(), z.number()]).nullish(),
valor_locacao: z.number().nullish(),
valor_locacao_visivel: z.union([z.boolean(), z.number()]).nullish(),
valor_temporada: z.number().nullish(),
valor_temporada_visivel: z.union([z.boolean(), z.number()]).nullish(),
valor_condominio: z.number().nullish(),
valor_condominio_visivel: z.union([z.boolean(), z.number()]).nullish(),
valor_iptu: z.number().nullish(),
valor_iptu_visivel: z.union([z.boolean(), z.number()]).nullish(),
valor_seguro_incendio: z.number().nullish(),
valor_taxa_limpeza: z.number().nullish(),
// Características físicas
dormitorios: z.number().nullish(),
suites: z.number().nullish(),
banheiros: z.number().nullish(),
garagens: z.number().nullish(),
area_total: z.number().nullish(),
area_privativa: z.number().nullish(),
area_util: z.number().nullish(),
andar: z.number().nullish(),
andar_visivel: z.union([z.boolean(), z.number()]).nullish(),
// Endereço
endereco_cep: z.string().max(9).nullish(),
endereco_estado: z.string().nullish(),
endereco_estado_visivel: z.union([z.boolean(), z.number()]).nullish(),
endereco_cidade: z.string().nullish(),
endereco_cidade_visivel: z.union([z.boolean(), z.number()]).nullish(),
endereco_bairro: z.string().nullish(),
endereco_bairro_visivel: z.union([z.boolean(), z.number()]).nullish(),
endereco_zona: z.string().nullish(),
endereco_logradouro: z.string().nullish(),
endereco_logradouro_visivel: z.union([z.boolean(), z.number()]).nullish(),
endereco_numero: z.union([z.string(), z.number()]).nullish(),
endereco_numero_visivel: z.union([z.boolean(), z.number()]).nullish(),
endereco_complemento: z.string().nullish(),
endereco_complemento_visivel: z.union([z.boolean(), z.number()]).nullish(),
endereco_referencia: z.string().nullish(),
endereco_referencia_visivel: z.union([z.boolean(), z.number()]).nullish(),
// Coordenadas e geolocalização
latitude: z.union([z.number(), z.string()]).nullish(),
longitude: z.union([z.number(), z.string()]).nullish(),
geoposicionamento_visivel: z.number().nullish(),
// IDs internos
id_imovel: z.number().nullish(),
id_estado: z.number().nullish(),
id_cidade: z.number().nullish(),
id_bairro: z.number().nullish(),
id_condominio: z.number().nullish(),
id_subcondominio: z.number().nullish(),
id_corretor: z.number().nullish(),
// Campos específicos
distancia_mar: z.number().nullish(),
tipo_construcao: z.string().nullish(),
tipo_piso: z.string().nullish(),
entrega_ano: z.union([z.number(), z.string()]).nullish(),
entrega_mes: z.union([z.number(), z.string()]).nullish(),
posicao: z.string().nullish(),
posicao_solar: z.string().nullish(),
tags: z.string().nullish(),
// Terreno — API às vezes envia medidas como string; converter faz Number()
terreno_frente: z.union([z.number(), z.string()]).nullish(),
terreno_fundos: z.union([z.number(), z.string()]).nullish(),
terreno_esquerdo: z.union([z.number(), z.string()]).nullish(),
terreno_direita: z.union([z.number(), z.string()]).nullish(),
terreno_total: z.union([z.number(), z.string()]).nullish(),
medida_terreno_total: z.string().nullish(),
// Pessoas e períodos
numero_pessoas: z.number().nullish(),
periodicidade_iptu: z.string().nullish(),
// Condomínio
condominio_fechado: z.union([z.boolean(), z.number()]).nullish(),
condominio_nome: z.string().nullish(),
condominio_tipo: z.string().nullish(),
condominio_comodidades: z.union([z.string(), z.array(z.string())]).nullish(),
// Características
imovel_comodidades: z.string().nullish(),
// Rural
rural: ruralSchema,
// Calendário
calendario_temporada: z.union([z.string(), z.array(z.any())]).nullish(),
// SEO
meta_description: z.string().nullish(),
// Mídia
imagens: z.array(imagemSchema).nullish(),
videos: z.array(z.any()).nullish(),
plantas: z.array(z.any()).nullish(),
tour360: z.array(z.any()).nullish(),
// Configurações
medida: z.string().nullish(),
// Datas
data_cadastro: z.string().nullish(),
data_update: z.string().nullish(),
data_atualizacao: z.string().nullish(),
updated_at: z.string().nullish()
}).passthrough();
var validateJetimobPropertySchema = (data) => {
return JetimobPropertySchemaSchema.parse(data);
};
var safeValidateJetimobPropertySchema = (data) => {
return JetimobPropertySchemaSchema.safeParse(data);
};
// src/services/PropertyConverter/convertBaseFields.ts
function convertToISO(dateStr) {
if (!dateStr) return null;
if (dateStr.includes("T")) return dateStr;
return dateStr.replace(" ", "T") + ".000Z";
}
function hashSeed(seed) {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function generateApproximateCoords(lat, lng, seed) {
const offsetDegrees = 500 / 111e3;
const h = hashSeed(seed);
const angle = h % 3600 / 3600 * 2 * Math.PI;
const distance = (h >>> 12) % 1e3 / 1e3 * offsetDegrees;
return {
lat: lat + Math.sin(angle) * distance,
lng: lng + Math.cos(angle) * distance
};
}
function convertBaseFields(imovel) {
const result = {};
result.source_key = "jetimob-api-imoveis";
result.reference = imovel.codigo;
result.title = imovel.titulo_anuncio || "Sem t\xEDtulo";
result.description = imovel.observacoes || "Sem descri\xE7\xE3o";
result.source_updated_at = convertToISO(imovel.data_update ?? imovel.updated_at ?? void 0);
if (imovel.meta_description) result.seo_description = imovel.meta_description;
result.currency = "BRL";
result.unit_area = imovel.medida === "m\xB2" || imovel.medida === "m2" ? "m2" : "m2";
result.unit_distance = "meters";
if (imovel.contrato) {
const partes = imovel.contrato.split(",").map((s) => s.trim().toLowerCase());
const operacoes = [];
for (const parte of partes) {
if (parte === "compra" || parte === "venda") operacoes.push("venda");
else if (parte === "loca\xE7\xE3o" || parte === "locacao" || parte === "aluguel") operacoes.push("locacao");
else if (parte === "temporada") operacoes.push("temporada");
}
if (operacoes.length > 0) result.operacao = operacoes;
}
if (imovel.valor_venda && imovel.valor_venda_visivel) {
const valor = Number(imovel.valor_venda);
if (!isNaN(valor) && valor > 0) result.valor_venda = valor;
}
if (imovel.valor_locacao && imovel.valor_locacao_visivel) {
const valor = Number(imovel.valor_locacao);
if (!isNaN(valor) && valor > 0) result.valor_locacao = valor;
}
if (imovel.valor_temporada && imovel.valor_temporada_visivel) {
const valor = Number(imovel.valor_temporada);
if (!isNaN(valor) && valor > 0) result.valor_diaria = valor;
}
if (imovel.valor_condominio && imovel.valor_condominio_visivel) {
const valor = Number(imovel.valor_condominio);
if (!isNaN(valor) && valor >= 0) result.valor_condominio = valor;
}
if (imovel.valor_iptu && imovel.valor_iptu_visivel) {
const valor = Number(imovel.valor_iptu);
if (!isNaN(valor) && valor >= 0) result.valor_iptu = valor;
}
if (imovel.area_total) {
const area = Number(imovel.area_total);
if (!isNaN(area) && area > 0) result.area_total = area;
}
if (imovel.area_privativa) {
const area = Number(imovel.area_privativa);
if (!isNaN(area) && area > 0) result.area_privativa = area;
}
if (imovel.area_util) {
const area = Number(imovel.area_util);
if (!isNaN(area) && area > 0) result.area_util = area;
}
if (imovel.dormitorios) result.dormitorios = Number(imovel.dormitorios);
if (imovel.suites) result.suites = Number(imovel.suites);
if (imovel.banheiros) result.banheiros = Number(imovel.banheiros);
if (imovel.garagens) result.vagas_garagem = Number(imovel.garagens);
if (imovel.subtipo) result.tipo = imovel.subtipo;
if (imovel.endereco_cep) {
const cepLimpo = imovel.endereco_cep.replace(/\D/g, "");
if (cepLimpo.length === 8) {
result.endereco_cep = `${cepLimpo.slice(0, 5)}-${cepLimpo.slice(5)}`;
} else {
result.endereco_cep = imovel.endereco_cep;
}
}
if (imovel.endereco_estado && imovel.endereco_estado_visivel) result.endereco_estado = imovel.endereco_estado;
if (imovel.endereco_cidade && imovel.endereco_cidade_visivel) result.endereco_cidade = imovel.endereco_cidade;
if (imovel.endereco_bairro && imovel.endereco_bairro_visivel) result.endereco_bairro = imovel.endereco_bairro;
if (imovel.endereco_logradouro && imovel.endereco_logradouro_visivel) result.endereco_logradouro = imovel.endereco_logradouro;
if (imovel.endereco_numero && imovel.endereco_numero_visivel) result.endereco_numero = String(imovel.endereco_numero).slice(0, 20);
if (imovel.endereco_complemento && imovel.endereco_complemento_visivel) result.endereco_complemento = imovel.endereco_complemento;
if (imovel.endereco_referencia && imovel.endereco_referencia_visivel) result.endereco_referencia = imovel.endereco_referencia;
if (imovel.endereco_zona) result.endereco_zona = imovel.endereco_zona;
if (imovel.latitude && imovel.longitude) {
const lat = Number(imovel.latitude);
const lng = Number(imovel.longitude);
if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) {
const geoVisivel = Number(imovel.geoposicionamento_visivel);
if (geoVisivel === 1) {
result.lat = lat;
result.lng = lng;
} else if (geoVisivel === 2) {
result.geo_aproximado = generateApproximateCoords(
lat,
lng,
String(imovel.codigo ?? imovel.id_imovel ?? `${lat},${lng}`)
);
}
}
}
if (imovel.destaque && imovel.destaque !== "Sem destaque") {
if (imovel.destaque === "Destaque" || imovel.destaque === "true" || String(imovel.destaque) === "true") {
result.destaque = true;
}
}
if (imovel.id_corretor) {
result.corretor_key = String(imovel.id_corretor);
}
if (imovel.id_condominio) {
result.condominio_key = String(imovel.id_condominio);
}
if (imovel.condominio_nome) {
result.condominio_nome = imovel.condominio_nome;
}
if (imovel.tags) {
result.tags = imovel.tags.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
}
if (imovel.numero_pessoas) {
const pessoas = Number(imovel.numero_pessoas);
if (!isNaN(pessoas) && pessoas > 0) result.numero_pessoas = pessoas;
}
if (imovel.imagens && imovel.imagens.length > 0) {
result.images = imovel.imagens.map((imagem, index) => ({
full: imagem.link,
md: imagem.link_thumb || imagem.link,
sm: imagem.link_thumb || imagem.link,
cover: index === 0
// primeira imagem como cover
}));
const imagemPrincipal = imovel.imagens[0];
if (imagemPrincipal) {
result.main_image = {
full: imagemPrincipal.link,
md: imagemPrincipal.link_thumb || imagemPrincipal.link,
sm: imagemPrincipal.link_thumb || imagemPrincipal.link
};
}
} else {
result.images = [];
}
if (imovel.videos && imovel.videos.length > 0) {
result.videos = imovel.videos.map((video) => ({
url: video.link ? `https://youtube.com/watch?v=${video.link}` : null
})).filter((v) => Boolean(v.url));
} else {
result.videos = [];
}
if (imovel.tour360 && imovel.tour360.length > 0) {
result.virtual_tours = imovel.tour360.map((tour) => {
const embedUrl = typeof tour === "string" ? tour : tour.link || tour.url || "";
return { embed_url: embedUrl };
}).filter((t) => t.embed_url);
} else {
result.virtual_tours = [];
}
return result;
}
// src/services/PropertyConverter/convertExtendedFields.ts
function convertExtendedFields(imovel) {
const result = {};
if (imovel.valor_seguro_incendio) {
const valor = Number(imovel.valor_seguro_incendio);
if (!isNaN(valor) && valor >= 0) result.valor_seguro_incendio = valor;
}
if (imovel.valor_taxa_limpeza) {
const valor = Number(imovel.valor_taxa_limpeza);
if (!isNaN(valor) && valor >= 0) result.valor_taxa_limpeza = valor;
}
if (imovel.andar && imovel.andar_visivel) result.andar = Number(imovel.andar);
if (String(imovel.financiavel) === "1" || String(imovel.financiavel) === "true") {
result.financiavel = true;
}
if (String(imovel.exclusividade) === "true" || String(imovel.exclusividade) === "1") {
result.exclusividade = true;
}
if (String(imovel.permuta) === "true" || String(imovel.permuta) === "1") {
result.permuta = true;
}
if (String(imovel.seguro_fianca) === "true" || String(imovel.seguro_fianca) === "1") {
result.seguro_fianca = true;
}
if (String(imovel.mobiliado) === "1" || String(imovel.mobiliado) === "true") {
result.mobiliado = true;
}
if (imovel.imovel_comodidades) {
const comodidadesArray = String(imovel.imovel_comodidades).split(",").map((c) => c.trim()).filter((c) => c.length > 0);
if (comodidadesArray.length > 0) {
result.caracteristicas = comodidadesArray;
}
}
if (imovel.plantas && imovel.plantas.length > 0) {
result.plantas = imovel.plantas.map((planta) => {
return typeof planta === "string" ? planta : planta.link || "";
}).filter(Boolean);
}
if (imovel.id_imovel != null) result.id_imovel = String(imovel.id_imovel);
if (imovel.tipo) result.finalidade = imovel.tipo;
if (imovel.situacao) result.situacao = imovel.situacao;
if (imovel.distancia_mar) result.distancia_mar = Number(imovel.distancia_mar);
if (imovel.status) result.status = imovel.status;
if (imovel.posicao) result.posicao = imovel.posicao;
if (imovel.posicao_solar) {
const arr = String(imovel.posicao_solar).split(",").map((p) => p.trim()).filter((p) => p.length > 0);
if (arr.length > 0) result.posicao_solar = arr;
}
if (imovel.calendario_temporada) {
result.calendario_temporada = Array.isArray(imovel.calendario_temporada) ? imovel.calendario_temporada : [imovel.calendario_temporada];
}
if (imovel.condominio_comodidades) {
const comodidadesCondArray = String(imovel.condominio_comodidades).split(",").map((c) => c.trim()).filter((c) => c.length > 0);
if (comodidadesCondArray.length > 0) {
result.condominio_comodidades = comodidadesCondArray;
}
}
if (imovel.condominio_tipo) result.condominio_tipo = imovel.condominio_tipo;
if (imovel.id_subcondominio) result.subcondominio_id = String(imovel.id_subcondominio);
if (String(imovel.condominio_fechado) === "1" || String(imovel.condominio_fechado) === "true") {
result.condominio_fechado = true;
}
if (imovel.entrega_ano) {
const ano = Number(imovel.entrega_ano);
if (!isNaN(ano) && ano > 0) result.entrega_ano = ano;
}
if (imovel.entrega_mes) {
const mes = Number(imovel.entrega_mes);
if (!isNaN(mes) && mes > 0 && mes <= 12) result.entrega_mes = mes;
}
if (imovel.tipo_construcao) result.tipo_construcao = imovel.tipo_construcao;
if (imovel.tipo_piso) {
const arr = String(imovel.tipo_piso).split(",").map((p) => p.trim()).filter((p) => p.length > 0);
if (arr.length > 0) result.tipo_piso = arr;
}
if (imovel.periodicidade_iptu) result.periodicidade_iptu = imovel.periodicidade_iptu;
if (imovel.terreno_frente) {
const medida = Number(imovel.terreno_frente);
if (!isNaN(medida) && medida > 0) result.terreno_frente = medida;
}
if (imovel.terreno_fundos) {
const medida = Number(imovel.terreno_fundos);
if (!isNaN(medida) && medida > 0) result.terreno_fundos = medida;
}
if (imovel.terreno_esquerdo) {
const medida = Number(imovel.terreno_esquerdo);
if (!isNaN(medida) && medida > 0) result.terreno_esquerdo = medida;
}
if (imovel.terreno_direita) {
const medida = Number(imovel.terreno_direita);
if (!isNaN(medida) && medida > 0) result.terreno_direita = medida;
}
if (imovel.terreno_total) {
const area = Number(imovel.terreno_total);
if (!isNaN(area) && area > 0) result.terreno_total = area;
}
if (imovel.rural?.rural_area_aravel) {
const area = Number(imovel.rural.rural_area_aravel);
if (!isNaN(area) && area > 0) result.rural_area_aravel = area;
}
if (imovel.rural?.rural_sedes) {
const sedes = Number(imovel.rural.rural_sedes);
if (!isNaN(sedes) && sedes > 0) result.rural_sedes = sedes;
}
if (imovel.rural?.atividade_rural) result.rural_atividade = imovel.rural.atividade_rural;
return result;
}
// src/services/PropertyConverter/index.ts
function convertJetimobPropertyToHorizon(rawImovel) {
const validation = safeValidateJetimobPropertySchema(rawImovel);
if (!validation.success) {
const errors = validation.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
throw new Error(`Valida\xE7\xE3o Jetimob falhou: ${errors}`);
}
const imovel = validation.data;
const baseFields = convertBaseFields(imovel);
const extendedFields = convertExtendedFields(imovel);
const payload = { ...baseFields, ...extendedFields };
return {
...payload,
sync_hash: hashObject(payload)
};
}
// src/streams/JetimobPropertyStream.ts
var DEFAULT_BASE_URL = "https://api.jetimob.com";
var DEFAULT_PAGE_SIZE = 200;
var JetimobPropertyStream = class extends HttpStream {
constructor(credentials, options = {}) {
super();
this.name = "properties";
this.supportedSyncModes = [SyncMode2.FULL_REFRESH, SyncMode2.INCREMENTAL];
this.cursorField = "source_updated_at";
this.primaryKey = ["reference"];
this.webserviceKey = credentials.webserviceKey;
this.base = options.baseUrl ?? DEFAULT_BASE_URL;
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
this.retryOptions = httpRetryPolicy({ maxAttempts: 4 });
}
// ========================================
// HOOKS DO HttpStream
// ========================================
/** A `webserviceKey` é a auth — vai no path da URL base. */
get urlBase() {
return `${this.base}/webservice/${this.webserviceKey}/`;
}
path() {
return "imoveis";
}
requestParams(context) {
const page = context.nextPageToken != null ? Number(context.nextPageToken) : 1;
const params = {
v: "4",
page: String(page),
pageSize: String(this.pageSize)
};
const cursor = context.state?.cursor;
if (cursor != null) {
const unix = isoToUnix(String(cursor));
if (unix != null) params.start = String(unix);
}
return params;
}
/** API v4 retorna `{ total, page, pageSize, totalPages, data: [...] }` (JET-001). */
parseResponse(body) {
if (Array.isArray(body)) return body;
const env = body ?? {};
return Array.isArray(env.data) ? env.data : [];
}
/** Próxima página via metadados do envelope. `undefined` encerra o loop. */
nextPageToken(response) {
const env = response.body ?? {};
if (typeof env.page === "number" && typeof env.totalPages === "number") {
return env.page < env.totalPages ? env.page + 1 : void 0;
}
return void 0;
}
convertRecord(raw) {
return convertJetimobPropertyToHorizon(raw);
}
getJsonSchema() {
return {
type: "object",
required: ["reference", "source_key", "sync_hash"],
properties: {
reference: { type: "string" },
source_key: { type: "string" },
source_updated_at: { type: ["string", "null"] },
sync_hash: { type: "string" }
}
};
}
/** Mapeia status HTTP da Jetimob pros FailureType corretos. */
async onHttpError(response) {
return this.buildHttpError(response, "request");
}
// ========================================
// iterateRaw — paginação automática (fonte única de verdade)
// ========================================
/**
* Itera os imóveis **crus** (sem conversão) página por página, seguindo a
* paginação automática do `HttpStream`. Base de `readRecords` e `fetchRaw`.
*
* Quando `state` é passado (INCREMENTAL), `requestParams` injeta `?start={unix}`
* e a própria API devolve só o delta — a paginação fica curta.
*/
async *iterateRaw(state) {
let nextPageToken = void 0;
do {
const url = this.buildUrl({ state, nextPageToken });
const init = this.buildRequestInit({ state, nextPageToken });
const { body, headers, status } = await this.doRequest(url, init);
for (const raw of this.parseResponse(body)) {
yield raw;
}
nextPageToken = this.nextPageToken({ body, headers, status });
} while (nextPageToken !== void 0);
}
/**
* Retorna todos os imóveis **crus** (array, sem conversão). Útil pra
* profiling, debug, ou pra consumers que querem converter em lote com
* `parseResponseDefensive` do core. Espelha `Si9PropertyStream.fetchRaw()`.
*/
async fetchRaw() {
const all = [];
for await (const raw of this.iterateRaw()) all.push(raw);
return all;
}
// ========================================
// readRecords — DEFENSIVO + INCREMENTAL server-side via ?start
// ========================================
async *readRecords(syncMode, state) {
const incremental = syncMode === SyncMode2.INCREMENTAL;
const cursor = incremental ? state?.cursor ?? void 0 : void 0;
for await (const raw of this.iterateRaw(incremental ? state : void 0)) {
let record;
try {
record = this.convertRecord(raw);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const codigo = raw?.codigo;
console.warn(
`[JetimobPropertyStream] skipped record codigo=${String(codigo ?? "?")}: ${msg}`
);
continue;
}
if (cursor && record.source_updated_at && String(record.source_updated_at) <= String(cursor)) {
continue;
}
yield record;
}
}
/**
* Avança o cursor incremental a partir de um record lido.
* Cursor = maior `source_updated_at` visto.
*/
getUpdatedState(currentState, record) {
const value = record.source_updated_at;
if (!value) return currentState;
const current = currentState.cursor;
if (current != null && String(current) >= value) return currentState;
return { ...currentState, cursor: value };
}
// ========================================
// getListing — lista leve via /imoveis-ativos
// ========================================
/**
* Lista leve `[{ ref, updatedAt }]`. Usa o endpoint **`/imoveis-ativos`**, que
* devolve só os `id_imovel` ativos numa única resposta (~0.3s, ~9KB).
*
* Notas (JET-002):
* - `ref` = `id_imovel` (não o `codigo`). É o que `/imoveis-ativos` fornece e
* o que `?id=` consome. O converter expõe `id_imovel` como campo extra.
* - `updatedAt` é sempre `null` — `/imoveis-ativos` não traz timestamp. Pra
* delta com data, use `readRecords(INCREMENTAL)` (que filtra via `?start`).
*/
async getListing() {
const url = `${this.urlBase}imoveis-ativos?v=4`;
const ids = await retry(async () => {
const response = await fetchWithTimeout(url, {
timeoutMs: this.requestTimeoutMs
});
if (!response.ok) {
throw await this.buildHttpError(response, "getListing(/imoveis-ativos)");
}
return extractAtivosIds(await response.json());
}, this.retryOptions);
return ids.map((id) => ({ ref: String(id), updatedAt: null }));
}
// ========================================
// fetchByRef — busca individual (?id= com fallback ?codigos=)
// ========================================
/**
* Busca UM imóvel pelo `ref`. A Jetimob não tem endpoint individual — usa o
* filtro no endpoint de lista. Tenta `?id={ref}` primeiro (pareia com o
* `id_imovel` que `getListing` devolve) e, se não achar, tenta `?codigos={ref}`.
* Retorna `null` se nenhum dos dois encontrar.
*/
async fetchByRef(ref) {
const byId = await this.fetchOne(
`id=${encodeURIComponent(ref)}`,
`fetchByRef(id=${ref})`
);
if (byId) return byId;
return this.fetchOne(
`codigos=${encodeURIComponent(ref)}`,
`fetchByRef(codigos=${ref})`
);
}
/** Busca 1 imóvel por um filtro de query (`id=...` ou `codigos=...`). */
async fetchOne(filterParam, context) {
const url = `${this.urlBase}imoveis?v=4&${filterParam}&pageSize=1`;
const items = await retry(async () => {
const response = await fetchWithTimeout(url, {
timeoutMs: this.requestTimeoutMs
});
if (!response.ok) {
throw await this.buildHttpError(response, context);
}
return this.parseResponse(await response.json());
}, this.retryOptions);
if (items.length === 0) return null;
return this.convertRecord(items[0]);
}
/**
* Valida a credencial fazendo o request mais barato possível
* (`/imoveis-ativos`, ~9KB). Lança `SyncError` se a `webserviceKey` for
* inválida. Usado por `JetimobSource.check()`.
*/
async probe() {
const url = `${this.urlBase}imoveis-ativos?v=4`;
await retry(async () => {
const response = await fetchWithTimeout(url, {
timeoutMs: this.requestTimeoutMs
});
if (!response.ok) {
throw await this.buildHttpError(response, "probe");
}
}, this.retryOptions);
}
// ========================================
// helpers
// ========================================
async buildHttpError(response, context) {
const body = await response.text().catch(() => "");
const detail = `${response.status} ${context} ${this.base}: ${body.slice(0, 300)}`;
if (response.status === 401 || response.status === 403) {
return SyncError.configError(
"webserviceKey Jetimob inv\xE1lida ou sem permiss\xE3o",
detail
);
}
if (response.status === 429 || response.status >= 500) {
return SyncError.transientError(
"API Jetimob temporariamente indispon\xEDvel",
detail
);
}
return SyncError.systemError(`Erro HTTP ${response.status} em ${context}`, detail);
}
};
function extractAtivosIds(body) {
if (Array.isArray(body)) return body;
const env = body ?? {};
if (Array.isArray(env.data)) return env.data;
const inner = env.data ?? {};
return Array.isArray(inner.result) ? inner.result : [];
}
function isoToUnix(iso) {
const ms = new Date(iso).getTime();
return Number.isFinite(ms) ? Math.floor(ms / 1e3) : null;
}
// src/source.ts
var JetimobSource = class {
constructor(options = {}) {
this.options = options;
this.name = "jetimob";
this.manifest = jetimobManifest;
this.spec = jetimobSpec;
}
async check(config) {
if (!config?.webserviceKey || typeof config.webserviceKey !== "string") {
return { ok: false, message: "webserviceKey ausente ou inv\xE1lida" };
}
try {
const stream = new JetimobPropertyStream(config, {
baseUrl: this.options.baseUrl
});
await stream.probe();
return { ok: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, message: msg };
}
}
streams(config) {
return [
new JetimobPropertyStream(config, {
baseUrl: this.options.baseUrl
})
];
}
};
// src/services/PropertyDownloader.ts
import { parseResponseDefensive } from "@horizon-js/integrations-core";
function buildStream(config) {
return new JetimobPropertyStream(config.credentials, {
baseUrl: config.baseUrl,
pageSize: config.pageSize
});
}
async function fetchAll(config) {
const stream = buildStream(config);
const rawItems = await stream.fetchRaw();
const { valid, errors } = parseResponseDefensive(
rawItems,
convertJetimobPropertyToHorizon,
(raw) => String(raw?.codigo ?? "?")
);
return {
properties: valid,
errors: errors.map((e) => `${e.ref ?? `#${e.index}`}: ${e.message}`)
};
}
async function getListing(config) {
return buildStream(config).getListing();
}
async function fetchByRef(config, ref) {
return buildStream(config).fetchByRef(ref);
}
var PropertyDownloader = class {
constructor(config) {
this.config = config;
}
fetchAll() {
return fetchAll(this.config);
}
getListing() {
return getListing(this.config);
}
fetchByRef(ref) {
return fetchByRef(this.config, ref);
}
};
// src/schemas/horizon-property-schema-by-jetimob.ts
import { horizonPropertySchemaBase } from "@horizon-js/property-domain-schema";
var HorizonPropertySchemaByJetimob = {
entity: "property",
version: "2.3.0",
description: "Schema estendido Jetimob CRM - 86 campos (47 base + 39 extras)",
fields: [
// ========================================
// 📦 53 CAMPOS BASE HORIZON V3 (importados)
// ========================================
...horizonPropertySchemaBase.fields,
// ========================================
// 🔧 39 CAMPOS ADICIONAIS JETIMOB
// ========================================
{
key: "tags",
type: "String[]",
db: { type: "jsonb", index: "gin" },
categories: ["ficha-tecnica"],
ui: { label: "Tags" },
audit: { origin: "provider-extra" }
},
{
key: "numero_pessoas",
type: "Number",
format: "count",
validation: { min: 0 },
categories: ["comercial"],
rules: { conditions: ["operacao.contains:temporada"] },
ui: { label: "N\xFAmero de pessoas" },
audit: { origin: "provider-extra" }
},
{
key: "valor_diaria",
type: "Number",
format: "currency",
unit: "BRL",
validation: { min: 0, precision: 2 },
categories: ["valores"],
rules: { conditions: ["operacao.contains:temporada"] },
ui: { label: "Valor da di\xE1ria" },
audit: { origin: "provider-extra" }
},
{
key: "geo_aproximado",
type: "Json",
db: { type: "jsonb" },
categories: ["localizacao"],
ui: { label: "Localiza\xE7\xE3o aproximada" },
audit: { origin: "provider-extra" }
},
{
key: "id_imovel",
type: "String",
categories: ["sistema"],
ui: { label: "ID interno Jetimob" },
audit: { origin: "provider-extra" }
},
{
key: "plantas",
type: "String[]",
db: { type: "jsonb", index: "gin" },
categories: ["ficha-tecnica"],
ui: { label: "Plantas" },
audit: { origin: "provider-extra" }
},
{
key: "caracteristicas",
type: "String[]",
categories: ["ficha-tecnica"],
db: {
type: "jsonb",
index: "gin"
},
ui: {
label: "Caracter\xEDsticas"
},
audit: {
origin: "provider-extra"
}
},
{
key: "finalidade",
type: "String",
categories: ["ficha-tecnica"],
ui: {
label: "Finalidade"
},
audit: {
origin: "provider-extra"
}
},
{
key: "mobiliado",
type: "Boolean",
categories: ["ficha-tecnica"],
ui: {
label: "Mobiliado"
},
audit: {
origin: "provider-extra"
}
},
{
key: "valor_seguro_incendio",
type: "Number",
format: "currency",
unit: "BRL",
validation: { min: 0, precision: 2 },
categories: ["valores"],
ui: { label: "Valor do seguro inc\xEAndio" },
audit: { origin: "provider-extra" }
},
{
key: "valor_taxa_limpeza",
type: "Number",
format: "currency",
unit: "BRL",
validation: { min: 0, precision: 2 },
categories: ["valores"],
ui: { label: "Valor da taxa de limpeza" },
audit: { origin: "provider-extra" }
},
{
key: "andar",
type: "Number",
categories: ["endereco"],
ui: { label: "Andar" },
audit: { origin: "provider-extra" }
},
{
key: "financiavel",
type: "Boolean",
categories: ["comercial"],
ui: { label: "Financi\xE1vel" },
audit: { origin: "provider-extra" }
},
{
key: "exclusividade",
type: "Boolean",
categories: ["comercial"],
ui: { label: "Exclusividade" },
audit: { origin: "provider-extra" }
},
{
key: "permuta",
type: "Boolean",
categories: ["comercial"],
rules: { conditions: ["operacao.contains:venda"] },
ui: { label: "Aceita permuta" },
audit: { origin: "provider-extra" }
},
{
key: "seguro_fianca",
type: "Boolean",
categories: ["comercial"],
rules: { conditions: ["operacao.contains:locacao"] },
ui: { label: "Seguro fian\xE7a" },
audit: { origin: "provider-extra" }
},
{
key: "situacao",
type: "String",
categories: ["situacoes"],
ui: { label: "Situa\xE7\xE3o de ocupa\xE7\xE3o" },
audit: { origin: "provider-extra" }
},
{
key: "distancia_mar",
type: "Number",
format: "distance",
unit: "m",
validation: { min: 0 },
categories: ["localizacao"],
ui: { label: "Dist\xE2ncia do mar" },
audit: { origin: "provider-extra" }
},
{
key: "posicao",
type: "String",
categories: ["estrutura"],
ui: { label: "Posi\xE7\xE3o do im\xF3vel no terreno" },
audit: { origin: "provider-extra" }
},
{
key: "posicao_solar",
type: "String[]",
db: { type: "jsonb", index: "gin" },
categories: ["estrutura"],
ui: { label: "Posi\xE7\xE3o solar" },
audit: { origin: "provider-extra" }
},
{
key: "status",
type: "String",
categories: ["comercial"],
db: { default: "ativo" },
ui: { label: "Status do im\xF3vel" },
audit: { origin: "provider-extra" }
},
{
key: "calendario_temporada",
type: "Json",
categories: ["comercial"],
db: { type: "jsonb" },
rules: { conditions: ["operacao.contains:temporada"] },
ui: { label: "Calend\xE1rio temporada" },
audit: { origin: "provider-extra" }
},
{
key: "condominio_comodidades",
type: "String[]",
categories: ["condominio"],
db: { type: "jsonb", index: "gin" },
ui: { label: "Comodidades do condom\xEDnio" },
audit: { origin: "provider-extra" }
},
{
key: "condominio_tipo",
type: "String",
categories: ["condominio"],
ui: { label: "Tipo do condom\xEDnio" },
audit: { origin: "provider-extra" }
},
{
key: "subcondominio_id",
type: "String",
categories: ["condominio"],
ui: { label: "ID do subcondom\xEDnio" },
audit: { origin: "provider-extra" }
},
{
key: "condominio_fechado",
type: "Boolean",
categories: ["condominio"],
ui: { label: "Condom\xEDnio fechado" },
audit: { origin: "provider-extra" }
},
{
key: "entrega_ano",
type: "Number",
format: "year",
categories: ["cronograma"],
ui: { label: "Ano de entrega" },
audit: { origin: "provider-extra" }
},
{
key: "entrega_mes",
type: "Number",
format: "month",
validation: { min: 1, max: 12 },
categories: ["cronograma"],
rules: { conditions: ["entrega_ano.exists:true"] },
ui: { label: "M\xEAs de entrega" },
audit: { origin: "provider-extra" }
},
{
key: "tipo_construcao",
type: "String",
categories: ["estrutura"],
ui: { label: "Tipo de constru\xE7\xE3o" },
audit: { origin: "provider-extra" }
},
{
key: "tipo_piso",
type: "String[]",
db: { type: "jsonb", index: "gin" },
categories: ["estrutura"],
ui: { label: "Tipo de piso" },
audit: { origin: "provider-extra" }
},
{
key: "periodicidade_iptu",
type: "String",
categories: ["valores"],
ui: { label: "Periodicidade do IPTU" },
audit: { origin: "provider-extra" }
},
{
key: "terreno_frente",
type: "Number",
format: "distance",
unit: "m",
validation: { min: 0, precision: 2 },
categories: ["medidas"],
ui: { label: "Frente do terreno" },
audit: { origin: "provider-extra" }
},
{
key: "terreno_fundos",
type: "Number",
format: "distance",
unit: "m",
validation: { min: 0, precision: 2 },
categories: ["medidas"],
ui: { label: "Fundos do terreno" },
audit: { origin: "provider-extra" }
},
{
key: "terreno_esquerdo",
type: "Number",
format: "distance",
unit: "m",
validation: { min: 0, precision: 2 },
categories: ["medidas"],
ui: { label: "Lateral esquerda" },
audit: { origin: "provider-extra" }
},
{
key: "terreno_direita",
type: "Number",
format: "distance",
unit: "m",
validation: { min: 0, precision: 2 },
categories: ["medidas"],
ui: { label: "Lateral direita" },
audit: { origin: "provider-extra" }
},
{
key: "terreno_total",
type: "Number",
format: "area",
unit: "m2",
validation: { min: 0, precision: 2 },
categories: ["medidas"],
ui: { label: "\xC1rea total do terreno" },
audit: { origin: "provider-extra" }
},
{
key: "rural_area_aravel",
type: "Number",
format: "area",
unit: "hectare",
validation: { min: 0, precision: 2 },
categories: ["rural"],
ui: { label: "\xC1rea ar\xE1vel" },
audit: { origin: "provider-extra" }
},
{
key: "rural_sedes",
type: "Number",
format: "count",
validation: { min: 0 },
categories: ["rural"],
ui: { label: "N\xFAmero de sedes" },
audit: { origin: "provider-extra" }
},
{
key: "rural_atividade",
type: "String",
categories: ["rural"],
ui: { label: "Atividade rural" },
audit: { origin: "provider-extra" }
}
]
};
// src/schemas/horizon-property-schema-by-jetimob.zod.ts
import { z as z2 } from "zod";
import { HorizonPropertySchemaBaseZod } from "@horizon-js/property-domain-schema";
var ImageSchema = z2.object({
md: z2.string().optional(),
sm: z2.string().optional(),
full: z2.string().optional(),
cover: z2.boolean().optional()
}).passthrough();
var VideoSchema = z2.object({
url: z2.string().optional()
}).passthrough();
var VirtualTourSchema = z2.object({
embed_url: z2.string().optional()
}).passthrough();
var MainImageSchema = z2.object({
md: z2.string().optional(),
sm: z2.string().optional(),
full: z2.string().optional()
}).passthrough();
var HorizonPropertySchemaByJetimobZod = HorizonPropertySchemaBaseZod.omit({ images: true, videos: true, virtual_tours: true, main_image: true }).extend({
// ========================================
// JETIMOB EXTRAS
// ========================================
// Campos que saíram do base (agora são extras)
tags: z2.array(z2.string()).describe("Tags do im\xF3vel").optional(),
numero_pessoas: z2.number().min(0).describe("N\xFAmero de pessoas (temporada)").optional(),
valor_diaria: z2.number().describe("Valor da di\xE1ria (temporada)").optional(),
geo_aproximado: z2.object({
lat: z2.number().min(-90).max(90),
lng: z2.number().min(-180).max(180)
}).describe("Coordenadas aproximadas (offset ~500m)").optional(),
// Campos específicos Jetimob
id_imovel: z2.string().describe(
"ID interno do im\xF3vel na Jetimob (id_imovel). \xC9 o que /imoveis-ativos lista e o que ?id= consome \u2014 pareia com ListingEntry.ref. Distinto de `reference`, que \xE9 o `codigo`."
).optional(),
finalidade: z2.string().describe("Finalidade").optional(),
mobiliado: z2.boolean().describe("Mobiliado").optional(),
caracteristicas: z2.array(z2.string()).describe("Caracter\xEDsticas/Comodidades").optional(),
plantas: z2.array(z2.string()).describe("URLs das plantas").optional(),
// Valores adicionais
valor_seguro_incendio: z2.number().min(0).describe("Valor do seguro inc\xEAndio").optional(),
valor_taxa_limpeza: z2.number().min(0).describe("Valor da taxa de limpeza").optional(),
// Estrutura
andar: z2.number().describe("Andar").optional(),
// Comercial
financiavel: z2.boolean().describe("Financi\xE1vel").optional(),
exclusividade: z2.boolean().describe("Exclusividade").optional(),
permuta: z2.boolean().describe("Aceita permuta").optional(),
seguro_fianca: z2.boolean().describe("Seguro fian\xE7a").optional(),
situacao: z2.string().describe("Situa\xE7\xE3o de ocupa\xE7\xE3o").optional(),
status: z2.string().describe("Status do im\xF3vel").optional(),
// Localização
distancia_mar: z2.number().min(0).describe("Dist\xE2ncia do mar").optional(),
posicao: z2.string().describe("Posi\xE7\xE3o do im\xF3vel no terreno").optional(),
posicao_solar: z2.array(z2.string()).describe("Posi\xE7\xE3o solar \u2014 multi-valor (Norte, Leste, ...)").optional(),
// Temporada
calendario_temporada: z2.any().describe("Calend\xE1rio temporada").optional(),
// Condomínio estendido
condominio_comodidades: z2.array(z2.string()).de