Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a95957945 | ||
|
|
0c356d6fbe | ||
|
|
c507da8abc | ||
|
|
e42d588514 | ||
|
|
e2938248f8 | ||
|
|
4992da394b |
No files matched your search
@@ -33,7 +33,16 @@ ENV CI=true \
|
|||||||
EPAYCO_P_KEY=build-only-epayco-pkey \
|
EPAYCO_P_KEY=build-only-epayco-pkey \
|
||||||
EPAYCO_ENV=sandbox \
|
EPAYCO_ENV=sandbox \
|
||||||
EPAYCO_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
|
EPAYCO_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
|
||||||
EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse
|
EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse \
|
||||||
|
ENVIOCLICK_API_KEY=build-only-envioclick-key \
|
||||||
|
ENVIOCLICK_SANDBOX=true \
|
||||||
|
ENVIOCLICK_ORIGIN_DANE_CODE=11001000 \
|
||||||
|
ENVIOCLICK_ORIGIN_ADDRESS="Calle 1 No 1-1" \
|
||||||
|
ENVIOCLICK_ORIGIN_COMPANY="ARI Shopping" \
|
||||||
|
ENVIOCLICK_ORIGIN_FIRST_NAME=ARI \
|
||||||
|
ENVIOCLICK_ORIGIN_LAST_NAME=Shopping \
|
||||||
|
ENVIOCLICK_ORIGIN_EMAIL=[email protected] \
|
||||||
|
ENVIOCLICK_ORIGIN_PHONE=3000000000
|
||||||
|
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,45 @@ module.exports = defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
|
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
|
||||||
|
// un modulo de fulfillment con el provider manual_manual
|
||||||
|
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
|
||||||
|
// directo) -- pero como este bloque propio comparte el mismo tipo de
|
||||||
|
// modulo, transformModules() lo pisa por completo (indexa por
|
||||||
|
// serviceName, el ultimo que llega gana), no lo mezcla. Por eso
|
||||||
|
// manual_manual va explicito ACA tambien: si se omitiera, la
|
||||||
|
// shipping option existente que ya usa ese provider se rompe en
|
||||||
|
// silencio. envioclick-fulfillment implementa el mismo contrato
|
||||||
|
// (AbstractFulfillmentProviderService) contra
|
||||||
|
// developers.envioclickpro.com.co, verificado en vivo (no solo doc)
|
||||||
|
// con una llamada de prueba real que devolvio tarifas reales de
|
||||||
|
// varias transportadoras.
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/fulfillment",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/fulfillment-manual",
|
||||||
|
id: "manual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/envioclick-fulfillment",
|
||||||
|
id: "standard",
|
||||||
|
options: {
|
||||||
|
apiKey: required("ENVIOCLICK_API_KEY"),
|
||||||
|
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
|
||||||
|
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "11001000",
|
||||||
|
originAddress: required("ENVIOCLICK_ORIGIN_ADDRESS"),
|
||||||
|
originCompany: process.env.ENVIOCLICK_ORIGIN_COMPANY || "ARI Shopping",
|
||||||
|
originFirstName: process.env.ENVIOCLICK_ORIGIN_FIRST_NAME || "ARI",
|
||||||
|
originLastName: process.env.ENVIOCLICK_ORIGIN_LAST_NAME || "Shopping",
|
||||||
|
originEmail: required("ENVIOCLICK_ORIGIN_EMAIL"),
|
||||||
|
originPhone: required("ENVIOCLICK_ORIGIN_PHONE"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
resolve: "@medusajs/medusa/file",
|
resolve: "@medusajs/medusa/file",
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// EnvíoClick (ADR 0009 §2, ya anticipado desde el modelo de direcciones
|
||||||
|
// del checkout): cotización y generación de guía real por peso/volumen,
|
||||||
|
// reemplazando la tarifa fija de $15.000 COP. Contrato REST confirmado en
|
||||||
|
// vivo contra la cuenta real del usuario (apiDoc real,
|
||||||
|
// apidoc.envioclickpro.com.co/api_data.json -- la SPA de la doc no
|
||||||
|
// renderiza sin JS, pero ese archivo generado por apidoc trae la spec
|
||||||
|
// completa) y una llamada de prueba real que devolvió 200 con tarifas
|
||||||
|
// reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC.
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.envioclickpro.com.co/api/v2";
|
||||||
|
|
||||||
|
export interface EnvioClickCredentials {
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickPackage {
|
||||||
|
weight: number;
|
||||||
|
height: number;
|
||||||
|
length: number;
|
||||||
|
// "widht" es un typo real de la API (confirmado en el body de
|
||||||
|
// /shipment de la doc oficial), no un error nuestro -- solo se usa acá,
|
||||||
|
// en el request de /quotation el campo si se llama "width" (confirmado
|
||||||
|
// tambien contra la doc real, son dos endpoints con esa inconsistencia).
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickRate {
|
||||||
|
idRate: number;
|
||||||
|
idProduct: number;
|
||||||
|
product: string;
|
||||||
|
idCarrier: number;
|
||||||
|
carrier: string;
|
||||||
|
flete: number;
|
||||||
|
minimumInsurance: number;
|
||||||
|
extraInsurance: number;
|
||||||
|
deliveryDays: number;
|
||||||
|
cod: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickAddress {
|
||||||
|
daneCode: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function envioclickRequest(
|
||||||
|
credentials: EnvioClickCredentials,
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// Header real confirmado contra la doc oficial y una llamada en
|
||||||
|
// vivo -- "Authorization" a secas (sin "Bearer"), NO
|
||||||
|
// "AuthorizationKey" como usaba el plugin de WordPress de
|
||||||
|
// referencia (ese era para la API v1, desactualizada).
|
||||||
|
Authorization: credentials.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let json: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Respuesta no-JSON de EnvíoClick (HTTP ${response.status}): ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
if (json.status !== "OK") {
|
||||||
|
throw new Error(`EnvíoClick respondió con error: ${JSON.stringify(json).slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo 1 paquete por cotizacion (limite real documentado y confirmado:
|
||||||
|
// "min 1, max 1" -- cada vez menos transportadoras ofrecen multi-paquete).
|
||||||
|
export async function quoteShipment(
|
||||||
|
credentials: EnvioClickCredentials,
|
||||||
|
params: {
|
||||||
|
pkg: EnvioClickPackage;
|
||||||
|
description: string;
|
||||||
|
contentValue: number;
|
||||||
|
origin: EnvioClickAddress;
|
||||||
|
destination: EnvioClickAddress;
|
||||||
|
},
|
||||||
|
): Promise<EnvioClickRate[]> {
|
||||||
|
const json = await envioclickRequest(credentials, "/quotation", {
|
||||||
|
packages: [params.pkg],
|
||||||
|
description: params.description,
|
||||||
|
contentValue: params.contentValue,
|
||||||
|
origin: params.origin,
|
||||||
|
destination: params.destination,
|
||||||
|
});
|
||||||
|
const data = json.data as Record<string, unknown>;
|
||||||
|
return (data.rates as EnvioClickRate[]) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickPerson {
|
||||||
|
company: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
suburb: string;
|
||||||
|
crossStreet: string;
|
||||||
|
reference: string;
|
||||||
|
daneCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGuideResult {
|
||||||
|
guide: string;
|
||||||
|
url: string;
|
||||||
|
tracker: string;
|
||||||
|
idOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGuide(
|
||||||
|
credentials: EnvioClickCredentials,
|
||||||
|
sandbox: boolean,
|
||||||
|
params: {
|
||||||
|
idRate: number;
|
||||||
|
myShipmentReference: string;
|
||||||
|
externalOrderId: string;
|
||||||
|
pkg: EnvioClickPackage;
|
||||||
|
description: string;
|
||||||
|
contentValue: number;
|
||||||
|
origin: EnvioClickPerson;
|
||||||
|
destination: EnvioClickPerson;
|
||||||
|
},
|
||||||
|
): Promise<CreateGuideResult> {
|
||||||
|
// /shipment_sandbox es el UNICO endpoint con modo de pruebas real (la
|
||||||
|
// cotizacion siempre usa tarifas reales de las transportadoras, no
|
||||||
|
// tiene sandbox) -- confirmado en la doc oficial ("Deberás cambiar a
|
||||||
|
// la URL de producción para que puedas generar guías reales").
|
||||||
|
const path = sandbox ? "/shipment_sandbox" : "/shipment";
|
||||||
|
const json = await envioclickRequest(credentials, path, {
|
||||||
|
idRate: params.idRate,
|
||||||
|
myShipmentReference: params.myShipmentReference,
|
||||||
|
external_order_id: params.externalOrderId,
|
||||||
|
requestPickup: false,
|
||||||
|
pickupDate: new Date().toISOString().slice(0, 10),
|
||||||
|
insurance: false,
|
||||||
|
description: params.description,
|
||||||
|
contentValue: params.contentValue,
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
weight: params.pkg.weight,
|
||||||
|
height: params.pkg.height,
|
||||||
|
length: params.pkg.length,
|
||||||
|
widht: params.pkg.width,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
origin: params.origin,
|
||||||
|
destination: params.destination,
|
||||||
|
});
|
||||||
|
const data = json.data as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
guide: String(data.guide ?? ""),
|
||||||
|
url: String(data.url ?? ""),
|
||||||
|
tracker: String(data.tracker ?? ""),
|
||||||
|
idOrder: Number(data.idOrder ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hasta 50 ordenes por llamada (limite real documentado).
|
||||||
|
export async function cancelGuides(credentials: EnvioClickCredentials, idOrders: number[]): Promise<void> {
|
||||||
|
await envioclickRequest(credentials, "cancellation/batch/order", { idOrders });
|
||||||
|
}
|
||||||
|
|
||||||
|
// código DANE estandar (5 digitos: 2 de departamento + 3 de municipio,
|
||||||
|
// ej. 11001 = Bogota, 5001 = Medellin -- mismo id que ya usa
|
||||||
|
// lib/data/colombia.ts del storefront, confirmado contra su propio
|
||||||
|
// comentario) con el padding real que espera EnvioClick (8 caracteres,
|
||||||
|
// confirmado contra su propio ejemplo real: "11001000" para Bogota =
|
||||||
|
// "11001" + "000").
|
||||||
|
export function toEnvioClickDaneCode(divipolaCityId: number): string {
|
||||||
|
return `${String(divipolaCityId).padStart(5, "0")}000`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
|
||||||
|
import EnvioClickFulfillmentService from "./service";
|
||||||
|
|
||||||
|
export default ModuleProvider(Modules.FULFILLMENT, {
|
||||||
|
services: [EnvioClickFulfillmentService],
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils";
|
||||||
|
import type {
|
||||||
|
CalculatedShippingOptionPrice,
|
||||||
|
CalculateShippingOptionPriceContext,
|
||||||
|
CalculateShippingOptionPriceDTO,
|
||||||
|
CreateFulfillmentResult,
|
||||||
|
CreateShippingOptionDTO,
|
||||||
|
FulfillmentDTO,
|
||||||
|
FulfillmentItemDTO,
|
||||||
|
FulfillmentOption,
|
||||||
|
FulfillmentOrderDTO,
|
||||||
|
Logger,
|
||||||
|
ValidateFulfillmentDataContext,
|
||||||
|
} from "@medusajs/framework/types";
|
||||||
|
import { cancelGuides, createGuide, quoteShipment, toEnvioClickDaneCode } from "../../lib/envioclick";
|
||||||
|
import { DEFAULT_PACKAGE, FALLBACK_PRICE_COP, type EnvioClickOptions } from "./types";
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
logger: Logger;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contexto minimo compartido entre calculatePrice/createFulfillment --
|
||||||
|
// ambos necesitan lo mismo (id DIVIPOLA de la ciudad destino, guardado
|
||||||
|
// como metadata en la direccion por el checkout -- ver ADR 0009 y
|
||||||
|
// checkout-client.tsx del storefront) y el paquete agregado del pedido.
|
||||||
|
interface AddressLike {
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
address_1?: string | null;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destinationDaneCode(address: AddressLike | null | undefined): string | null {
|
||||||
|
const cityId = address?.metadata?.cityId;
|
||||||
|
return typeof cityId === "number" ? toEnvioClickDaneCode(cityId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrega el carrito/orden a UN solo paquete (EnvioClick solo acepta 1 por
|
||||||
|
// cotizacion/guia, confirmado en la doc real: "min 1, max 1"). Si algun
|
||||||
|
// item no tiene peso real cargado (hoy NINGUN producto lo tiene, ver
|
||||||
|
// plan de implementacion), se usa el paquete de fallback conservador
|
||||||
|
// completo en vez de mezclar datos reales con inventados a medias.
|
||||||
|
function aggregatePackage(items: Array<{ quantity: number; variant?: { weight?: number | null } | null }>) {
|
||||||
|
if (items.length === 0) return { ...DEFAULT_PACKAGE };
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const weight = item.variant?.weight;
|
||||||
|
if (!weight) return { ...DEFAULT_PACKAGE };
|
||||||
|
totalWeight += weight * item.quantity;
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_PACKAGE, weight: totalWeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class EnvioClickFulfillmentService extends AbstractFulfillmentProviderService {
|
||||||
|
static identifier = "envioclick";
|
||||||
|
|
||||||
|
protected logger_: Logger;
|
||||||
|
protected options_: EnvioClickOptions;
|
||||||
|
|
||||||
|
constructor(container: InjectedDependencies, options: EnvioClickOptions) {
|
||||||
|
super();
|
||||||
|
this.logger_ = container.logger;
|
||||||
|
this.options_ = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateOptions(options: Record<string, unknown>): void {
|
||||||
|
for (const key of ["apiKey", "originDaneCode", "originAddress", "originCompany", "originFirstName", "originLastName", "originEmail", "originPhone"]) {
|
||||||
|
if (!options[key]) {
|
||||||
|
throw new Error(`envioclick-fulfillment: falta la opción "${key}" en medusa-config.ts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFulfillmentOptions(): Promise<FulfillmentOption[]> {
|
||||||
|
// Una sola opcion: siempre toma la tarifa mas barata entre las
|
||||||
|
// transportadoras que cotizan, mismo criterio que el plugin oficial
|
||||||
|
// de WooCommerce de referencia ("toma el precio mas economico").
|
||||||
|
return [{ id: "standard", name: "Envío nacional (calculado)" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateFulfillmentData(
|
||||||
|
optionData: Record<string, unknown>,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
_context: ValidateFulfillmentDataContext,
|
||||||
|
): Promise<any> {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateOption(_data: Record<string, unknown>): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async canCalculate(_data: CreateShippingOptionDTO): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se invoca en vivo durante el checkout cada vez que se piden/refrescan
|
||||||
|
// las shipping options del carrito. Medusa advierte explicitamente que
|
||||||
|
// un error acá bloquea el checkout entero -- por eso NUNCA se deja
|
||||||
|
// propagar una excepcion: cualquier falla (red, ciudad sin id DIVIPOLA,
|
||||||
|
// EnvioClick caido) cae al precio fijo que ya se usaba antes de esta
|
||||||
|
// integracion, para no romper la compra.
|
||||||
|
async calculatePrice(
|
||||||
|
optionData: CalculateShippingOptionPriceDTO["optionData"],
|
||||||
|
_data: CalculateShippingOptionPriceDTO["data"],
|
||||||
|
context: CalculateShippingOptionPriceContext,
|
||||||
|
): Promise<CalculatedShippingOptionPrice> {
|
||||||
|
const fallback: CalculatedShippingOptionPrice = {
|
||||||
|
calculated_amount: FALLBACK_PRICE_COP,
|
||||||
|
is_calculated_price_tax_inclusive: false,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const address = context.shipping_address as AddressLike | undefined;
|
||||||
|
const daneCode = destinationDaneCode(address);
|
||||||
|
if (!daneCode) {
|
||||||
|
this.logger_.warn("envioclick-fulfillment: dirección sin id DIVIPOLA en metadata, usando precio fijo de respaldo");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (context.items ?? []) as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
|
||||||
|
const pkg = aggregatePackage(items);
|
||||||
|
|
||||||
|
const rates = await quoteShipment(
|
||||||
|
{ apiKey: this.options_.apiKey },
|
||||||
|
{
|
||||||
|
pkg,
|
||||||
|
description: "Pedido ARI Shopping",
|
||||||
|
contentValue: 50000,
|
||||||
|
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
|
||||||
|
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rates.length === 0) return fallback;
|
||||||
|
|
||||||
|
const cheapest = rates.reduce((min, rate) =>
|
||||||
|
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculated_amount: cheapest.flete + cheapest.minimumInsurance,
|
||||||
|
is_calculated_price_tax_inclusive: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger_.warn(`envioclick-fulfillment: falló calculatePrice (${(error as Error).message}), usando precio fijo de respaldo`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A diferencia de calculatePrice, acá SÍ se deja propagar el error --
|
||||||
|
// el idRate de la cotización mostrada en checkout no se persiste en
|
||||||
|
// ningún lado (CalculatedShippingOptionPrice no tiene un campo `data`
|
||||||
|
// para eso, confirmado leyendo el tipo real), así que se vuelve a
|
||||||
|
// cotizar en el momento de generar la guía (más correcto igual: el
|
||||||
|
// fulfillment puede pasar horas/días después del checkout, un idRate
|
||||||
|
// viejo podría estar vencido) y se reserva con la tarifa más barata de
|
||||||
|
// esa cotización fresca.
|
||||||
|
async createFulfillment(
|
||||||
|
_data: Record<string, unknown>,
|
||||||
|
_items: Partial<Omit<FulfillmentItemDTO, "fulfillment">>[],
|
||||||
|
order: Partial<FulfillmentOrderDTO> | undefined,
|
||||||
|
_fulfillment: Partial<Omit<FulfillmentDTO, "provider_id" | "data" | "items">>,
|
||||||
|
): Promise<CreateFulfillmentResult> {
|
||||||
|
const address = order?.shipping_address as AddressLike | undefined;
|
||||||
|
const daneCode = destinationDaneCode(address);
|
||||||
|
if (!daneCode) {
|
||||||
|
throw new Error("envioclick-fulfillment: no se puede generar la guía sin un id DIVIPOLA en la dirección de envío.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (order?.items ?? []) as unknown as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
|
||||||
|
const pkg = aggregatePackage(items);
|
||||||
|
|
||||||
|
const rates = await quoteShipment(
|
||||||
|
{ apiKey: this.options_.apiKey },
|
||||||
|
{
|
||||||
|
pkg,
|
||||||
|
description: "Pedido ARI Shopping",
|
||||||
|
contentValue: 50000,
|
||||||
|
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
|
||||||
|
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (rates.length === 0) {
|
||||||
|
throw new Error("envioclick-fulfillment: no hay tarifas disponibles para generar la guía.");
|
||||||
|
}
|
||||||
|
const cheapest = rates.reduce((min, rate) =>
|
||||||
|
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
|
||||||
|
);
|
||||||
|
|
||||||
|
const guide = await createGuide(
|
||||||
|
{ apiKey: this.options_.apiKey },
|
||||||
|
this.options_.sandbox,
|
||||||
|
{
|
||||||
|
idRate: cheapest.idRate,
|
||||||
|
myShipmentReference: `Orden ${order?.display_id ?? order?.id ?? ""}`,
|
||||||
|
externalOrderId: String(order?.id ?? ""),
|
||||||
|
pkg,
|
||||||
|
description: "Pedido ARI Shopping",
|
||||||
|
contentValue: 50000,
|
||||||
|
origin: {
|
||||||
|
company: this.options_.originCompany,
|
||||||
|
firstName: this.options_.originFirstName,
|
||||||
|
lastName: this.options_.originLastName,
|
||||||
|
email: this.options_.originEmail,
|
||||||
|
phone: this.options_.originPhone,
|
||||||
|
address: this.options_.originAddress,
|
||||||
|
suburb: "N/A",
|
||||||
|
crossStreet: "N/A",
|
||||||
|
reference: "N/A",
|
||||||
|
daneCode: this.options_.originDaneCode,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
company: "N/A",
|
||||||
|
firstName: address?.first_name || "Cliente",
|
||||||
|
lastName: address?.last_name || "ARI",
|
||||||
|
email: address?.email || order?.email || "[email protected]",
|
||||||
|
phone: address?.phone || "0000000000",
|
||||||
|
address: address?.address_1 || "Dirección de entrega",
|
||||||
|
suburb: "N/A",
|
||||||
|
crossStreet: "N/A",
|
||||||
|
reference: "N/A",
|
||||||
|
daneCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
idOrder: guide.idOrder,
|
||||||
|
tracker: guide.tracker,
|
||||||
|
labelUrl: guide.url,
|
||||||
|
},
|
||||||
|
labels: [
|
||||||
|
{
|
||||||
|
tracking_number: guide.tracker,
|
||||||
|
tracking_url: guide.url,
|
||||||
|
label_url: guide.url,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelFulfillment(data: Record<string, unknown>): Promise<any> {
|
||||||
|
const idOrder = data.idOrder;
|
||||||
|
if (typeof idOrder !== "number") {
|
||||||
|
this.logger_.warn("envioclick-fulfillment: cancelFulfillment sin idOrder guardado, no hay nada que cancelar en EnvíoClick");
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
await cancelGuides({ apiKey: this.options_.apiKey }, [idOrder]);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFulfillmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReturnFulfillment(_fulfillment: Record<string, unknown>): Promise<CreateFulfillmentResult> {
|
||||||
|
// Devoluciones no estan en alcance de esta primera version.
|
||||||
|
throw new Error("envioclick-fulfillment: createReturnFulfillment no está implementado todavía.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReturnDocuments(_data: Record<string, unknown>): Promise<never[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShipmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveDocuments(_fulfillmentData: Record<string, unknown>, _documentType: string): Promise<void> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface EnvioClickOptions {
|
||||||
|
apiKey: string;
|
||||||
|
// El unico endpoint con modo de pruebas real es la generacion de guia
|
||||||
|
// (ver src/lib/envioclick.ts) -- la cotizacion siempre usa tarifas
|
||||||
|
// reales, no hay nada que alternar ahi.
|
||||||
|
sandbox: boolean;
|
||||||
|
// Datos de la bodega de origen -- la stock location real todavia no
|
||||||
|
// tiene direccion fisica cargada ("Sin dirección física registrada"),
|
||||||
|
// asi que estos vienen de config hasta que se cargue una real.
|
||||||
|
originDaneCode: string;
|
||||||
|
originAddress: string;
|
||||||
|
originCompany: string;
|
||||||
|
originFirstName: string;
|
||||||
|
originLastName: string;
|
||||||
|
originEmail: string;
|
||||||
|
originPhone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precio de fallback cuando EnvíoClick falla o no hay peso/dimensiones
|
||||||
|
// reales del pedido (hoy ningun producto los tiene cargados) -- mismo
|
||||||
|
// valor que ya usaba la shipping option fija de ADR 0009, para no
|
||||||
|
// empeorar la experiencia mientras no haya datos reales.
|
||||||
|
export const FALLBACK_PRICE_COP = 15000;
|
||||||
|
|
||||||
|
// Paquete por defecto cuando algun item del carrito no tiene
|
||||||
|
// peso/dimensiones reales -- conservador, no preciso. Ver nota en el
|
||||||
|
// plan de implementacion: esto es una tarea de datos, no de codigo.
|
||||||
|
export const DEFAULT_PACKAGE = {
|
||||||
|
weight: 1,
|
||||||
|
length: 20,
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
};
|
||||||
@@ -33,3 +33,18 @@ data:
|
|||||||
EPAYCO_ENV: sandbox
|
EPAYCO_ENV: sandbox
|
||||||
EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
|
EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
|
||||||
EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse
|
EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse
|
||||||
|
|
||||||
|
# EnvíoClick (ADR 0009 §2) -- fulfillment provider real por peso/volumen,
|
||||||
|
# reemplaza la tarifa fija. Solo config no sensible; ENVIOCLICK_API_KEY
|
||||||
|
# va en secrets.template.yaml. ENVIOCLICK_SANDBOX=true usa el endpoint
|
||||||
|
# de guía de pruebas (/shipment_sandbox) -- la cotización siempre usa
|
||||||
|
# tarifas reales, no tiene modo sandbox. Origen = bodega Bogotá
|
||||||
|
# ("Bodega principal ARI Shopping", sin dirección física real cargada
|
||||||
|
# todavía -- ENVIOCLICK_ORIGIN_ADDRESS es un placeholder hasta que la
|
||||||
|
# haya).
|
||||||
|
ENVIOCLICK_SANDBOX: "true"
|
||||||
|
ENVIOCLICK_ORIGIN_DANE_CODE: "11001000"
|
||||||
|
ENVIOCLICK_ORIGIN_ADDRESS: "Cra 100 #42-65"
|
||||||
|
ENVIOCLICK_ORIGIN_COMPANY: "ARI Shopping"
|
||||||
|
ENVIOCLICK_ORIGIN_EMAIL: "[email protected]"
|
||||||
|
ENVIOCLICK_ORIGIN_PHONE: "3014750823"
|
||||||
@@ -65,7 +65,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.233
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- node
|
- node
|
||||||
@@ -86,7 +86,7 @@ spec:
|
|||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.233
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ stringData:
|
|||||||
EPAYCO_PRIVATE_KEY: CAMBIAR
|
EPAYCO_PRIVATE_KEY: CAMBIAR
|
||||||
EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR
|
EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR
|
||||||
EPAYCO_P_KEY: CAMBIAR
|
EPAYCO_P_KEY: CAMBIAR
|
||||||
|
|
||||||
|
# EnvíoClick (ADR 0009 §2) -- API key real de la cuenta
|
||||||
|
# (envioclickpro.com.co > API Key / Desarrolladores).
|
||||||
|
ENVIOCLICK_API_KEY: CAMBIAR
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ export function CheckoutClient() {
|
|||||||
address1: form.address1,
|
address1: form.address1,
|
||||||
city: selectedCiudad.name,
|
city: selectedCiudad.name,
|
||||||
province: selectedDepartamento.name,
|
province: selectedDepartamento.name,
|
||||||
|
// ids DIVIPOLA reales -- EnvioClick (fulfillment provider) los
|
||||||
|
// necesita para cotizar sin ambiguedad, el nombre solo no
|
||||||
|
// alcanza (hay municipios con el mismo nombre en distintos
|
||||||
|
// departamentos).
|
||||||
|
cityId: selectedCiudad.id,
|
||||||
|
departmentId: selectedDepartamento.id,
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.230
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.232
|
||||||
env:
|
env:
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export interface ShippingAddressInput {
|
|||||||
province?: string;
|
province?: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
// ids DIVIPOLA/DANE reales (lib/data/colombia.ts) -- se guardan en la
|
||||||
|
// metadata de la direccion porque Medusa no tiene un campo propio para
|
||||||
|
// esto. EnvioClick (fulfillment provider real, ver ADR 0009 §2) los
|
||||||
|
// necesita para cotizar/generar guia sin ambiguedad -- el nombre de
|
||||||
|
// ciudad solo no alcanza (hay nombres de municipio repetidos entre
|
||||||
|
// distintos departamentos en Colombia).
|
||||||
|
cityId?: number;
|
||||||
|
departmentId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShippingOption {
|
export interface ShippingOption {
|
||||||
@@ -212,6 +220,10 @@ export async function setShippingAddress(
|
|||||||
province: address.province || undefined,
|
province: address.province || undefined,
|
||||||
country_code: "co",
|
country_code: "co",
|
||||||
phone: address.phone,
|
phone: address.phone,
|
||||||
|
metadata:
|
||||||
|
address.cityId || address.departmentId
|
||||||
|
? { cityId: address.cityId, departmentId: address.departmentId }
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user