Montify: robust pagination (beyond 50), endpoint diagnose button
This commit is contained in:
@@ -7,6 +7,8 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import type {
|
import type {
|
||||||
CreateMontifyConnectionRequest,
|
CreateMontifyConnectionRequest,
|
||||||
MontifyConnectionDto,
|
MontifyConnectionDto,
|
||||||
|
MontifyDiagnoseCheckDto,
|
||||||
|
MontifyDiagnoseDto,
|
||||||
MontifySyncResultDto,
|
MontifySyncResultDto,
|
||||||
MontifySyncStatus,
|
MontifySyncStatus,
|
||||||
MontifyTestResultDto,
|
MontifyTestResultDto,
|
||||||
@@ -171,7 +173,7 @@ export class MontifyConnectionsService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: { lastHealthAt: new Date(), lastHealthOk: true, lastError: null },
|
data: { lastHealthAt: new Date(), lastHealthOk: true, lastError: null },
|
||||||
});
|
});
|
||||||
return { ok: true, totalCustomers: Number.isFinite(page.meta.total) ? page.meta.total : null };
|
return { ok: true, totalCustomers: page.meta.total || null };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'خطای ناشناخته';
|
const msg = e instanceof Error ? e.message : 'خطای ناشناخته';
|
||||||
await this.prisma.montifyConnection.update({
|
await this.prisma.montifyConnection.update({
|
||||||
@@ -182,6 +184,48 @@ export class MontifyConnectionsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** همهٔ مسیرهای مستند را با توکن همین فروشگاه صدا میزند و وضعیت هر کدام را برمیگرداند */
|
||||||
|
async diagnose(organizationId: string, id: string): Promise<MontifyDiagnoseDto> {
|
||||||
|
const row = await this.getOrThrow(organizationId, id);
|
||||||
|
const client = this.clientFor(row);
|
||||||
|
const checks: MontifyDiagnoseCheckDto[] = [];
|
||||||
|
const describe = (status: number, body: unknown, text: string) => {
|
||||||
|
if (status >= 200 && status < 300) return MontifyClient.describePage(body);
|
||||||
|
return text.replace(/\s+/g, ' ').slice(0, 200) || 'بدون پاسخ';
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async (name: string, path: string) => {
|
||||||
|
const r = await client.probe(path);
|
||||||
|
checks.push({
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
status: r.status,
|
||||||
|
ok: r.status >= 200 && r.status < 300,
|
||||||
|
info: describe(r.status, r.body, r.text),
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
const p1 = await run('لیست مشتریان (صفحه ۱)', '/montact/customers?page=1&limit=50');
|
||||||
|
await run('لیست مشتریان (صفحه ۲)', '/montact/customers?page=2&limit=50');
|
||||||
|
|
||||||
|
const first = (() => {
|
||||||
|
const b = p1.body as { data?: Array<{ id?: unknown }> } | null;
|
||||||
|
const list = Array.isArray(b) ? b : b?.data;
|
||||||
|
return Array.isArray(list) && list[0]?.id !== undefined ? String(list[0].id) : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
const c = encodeURIComponent(first);
|
||||||
|
await run('جزئیات مشتری', `/montact/customers/${c}`);
|
||||||
|
await run('سفارشهای مشتری', `/montact/customers/${c}/orders?page=1&limit=50`);
|
||||||
|
await run('آدرسهای مشتری', `/montact/customers/${c}/addresses?page=1&limit=50`);
|
||||||
|
await run('تراکنشهای مشتری', `/montact/customers/${c}/transactions?page=1&limit=50`);
|
||||||
|
await run('تعاملات مشتری', `/montact/customers/${c}/engagement`);
|
||||||
|
}
|
||||||
|
return { customerId: first, checks };
|
||||||
|
}
|
||||||
|
|
||||||
webhookUrl(id: string): string {
|
webhookUrl(id: string): string {
|
||||||
return `${this.publicApiBaseUrl()}/public/integrations/montify/${id}/webhook`;
|
return `${this.publicApiBaseUrl()}/public/integrations/montify/${id}/webhook`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,26 +136,73 @@ function unwrap<T>(body: unknown): T {
|
|||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstNumber(...values: unknown[]): number | null {
|
||||||
|
for (const v of values) {
|
||||||
|
if (v === null || v === undefined || v === '') continue;
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** meta ناشناخته = 0؛ در آن صفحهها تا رسیدن به صفحهٔ خالی خوانده میشوند */
|
||||||
function toPage<T>(body: unknown): MontifyPage<T> {
|
function toPage<T>(body: unknown): MontifyPage<T> {
|
||||||
if (Array.isArray(body)) {
|
if (Array.isArray(body)) {
|
||||||
return {
|
return {
|
||||||
data: body as T[],
|
data: body as T[],
|
||||||
meta: { current_page: 1, per_page: body.length, total: body.length, last_page: 1 },
|
meta: { current_page: 0, per_page: 0, total: 0, last_page: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const b = (body ?? {}) as Partial<MontifyPage<T>>;
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
const data = Array.isArray(b.data) ? b.data : [];
|
const rawData = b.data ?? b.items ?? b.results;
|
||||||
|
const data = (Array.isArray(rawData) ? rawData : []) as T[];
|
||||||
|
const m = ((b.meta ?? b.pagination ?? b) || {}) as Record<string, unknown>;
|
||||||
|
const perPage = firstNumber(m.per_page, m.perPage, m.limit) ?? 0;
|
||||||
|
const total = firstNumber(m.total, m.totalItems, m.total_count, m.count) ?? 0;
|
||||||
|
const lastPage =
|
||||||
|
firstNumber(m.last_page, m.lastPage, m.total_pages, m.totalPages, m.pages) ??
|
||||||
|
(total && perPage ? Math.ceil(total / perPage) : 0);
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
current_page: Number(b.meta?.current_page ?? 1),
|
current_page: firstNumber(m.current_page, m.currentPage, m.page) ?? 0,
|
||||||
per_page: Number(b.meta?.per_page ?? data.length),
|
per_page: perPage,
|
||||||
total: Number(b.meta?.total ?? data.length),
|
total,
|
||||||
last_page: Number(b.meta?.last_page ?? 1),
|
last_page: lastPage,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همهٔ صفحهها را میخواند. به meta اعتماد کامل نمیکند: تا وقتی total بیشتر از خواندهشدههاست
|
||||||
|
* یا meta نامعلوم است ادامه میدهد؛ صفحهٔ خالی یا تکراری (نادیده گرفتن page) پایان است.
|
||||||
|
*/
|
||||||
|
export async function forEachPage<T>(
|
||||||
|
fetchPage: (page: number) => Promise<MontifyPage<T>>,
|
||||||
|
onPage: (res: MontifyPage<T>, page: number) => Promise<void>,
|
||||||
|
maxPages = 2000,
|
||||||
|
): Promise<void> {
|
||||||
|
let fetched = 0;
|
||||||
|
let prevSignature: string | null = null;
|
||||||
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
|
const res = await fetchPage(page);
|
||||||
|
if (!res.data.length) break;
|
||||||
|
const signature = JSON.stringify(
|
||||||
|
res.data.map((d) => (d && typeof d === 'object' && 'id' in d ? (d as { id: unknown }).id : d)),
|
||||||
|
);
|
||||||
|
if (signature === prevSignature) break;
|
||||||
|
prevSignature = signature;
|
||||||
|
fetched += res.data.length;
|
||||||
|
await onPage(res, page);
|
||||||
|
const { total, last_page: lastPage } = res.meta;
|
||||||
|
if (total > 0) {
|
||||||
|
if (fetched >= total) break;
|
||||||
|
} else if (lastPage > 0 && page >= lastPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MontifyClient {
|
export class MontifyClient {
|
||||||
private readonly base: string;
|
private readonly base: string;
|
||||||
private readonly throttleKey: string;
|
private readonly throttleKey: string;
|
||||||
@@ -219,14 +266,53 @@ export class MontifyClient {
|
|||||||
/** این مسیرها صفحهبندی دارند ولی updated_after ندارند */
|
/** این مسیرها صفحهبندی دارند ولی updated_after ندارند */
|
||||||
private async allPages<T>(path: string, maxPages = 100): Promise<T[]> {
|
private async allPages<T>(path: string, maxPages = 100): Promise<T[]> {
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
for (let page = 1; page <= maxPages; page += 1) {
|
await forEachPage<T>(
|
||||||
const res = toPage<T>(await this.request('GET', `${path}?page=${page}&limit=50`));
|
async (page) => toPage<T>(await this.request('GET', `${path}?page=${page}&limit=50`)),
|
||||||
out.push(...res.data);
|
async (res) => {
|
||||||
if (!res.data.length || page >= res.meta.last_page) break;
|
out.push(...res.data);
|
||||||
}
|
},
|
||||||
|
maxPages,
|
||||||
|
);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** برای عیبیابی: درخواست خام بدون پرتاب خطا */
|
||||||
|
async probe(path: string): Promise<{ status: number; body: unknown; text: string }> {
|
||||||
|
await this.throttle();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.base}${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'MontactCRM/1.0',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
return { status: res.status, body, text };
|
||||||
|
} catch (e) {
|
||||||
|
return { status: 0, body: null, text: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static describePage(body: unknown): string {
|
||||||
|
const p = toPage<unknown>(body);
|
||||||
|
const keys = body && typeof body === 'object' && !Array.isArray(body) ? Object.keys(body) : [];
|
||||||
|
return (
|
||||||
|
`${p.data.length} رکورد` +
|
||||||
|
(p.meta.total ? ` · total=${p.meta.total}` : '') +
|
||||||
|
(p.meta.per_page ? ` · per_page=${p.meta.per_page}` : '') +
|
||||||
|
(p.meta.last_page ? ` · last_page=${p.meta.last_page}` : ' · meta نامعلوم') +
|
||||||
|
(keys.length ? ` · کلیدها: ${keys.join(', ')}` : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getCustomerEngagement(customerId: string | number): Promise<MontifyEngagement> {
|
async getCustomerEngagement(customerId: string | number): Promise<MontifyEngagement> {
|
||||||
return (
|
return (
|
||||||
unwrap<MontifyEngagement>(
|
unwrap<MontifyEngagement>(
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export class MontifyController {
|
|||||||
return this.connections.testConnection(user.organizationId, id);
|
return this.connections.testConnection(user.organizationId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('connections/:id/diagnose')
|
||||||
|
diagnose(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
|
||||||
|
return this.connections.diagnose(user.organizationId, id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('connections/:id/sync')
|
@Post('connections/:id/sync')
|
||||||
syncNow(
|
syncNow(
|
||||||
@CurrentUser() user: { organizationId: string },
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import {
|
import {
|
||||||
Prisma,
|
Prisma,
|
||||||
@@ -26,6 +21,7 @@ import { carryPreviousSource } from '../../contacts/person-source';
|
|||||||
import { ownerWhere, type Actor } from '../../common/record-scope';
|
import { ownerWhere, type Actor } from '../../common/record-scope';
|
||||||
import { MontifyConnectionsService } from './connections.service';
|
import { MontifyConnectionsService } from './connections.service';
|
||||||
import {
|
import {
|
||||||
|
forEachPage,
|
||||||
MontifyClient,
|
MontifyClient,
|
||||||
MontifyHttpError,
|
MontifyHttpError,
|
||||||
type MontifyAddress,
|
type MontifyAddress,
|
||||||
@@ -56,7 +52,11 @@ type CustomerProfile = {
|
|||||||
goldWalletBalance: number | null;
|
goldWalletBalance: number | null;
|
||||||
summary: { totalOrders: number | null; totalSpent: number | null };
|
summary: { totalOrders: number | null; totalSpent: number | null };
|
||||||
addresses: MontifyAddress[];
|
addresses: MontifyAddress[];
|
||||||
engagement: { saved: unknown[]; likes: unknown[]; comments: unknown[] } | null;
|
engagement: {
|
||||||
|
saved: unknown[];
|
||||||
|
likes: unknown[];
|
||||||
|
comments: unknown[];
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function num(v: unknown): number | null {
|
function num(v: unknown): number | null {
|
||||||
@@ -119,7 +119,10 @@ function formatAddress(a: MontifyAddress): string | null {
|
|||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitName(c: MontifyCustomer): { firstName: string; lastName: string } {
|
function splitName(c: MontifyCustomer): {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
} {
|
||||||
let firstName = str(c.first_name) ?? '';
|
let firstName = str(c.first_name) ?? '';
|
||||||
let lastName = str(c.last_name) ?? '';
|
let lastName = str(c.last_name) ?? '';
|
||||||
if (!firstName && !lastName) {
|
if (!firstName && !lastName) {
|
||||||
@@ -137,7 +140,11 @@ function splitName(c: MontifyCustomer): { firstName: string; lastName: string }
|
|||||||
|
|
||||||
function customerLinkKey(connectionId: string, externalId: string) {
|
function customerLinkKey(connectionId: string, externalId: string) {
|
||||||
return {
|
return {
|
||||||
connectionId_entityType_externalId: { connectionId, entityType: 'customer', externalId },
|
connectionId_entityType_externalId: {
|
||||||
|
connectionId,
|
||||||
|
entityType: 'customer',
|
||||||
|
externalId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,35 +241,46 @@ export class MontifySyncService {
|
|||||||
mode === 'incremental' && row.customersCursor
|
mode === 'incremental' && row.customersCursor
|
||||||
? row.customersCursor.toISOString()
|
? row.customersCursor.toISOString()
|
||||||
: undefined;
|
: undefined;
|
||||||
let page = 1;
|
this.logger.log(
|
||||||
let lastPage = 1;
|
`Montify sync ${row.id} (${row.label}) start ${mode}${updatedAfter ? ` after ${updatedAfter}` : ''}`,
|
||||||
this.logger.log(`Montify sync ${row.id} (${row.label}) start ${mode}${updatedAfter ? ` after ${updatedAfter}` : ''}`);
|
);
|
||||||
do {
|
await forEachPage(
|
||||||
const res = await client.listCustomers({ page, limit: CUSTOMER_PAGE_SIZE, updatedAfter });
|
(page) =>
|
||||||
lastPage = res.meta.last_page;
|
client.listCustomers({
|
||||||
this.logger.log(
|
page,
|
||||||
`Montify sync ${row.id} page ${page}/${lastPage}: ${res.data.length} customers (total ${res.meta.total})`,
|
limit: CUSTOMER_PAGE_SIZE,
|
||||||
);
|
updatedAfter,
|
||||||
for (const customer of res.data) {
|
}),
|
||||||
result.customersFetched += 1;
|
async (res, page) => {
|
||||||
try {
|
this.logger.log(
|
||||||
const outcome = await this.syncCustomerDeep(row, client, customer, mode, result, endpoints);
|
`Montify sync ${row.id} page ${page}/${res.meta.last_page || '?'}: ${res.data.length} customers (total ${res.meta.total || '?'})`,
|
||||||
if (outcome === 'created') result.customersCreated += 1;
|
);
|
||||||
else result.customersUpdated += 1;
|
for (const customer of res.data) {
|
||||||
const updatedAt = parseDate(customer.updated_at);
|
result.customersFetched += 1;
|
||||||
if (updatedAt && (!cursor || updatedAt > cursor)) cursor = updatedAt;
|
try {
|
||||||
} catch (e) {
|
const outcome = await this.syncCustomerDeep(
|
||||||
if (e instanceof MontifyHttpError && e.status === 401) throw e;
|
row,
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
client,
|
||||||
result.errors.push(`مشتری ${customer.id}: ${msg}`);
|
customer,
|
||||||
this.logger.warn(`Montify ${row.id} customer ${customer.id}: ${msg}`);
|
mode,
|
||||||
|
result,
|
||||||
|
endpoints,
|
||||||
|
);
|
||||||
|
if (outcome === 'created') result.customersCreated += 1;
|
||||||
|
else result.customersUpdated += 1;
|
||||||
|
const updatedAt = parseDate(customer.updated_at);
|
||||||
|
if (updatedAt && (!cursor || updatedAt > cursor)) cursor = updatedAt;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof MontifyHttpError && e.status === 401) throw e;
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
result.errors.push(`مشتری ${customer.id}: ${msg}`);
|
||||||
|
this.logger.warn(`Montify ${row.id} customer ${customer.id}: ${msg}`);
|
||||||
|
}
|
||||||
|
if (result.customersFetched % 5 === 0) await saveProgress();
|
||||||
}
|
}
|
||||||
if (result.customersFetched % 5 === 0) await saveProgress();
|
await saveProgress();
|
||||||
}
|
},
|
||||||
await saveProgress();
|
);
|
||||||
if (!res.data.length) break;
|
|
||||||
page += 1;
|
|
||||||
} while (page <= lastPage && page <= 2000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
fatal = e instanceof Error ? e.message : String(e);
|
fatal = e instanceof Error ? e.message : String(e);
|
||||||
result.errors.push(fatal);
|
result.errors.push(fatal);
|
||||||
@@ -354,19 +372,18 @@ export class MontifySyncService {
|
|||||||
const ordersStartedAt = new Date();
|
const ordersStartedAt = new Date();
|
||||||
const ordersOk = await step('سفارشها', async () => {
|
const ordersOk = await step('سفارشها', async () => {
|
||||||
const since = mode === 'full' ? null : ordersSyncedAt;
|
const since = mode === 'full' ? null : ordersSyncedAt;
|
||||||
let page = 1;
|
await forEachPage(
|
||||||
let lastPage = 1;
|
(page) =>
|
||||||
do {
|
client.listCustomerOrders(externalId, {
|
||||||
const res = await client.listCustomerOrders(externalId, {
|
page,
|
||||||
page,
|
updatedAfter: since ? new Date(since.getTime() - 60_000).toISOString() : undefined,
|
||||||
updatedAfter: since ? new Date(since.getTime() - 60_000).toISOString() : undefined,
|
}),
|
||||||
});
|
async (res) => {
|
||||||
lastPage = res.meta.last_page;
|
if (result) result.ordersFetched += res.data.length;
|
||||||
if (result) result.ordersFetched += res.data.length;
|
for (const order of res.data) await this.upsertOrder(row, order, personId, externalId);
|
||||||
for (const order of res.data) await this.upsertOrder(row, order, personId, externalId);
|
},
|
||||||
if (!res.data.length) break;
|
200,
|
||||||
page += 1;
|
);
|
||||||
} while (page <= lastPage && page <= 200);
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (ordersOk) {
|
if (ordersOk) {
|
||||||
@@ -392,7 +409,11 @@ export class MontifySyncService {
|
|||||||
c: MontifyCustomer,
|
c: MontifyCustomer,
|
||||||
addresses: MontifyAddress[],
|
addresses: MontifyAddress[],
|
||||||
engagement: MontifyEngagement | null,
|
engagement: MontifyEngagement | null,
|
||||||
): Promise<{ personId: string; outcome: 'created' | 'updated'; ordersSyncedAt: Date | null }> {
|
): Promise<{
|
||||||
|
personId: string;
|
||||||
|
outcome: 'created' | 'updated';
|
||||||
|
ordersSyncedAt: Date | null;
|
||||||
|
}> {
|
||||||
const externalId = String(c.id);
|
const externalId = String(c.id);
|
||||||
const { firstName, lastName } = splitName(c);
|
const { firstName, lastName } = splitName(c);
|
||||||
const mobile = normalizePhone(c.phone ?? null);
|
const mobile = normalizePhone(c.phone ?? null);
|
||||||
@@ -429,7 +450,10 @@ export class MontifySyncService {
|
|||||||
|
|
||||||
let person = existingLink
|
let person = existingLink
|
||||||
? await this.prisma.person.findFirst({
|
? await this.prisma.person.findFirst({
|
||||||
where: { id: existingLink.entityId, organizationId: row.organizationId },
|
where: {
|
||||||
|
id: existingLink.entityId,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -438,9 +462,7 @@ export class MontifySyncService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
OR: [
|
OR: [
|
||||||
...(mobile
|
...(mobile ? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }]) : []),
|
||||||
? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }])
|
|
||||||
: []),
|
|
||||||
...(email ? [{ email }] : []),
|
...(email ? [{ email }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -550,8 +572,7 @@ export class MontifySyncService {
|
|||||||
if (!o?.id) return;
|
if (!o?.id) return;
|
||||||
const externalId = String(o.id);
|
const externalId = String(o.id);
|
||||||
const customerExternalId = str(o.user_id) ?? customerHint;
|
const customerExternalId = str(o.user_id) ?? customerHint;
|
||||||
const personId =
|
const personId = personIdHint ?? (await this.personForCustomer(row.id, customerExternalId));
|
||||||
personIdHint ?? (await this.personForCustomer(row.id, customerExternalId));
|
|
||||||
const items = (o.order_items ?? []).map((i) => ({
|
const items = (o.order_items ?? []).map((i) => ({
|
||||||
id: str(i.id),
|
id: str(i.id),
|
||||||
productId: str(i.product_id),
|
productId: str(i.product_id),
|
||||||
@@ -592,7 +613,12 @@ export class MontifySyncService {
|
|||||||
};
|
};
|
||||||
await this.prisma.montifyOrder.upsert({
|
await this.prisma.montifyOrder.upsert({
|
||||||
where: { connectionId_externalId: { connectionId: row.id, externalId } },
|
where: { connectionId_externalId: { connectionId: row.id, externalId } },
|
||||||
create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
|
create: {
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
connectionId: row.id,
|
||||||
|
externalId,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
update: data,
|
update: data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -619,7 +645,12 @@ export class MontifySyncService {
|
|||||||
};
|
};
|
||||||
await this.prisma.montifyTransaction.upsert({
|
await this.prisma.montifyTransaction.upsert({
|
||||||
where: { connectionId_externalId: { connectionId: row.id, externalId } },
|
where: { connectionId_externalId: { connectionId: row.id, externalId } },
|
||||||
create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
|
create: {
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
connectionId: row.id,
|
||||||
|
externalId,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
update: data,
|
update: data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -691,12 +722,20 @@ export class MontifySyncService {
|
|||||||
|
|
||||||
async personStores(actor: Actor, personId: string): Promise<MontifyPersonStoreDto[]> {
|
async personStores(actor: Actor, personId: string): Promise<MontifyPersonStoreDto[]> {
|
||||||
const person = await this.prisma.person.findFirst({
|
const person = await this.prisma.person.findFirst({
|
||||||
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
where: {
|
||||||
|
id: personId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...ownerWhere(actor),
|
||||||
|
},
|
||||||
select: { id: true, firstName: true, lastName: true },
|
select: { id: true, firstName: true, lastName: true },
|
||||||
});
|
});
|
||||||
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
||||||
const links = await this.prisma.montifyLink.findMany({
|
const links = await this.prisma.montifyLink.findMany({
|
||||||
where: { organizationId: actor.organizationId, entityType: 'customer', entityId: personId },
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
entityType: 'customer',
|
||||||
|
entityId: personId,
|
||||||
|
},
|
||||||
include: { connection: { select: { label: true } } },
|
include: { connection: { select: { label: true } } },
|
||||||
orderBy: { syncedAt: 'desc' },
|
orderBy: { syncedAt: 'desc' },
|
||||||
});
|
});
|
||||||
@@ -705,12 +744,18 @@ export class MontifySyncService {
|
|||||||
for (const l of links) {
|
for (const l of links) {
|
||||||
const [orders, transactions] = await Promise.all([
|
const [orders, transactions] = await Promise.all([
|
||||||
this.prisma.montifyOrder.findMany({
|
this.prisma.montifyOrder.findMany({
|
||||||
where: { connectionId: l.connectionId, customerExternalId: l.externalId },
|
where: {
|
||||||
|
connectionId: l.connectionId,
|
||||||
|
customerExternalId: l.externalId,
|
||||||
|
},
|
||||||
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
|
||||||
take: 20,
|
take: 20,
|
||||||
}),
|
}),
|
||||||
this.prisma.montifyTransaction.findMany({
|
this.prisma.montifyTransaction.findMany({
|
||||||
where: { connectionId: l.connectionId, customerExternalId: l.externalId },
|
where: {
|
||||||
|
connectionId: l.connectionId,
|
||||||
|
customerExternalId: l.externalId,
|
||||||
|
},
|
||||||
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
|
||||||
take: 20,
|
take: 20,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from '@/lib/api';
|
|||||||
import type {
|
import type {
|
||||||
CreateMontifyConnectionRequest,
|
CreateMontifyConnectionRequest,
|
||||||
MontifyConnectionDto,
|
MontifyConnectionDto,
|
||||||
|
MontifyDiagnoseDto,
|
||||||
MontifyOrderDto,
|
MontifyOrderDto,
|
||||||
MontifyTestResultDto,
|
MontifyTestResultDto,
|
||||||
UpdateMontifyConnectionRequest,
|
UpdateMontifyConnectionRequest,
|
||||||
@@ -158,6 +159,17 @@ function test(c: MontifyConnectionDto) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diagnoses = ref<Record<string, MontifyDiagnoseDto>>({});
|
||||||
|
|
||||||
|
function diagnose(c: MontifyConnectionDto) {
|
||||||
|
return run(c.id, async () => {
|
||||||
|
const { data } = await api.post<MontifyDiagnoseDto>(
|
||||||
|
`/integrations/montify/connections/${c.id}/diagnose`,
|
||||||
|
);
|
||||||
|
diagnoses.value = { ...diagnoses.value, [c.id]: data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function sync(c: MontifyConnectionDto, mode: 'incremental' | 'full') {
|
function sync(c: MontifyConnectionDto, mode: 'incremental' | 'full') {
|
||||||
if (
|
if (
|
||||||
mode === 'full' &&
|
mode === 'full' &&
|
||||||
@@ -314,11 +326,30 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||||
|
<div v-if="diagnoses[c.id]" class="diag">
|
||||||
|
<p class="muted">
|
||||||
|
بررسی مسیرها
|
||||||
|
<template v-if="diagnoses[c.id]!.customerId">
|
||||||
|
(با مشتری {{ diagnoses[c.id]!.customerId }})
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="ch in diagnoses[c.id]!.checks" :key="ch.path" :class="ch.ok ? 'ok-line' : 'bad-line'">
|
||||||
|
<strong>{{ ch.ok ? '✓' : '✗' }} {{ ch.name }}</strong>
|
||||||
|
<span class="code">{{ ch.status || 'خطای شبکه' }}</span>
|
||||||
|
<span class="ltr path">{{ ch.path }}</span>
|
||||||
|
<span class="info">{{ ch.info }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="test(c)">
|
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="test(c)">
|
||||||
تست اتصال
|
تست اتصال
|
||||||
</UiButton>
|
</UiButton>
|
||||||
|
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="diagnose(c)">
|
||||||
|
بررسی مسیرها
|
||||||
|
</UiButton>
|
||||||
<UiButton
|
<UiButton
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!c.enabled"
|
:disabled="!c.enabled"
|
||||||
@@ -463,6 +494,38 @@ onBeforeUnmount(() => {
|
|||||||
justify-content: end;
|
justify-content: end;
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
}
|
}
|
||||||
|
.diag {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.diag ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.diag li {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.diag .code {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.diag .path,
|
||||||
|
.diag .info {
|
||||||
|
color: #64748b;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.ok-line strong {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.bad-line strong {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
.warn {
|
.warn {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -4173,6 +4173,19 @@ export interface MontifyTestResultDto {
|
|||||||
totalCustomers: number | null;
|
totalCustomers: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MontifyDiagnoseCheckDto {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
status: number;
|
||||||
|
ok: boolean;
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MontifyDiagnoseDto {
|
||||||
|
customerId: string | null;
|
||||||
|
checks: MontifyDiagnoseCheckDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MontifyOrderItemDto {
|
export interface MontifyOrderItemDto {
|
||||||
productId: string | null;
|
productId: string | null;
|
||||||
productName: string | null;
|
productName: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user