Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/apps/bling-erp/src/integration/after-bling-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ export default async (
payload: any,
) => {
const isError = payload instanceof Error;
if (isError && (payload as any).isConcurrentExport) {
/* Outra execução segura o lock de exportação deste pedido por tempo demais.
Rejeitar faz o evento ser reentregue (`failurePolicy`) também na exportação
automática (fora da fila), e a nova tentativa acha o pedido exportado ou assume
o lock, em vez de terminar como falso sucesso sem nada no Bling. */
logger.warn(payload.message);
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(payload);
}, 2000);
});
}
const isImportation = !!queueEntry.action?.endsWith('importation');
const isQueued = !queueEntry.isNotQueued;
const logs = appData.logs || [];
Expand Down
165 changes: 108 additions & 57 deletions packages/apps/bling-erp/src/integration/export-order-to-bling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { URLSearchParams } from 'url';
import ecomUtils from '@ecomplus/utils';
import api from '@cloudcommerce/api';
import { logger } from '@cloudcommerce/firebase/lib/config';
import {
type ExportLock,
acquireExportLock,
} from '@cloudcommerce/firebase/lib/helpers/export-lock';
import { createBlingClient } from '../bling-auth/client';
import parseOrder from './parsers/order-to-bling';
import parseStatusToBling from './parsers/status-to-bling';
Expand All @@ -11,6 +15,14 @@ import getProductsBling from './helpers/get-products-bling';
import { getPaymentBling } from './helpers/payment-method';
import getStatusBling from './helpers/get-status-bling';

const EXPORT_LOCKS_COLLECTION = 'blingExportLocks';
/* Maior que o timeout de 300s do `onStoreEvent`: uma execução lenta, mas viva, não
pode ter o lock tomado no meio da criação. */
const EXPORT_LOCK_STALE_MS = 1000 * 60 * 6;
/* Depois de pegar o lock o vencedor ainda busca contato, forma de pagamento e
produtos antes do POST, com o client limitado a ~1 req/s. */
const CONCURRENT_EXPORT_WAIT_MS = 1000 * 60;

/*
`canCreateNew` is a tri-state:
- `true`: always create the order on Bling when not found;
Expand Down Expand Up @@ -148,69 +160,108 @@ const exportOrderToBling = async (
: String(order.number);
}

const customerIdBling = await getCustomerBling(bling, appData, order);
if (!customerIdBling) {
throw new Error('Bling Customer not found');
/* Criar não é idempotente: o mesmo pedido recebe um evento no checkout e outro
no pagamento, e as duas execuções podem rodar ao mesmo tempo. As duas buscaram
no Bling antes de qualquer uma gravar, então sem o lock as duas criam. */
let exportLock: ExportLock | undefined;
let isExportedConcurrently = false;
if (!blingOrderId) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sem teste automatizado do lock. A validação no emulador não foi commitada, e os 84 testes do bling-erp não passam por nenhuma linha nova. Os caminhos de takeover, liberação e espera do perdedor são justamente os que quase nunca rodam em produção.

const lockResult = await acquireExportLock(EXPORT_LOCKS_COLLECTION, orderId, {
staleLockMs: EXPORT_LOCK_STALE_MS,
maxWaitMs: CONCURRENT_EXPORT_WAIT_MS,
});
if (lockResult.isLocked) {
exportLock = lockResult;
} else {
/* Criado por outra execução enquanto esta esperava. O id vem do lock porque
a busca acima não acha pedido com número aleatório nem com o `apiDoc`
lido antes de o vencedor gravar os metafields. */
isExportedConcurrently = true;
blingOrderId = lockResult.exported.id;
logger.info(`${orderId} exported by a concurrent execution`, { blingOrderId });
}
}
/* Buscado só quando o pedido vai ser criado/atualizado de fato: mudança de
status de pedido já exportado não usa forma de pagamento nem itens. */
const paymentTypeId = transaction
? await getPaymentBling(bling, transaction, appData.parse_payment, order.payment_method_label)
: null;
const itemsBling = await getProductsBling(bling, order);
const blingOrder = parseOrder(
order,
blingOrderNumber,
blingStore,
appData,
customerIdBling,
paymentTypeId,
itemsBling,
blingSavedOrder,
);
const endpoint = `/pedidos/vendas${blingOrderId ? `/${blingOrderId}` : ''}`;
const method = blingOrderId ? 'put' : 'post';
logger.info(`[${method}]: ${endpoint} for ${order._id}`, { blingOrder, blingStatuses });
try {
const { data: { data: savedData } } = await bling[method](endpoint, blingOrder);
logger.info(`Bling order ${method === 'put' ? 'upd' : 'cre'}ated successfully`);
if (savedData?.id) {
blingSavedOrder = blingOrder;
blingOrderId = savedData.id;
if (metafieldId) {
metafieldId.value = String(blingOrderId);
} else {
metafields.push({
_id: ecomUtils.randomObjectId(),
namespace: 'bling',
field: 'bling:id',
value: String(blingOrderId),
});

if (!isExportedConcurrently) {
let blingOrder: Record<string, any> | undefined;
try {
const customerIdBling = await getCustomerBling(bling, appData, order);
if (!customerIdBling) {
throw new Error('Bling Customer not found');
}
/* Guardado para a busca por número funcionar quando o número do Bling
não é o da loja (`random_order_number` ou colisão de numeração). */
if (
blingOrderNumber
&& !appData.disable_order_number
&& !metafields.some(({ field }) => field === 'bling:numero')
) {
metafields.push({
_id: ecomUtils.randomObjectId(),
namespace: 'bling',
field: 'bling:numero',
value: String(blingOrderNumber),
/* Buscado só quando o pedido vai ser criado/atualizado de fato: mudança de
status de pedido já exportado não usa forma de pagamento nem itens. */
const paymentTypeId = transaction
? await getPaymentBling(
bling,
transaction,
appData.parse_payment,
order.payment_method_label,
)
: null;
const itemsBling = await getProductsBling(bling, order);
blingOrder = parseOrder(
order,
blingOrderNumber,
blingStore,
appData,
customerIdBling,
paymentTypeId,
itemsBling,
blingSavedOrder,
);
const endpoint = `/pedidos/vendas${blingOrderId ? `/${blingOrderId}` : ''}`;
const method = blingOrderId ? 'put' : 'post';
logger.info(`[${method}]: ${endpoint} for ${order._id}`, { blingOrder, blingStatuses });
exportLock?.setSending();
const { data: { data: savedData } } = await bling[method](endpoint, blingOrder);
logger.info(`Bling order ${method === 'put' ? 'upd' : 'cre'}ated successfully`);
if (savedData?.id) {
blingSavedOrder = blingOrder;
blingOrderId = savedData.id;
if (metafieldId) {
metafieldId.value = String(blingOrderId);
} else {
metafields.push({
_id: ecomUtils.randomObjectId(),
namespace: 'bling',
field: 'bling:id',
value: String(blingOrderId),
});
}
/* Guardado para a busca por número funcionar quando o número do Bling
não é o da loja (`random_order_number` ou colisão de numeração). */
if (
blingOrderNumber
&& !appData.disable_order_number
&& !metafields.some(({ field }) => field === 'bling:numero')
) {
metafields.push({
_id: ecomUtils.randomObjectId(),
namespace: 'bling',
field: 'bling:numero',
value: String(blingOrderNumber),
});
}
await api.patch(`orders/${order._id}`, { metafields } as any).catch(logger.error);
}
} catch (err: any) {
/* O lock é liberado para a próxima tentativa quando o pedido certamente não
foi criado: falha antes do POST (contato, forma de pagamento, produtos) ou
erro respondido pelo Bling. Sem resposta (timeout) o pedido pode ter sido
criado, e o lock fica até expirar. */
await exportLock?.fail(err);
if (err.response) {
logger.warn(`Failed exporting order ${order._id}`, {
blingOrder,
response: err.response.data,
});
}
await api.patch(`orders/${order._id}`, { metafields } as any).catch(logger.error);
throw err;
}
} catch (err: any) {
if (err.response) {
logger.warn(`Failed exporting order ${order._id}`, {
blingOrder,
response: err.response.data,
});
if (exportLock && blingOrderId) {
await exportLock.complete({ id: blingOrderId, numero: blingOrderNumber });
}
throw err;
}
}

Expand Down
12 changes: 12 additions & 0 deletions packages/apps/tiny-erp/src/integration/after-tiny-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ export default async (
payload: any,
) => {
const isError = payload instanceof Error;
if (isError && (payload as any).isConcurrentExport) {
/* Another execution holds the export lock of this order for too long. Rejecting
gets the event redelivered (`failurePolicy`) even for automatic exports (not
queued), and the retry finds the order exported or takes over the lock, instead
of ending as a false success with nothing sent to Tiny. */
logger.warn(payload.message);
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(payload);
}, 2000);
});
}
const isImportation = !!queueEntry.action?.endsWith('importation');
const isQueued = !queueEntry.isNotQueued;
const logs = appData.logs || [];
Expand Down
115 changes: 78 additions & 37 deletions packages/apps/tiny-erp/src/integration/export-order-to-tiny.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import type { Orders } from '@cloudcommerce/types';
import { logger } from '@cloudcommerce/firebase/lib/config';
import { acquireExportLock } from '@cloudcommerce/firebase/lib/helpers/export-lock';
import api from '@cloudcommerce/api';
import postTiny from './post-tiny-erp';
import parseStatusToTiny from './parsers/status-to-tiny';
import parseTinyStatus from './parsers/status-from-tiny';
import parseOrder from './parsers/order-to-tiny';

const EXPORT_LOCKS_COLLECTION = 'tinyErpExportLocks';
/* Longer than anything holding the lock can live: the events function times out
in 60s, and the cron holds it for a single order (the POST times out in 30s). */
const EXPORT_LOCK_STALE_MS = 1000 * 60 * 2;
const CONCURRENT_EXPORT_WAIT_MS = 20000;

/* `pedido.incluir.php` returns the created order in `registros.registro` */
const getCreatedTinyId = (response: Record<string, any>) => {
const { registros } = response;
const registro = (Array.isArray(registros) ? registros[0] : registros)?.registro;
return registro?.id as string | number | undefined;
};

export default async (apiDoc, queueEntry, appData, canCreateNew) => {
const orderId = queueEntry.nextId;
let order: Orders;
Expand Down Expand Up @@ -52,39 +66,38 @@ export default async (apiDoc, queueEntry, appData, canCreateNew) => {
logger.info(`${orderId} skipped with last fulfillment status from Tiny`);
return null;
}
logger.info(`${orderId} searching order ${order.number}`);

let tinyData: { pedidos?: any };
try {
tinyData = await postTiny('/pedidos.pesquisa.php', {
numeroEcommerce: String(order.number),
});
} catch (err: any) {
const status = err.response && err.response.status;
if (status === 404) {
tinyData = {};
} else {
logger.info(`${orderId} search on tiny ends with status ${status}`);
if (!status) {
logger.error(err);
const searchTinyOrder = async () => {
logger.info(`${orderId} searching order ${order.number}`);
let tinyData: { pedidos?: any };
try {
tinyData = await postTiny('/pedidos.pesquisa.php', {
numeroEcommerce: String(order.number),
});
} catch (err: any) {
const status = err.response && err.response.status;
if (status === 404) {
tinyData = {};
} else {
logger.info(`${orderId} search on tiny ends with status ${status}`);
if (!status) {
logger.error(err);
}
throw err;
}
throw err;
}
}
const { pedidos } = tinyData;
let originalTinyOrder: undefined | Record<string, any>;
if (Array.isArray(pedidos)) {
originalTinyOrder = pedidos
const { pedidos } = tinyData;
if (!Array.isArray(pedidos)) return undefined;
const foundEntry = pedidos
.sort((a: { pedido: Record<string, any> }, b: { pedido: Record<string, any> }) => {
return a.pedido.id < b.pedido.id ? 1 : -1;
})
.find(({ pedido }) => {
return order.number === Number(pedido.numero_ecommerce);
});
if (originalTinyOrder) {
originalTinyOrder = originalTinyOrder.pedido;
}
}
return foundEntry?.pedido as undefined | Record<string, any>;
};

let originalTinyOrder = await searchTinyOrder();

if (!originalTinyOrder) {
if (!canCreateNew) {
Expand Down Expand Up @@ -113,24 +126,52 @@ export default async (apiDoc, queueEntry, appData, canCreateNew) => {
break;
}
}
const tinyOrder = await parseOrder(order, appData);
try {
logger.info(`Posting ${orderId}`, { tinyOrder });
} catch {
logger.info(`Posting ${orderId} (stringify failed)`);
}
return postTiny('/pedido.incluir.php', {
pedido: {
pedido: tinyOrder,
},
}).then((response) => {
const exportLock = await acquireExportLock(EXPORT_LOCKS_COLLECTION, orderId, {
staleLockMs: EXPORT_LOCK_STALE_MS,
maxWaitMs: queueEntry.concurrentExportWaitMs ?? CONCURRENT_EXPORT_WAIT_MS,
});
if (!exportLock.isLocked) {
/* Created by a concurrent execution while this one waited, the search above
could not see it: falls through to the status update on that order. */
const { exported } = exportLock;
originalTinyOrder = exported.id
? { id: exported.id, situacao: exported.situacao }
: await searchTinyOrder();
if (!originalTinyOrder) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quem perde o lock desiste quando o POST do vencedor falha. O perdedor procura o pedido uma única vez, 5 s depois. Se o POST do vencedor falha (código 6 do Tiny, que vira 503, ou 5xx do Bling), o vencedor libera o lock e lança erro. Só que o perdedor já saiu, e evento de pedido é isNotQueued, então o afterQueue engole o erro e ninguém tenta de novo.

Resultado: nenhuma das duas execuções cria o pedido. No Tiny ele só volta pelo cron de 3 em 3 h, e só se estiver pago. No Bling ele não volta.

Sugestão: o perdedor deve acompanhar o estado do lock, e não do ERP. Se o vencedor liberar o lock, o perdedor assume e cria o pedido.

logger.warn(`${orderId} exported by a concurrent execution but not found`);
return null;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reexportação manual pulada vira sucesso. Na fila manual (applications-dataSet, canCreateNew: true), este return null chega ao afterQueue como success: true, sem notas.

Isso acontece quando o lojista apaga o pedido no ERP e reenfileira, ou quando uma tentativa anterior morreu com o lock preso. O painel mostra sucesso e nada foi criado. Aqui o retorno precisa ser um erro.

}
} else {
let tinyOrder: Record<string, any>;
let response: Record<string, any>;
try {
tinyOrder = await parseOrder(order, appData);
try {
logger.info(`Posting ${orderId}`, { tinyOrder });
} catch {
logger.info(`Posting ${orderId} (stringify failed)`);
}
exportLock.setSending();
response = await postTiny('/pedido.incluir.php', {
pedido: {
pedido: tinyOrder,
},
});
} catch (err) {
await exportLock.fail(err);
throw err;
}
logger.info(`Created ${orderId} on Tiny`, { response });
await exportLock.complete({
id: getCreatedTinyId(response),
situacao: tinyOrder.situacao,
});
const tinyErpOnNewOrder = global.$tinyErpOnNewOrder;
if (tinyErpOnNewOrder && typeof tinyErpOnNewOrder === 'function') {
return tinyErpOnNewOrder({ response, order, postTiny });
}
return response;
});
}
}

if (originalTinyOrder) {
Expand Down
Loading
Loading