Files
CRM_Montzar/apps/api/src/integrations/montify/sync.service.ts
T
MahdiandCursor cf326d1ed9 fix: keep birthdays and show Shamsi dates
Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:04:53 +03:30

851 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import {
Prisma,
type MontifyConnection,
type MontifyOrder as MontifyOrderRow,
type MontifyTransaction as MontifyTransactionRow,
} from '@prisma/client';
import type {
MontifyConnectionDto,
MontifyEngagementDto,
MontifyOrderDto,
MontifyOrderItemDto,
MontifyPersonStoreDto,
MontifySyncResultDto,
MontifyTransactionDto,
} from '@mento/shared';
import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service';
import {
forEachPage,
MontifyClient,
MontifyHttpError,
type MontifyAddress,
type MontifyCustomer,
type MontifyEngagement,
type MontifyOrder,
type MontifyTransaction,
} from './montify-client';
const CUSTOMER_PAGE_SIZE = 50;
const ENGAGEMENT_KEEP = 50;
/** مسیری که پشت‌سرهم 404 بدهد (پیاده نشده) تا پایان همان دور رد می‌شود */
const SKIP_AFTER_NOT_FOUND = 3;
type EndpointStats = Map<string, { ok: number; notFound: number }>;
/** پروفایل فروشگاهی مشتری (MontifyLink.data) — نام‌ها طبق جدول نگاشت مستند */
type CustomerProfile = {
fullName: string | null;
username: string | null;
avatar: string | null;
birthday: string | null;
gender: string | null;
type: string | null;
job: string | null;
storeId: string | null;
walletBalance: number | null;
goldWalletBalance: number | null;
summary: { totalOrders: number | null; totalSpent: number | null };
addresses: MontifyAddress[];
engagement: {
saved: unknown[];
likes: unknown[];
comments: unknown[];
} | null;
};
function num(v: unknown): number | null {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function dec(v: unknown): Prisma.Decimal | null {
const n = num(v);
return n === null ? null : new Prisma.Decimal(n);
}
function decNum(v: Prisma.Decimal | null): number | null {
return v === null ? null : Number(v);
}
function str(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
return s ? s : null;
}
/** متن نمایشی فروشگاه (مثل sts_txt) اگر باشد، وگرنه مقدار خام */
function label(obj: unknown, key: string): string | null {
if (!obj || typeof obj !== 'object') return null;
const o = obj as Record<string, unknown>;
return str(o[`${key}_txt`]) ?? str(o[key]);
}
function parseDate(v: unknown): Date | null {
const s = str(v);
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, '');
let core = digits;
if (core.startsWith('0098')) core = core.slice(4);
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
else if (core.startsWith('0')) core = core.slice(1);
if (!core) return [mobile];
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
}
function formatAddress(a: MontifyAddress): string | null {
const parts = [str(a.province), str(a.city), str(a.address)].filter(Boolean);
if (!parts.length) return null;
let line = parts.join('، ');
const postal = str(a.postal_code);
if (postal) line += ` — کدپستی ${postal}`;
const receiver = [str(a.receiver_name), str(a.receiver_phone)].filter(Boolean).join(' ');
if (receiver) line += ` — گیرنده: ${receiver}`;
return line;
}
function splitName(c: MontifyCustomer): {
firstName: string;
lastName: string;
} {
let firstName = str(c.first_name) ?? '';
let lastName = str(c.last_name) ?? '';
if (!firstName && !lastName) {
const parts = (str(c.name) ?? '').split(/\s+/).filter(Boolean);
if (parts.length > 1) {
firstName = parts[0]!;
lastName = parts.slice(1).join(' ');
} else {
lastName = parts[0] ?? '';
}
}
if (!firstName && !lastName) lastName = str(c.username) ?? `مشتری ${c.id}`;
return { firstName, lastName };
}
function customerLinkKey(connectionId: string, externalId: string) {
return {
connectionId_entityType_externalId: {
connectionId,
entityType: 'customer',
externalId,
},
};
}
@Injectable()
export class MontifySyncService {
private readonly logger = new Logger(MontifySyncService.name);
private readonly running = new Set<string>();
constructor(
private readonly prisma: PrismaService,
private readonly connections: MontifyConnectionsService,
) {}
/* ——— فاز ۱ و ۲: Polling و Incremental Sync ——— */
async startSync(
organizationId: string,
connectionId: string,
mode: 'incremental' | 'full',
): Promise<MontifyConnectionDto> {
const row = await this.connections.getOrThrow(organizationId, connectionId);
if (!row.enabled) throw new BadRequestException('این اتصال غیرفعال است');
if (!this.running.has(connectionId)) {
await new Promise<void>((started) => {
void this.runSync(row, mode, started);
});
}
return this.connections.getDto(organizationId, connectionId);
}
@Cron('*/10 * * * *')
async cronSyncAll(): Promise<void> {
const rows = await this.prisma.montifyConnection.findMany({
where: { enabled: true },
take: 50,
});
for (const row of rows) {
if (this.running.has(row.id)) continue;
await this.runSync(row, 'incremental');
}
}
private async runSync(
row: MontifyConnection,
mode: 'incremental' | 'full',
onStarted?: () => void,
): Promise<void> {
if (this.running.has(row.id)) {
onStarted?.();
return;
}
this.running.add(row.id);
const startedAt = new Date();
const result: MontifySyncResultDto = {
mode,
customersFetched: 0,
customersCreated: 0,
customersUpdated: 0,
ordersFetched: 0,
transactionsFetched: 0,
errors: [],
startedAt: startedAt.toISOString(),
finishedAt: null,
};
const saveProgress = () =>
this.prisma.montifyConnection.update({
where: { id: row.id },
data: { lastSyncResult: result as unknown as Prisma.InputJsonValue },
});
try {
await this.prisma.montifyConnection.update({
where: { id: row.id },
data: {
syncStatus: 'running',
syncStartedAt: startedAt,
lastSyncResult: result as unknown as Prisma.InputJsonValue,
},
});
} catch (e) {
this.running.delete(row.id);
this.logger.warn(`Montify sync ${row.id} start: ${e instanceof Error ? e.message : e}`);
return;
} finally {
onStarted?.();
}
const endpoints: EndpointStats = new Map();
let cursor = row.customersCursor;
let fatal: string | null = null;
try {
const client = this.connections.clientFor(row);
const updatedAfter =
mode === 'incremental' && row.customersCursor
? row.customersCursor.toISOString()
: undefined;
this.logger.log(
`Montify sync ${row.id} (${row.label}) start ${mode}${updatedAfter ? ` after ${updatedAfter}` : ''}`,
);
await forEachPage(
(page) =>
client.listCustomers({
page,
limit: CUSTOMER_PAGE_SIZE,
updatedAfter,
}),
async (res, page) => {
this.logger.log(
`Montify sync ${row.id} page ${page}/${res.meta.last_page || '?'}: ${res.data.length} customers (total ${res.meta.total || '?'})`,
);
for (const customer of res.data) {
result.customersFetched += 1;
try {
const outcome = await this.syncCustomerDeep(
row,
client,
customer,
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();
}
await saveProgress();
},
);
} catch (e) {
fatal = e instanceof Error ? e.message : String(e);
result.errors.push(fatal);
this.logger.warn(`Montify sync ${row.id}: ${fatal}`);
} finally {
this.running.delete(row.id);
}
const missing = [...endpoints.entries()]
.filter(([, s]) => s.notFound > 0)
.map(
([label, s]) =>
`مسیر ${label}: ${s.notFound} بار 404 داد` +
(s.ok ? '' : ' (احتمالاً در این فروشگاه پیاده نشده)'),
);
result.finishedAt = new Date().toISOString();
result.errors = [...missing, ...result.errors].slice(0, 50);
this.logger.log(
`Montify sync ${row.id} done: ${result.customersFetched} customers, ${result.ordersFetched} orders, ` +
`${result.transactionsFetched} transactions, ${result.errors.length} errors${fatal ? ` — fatal: ${fatal}` : ''}`,
);
await this.prisma.montifyConnection.update({
where: { id: row.id },
data: {
syncStatus: fatal ? 'error' : 'ok',
lastSyncAt: fatal ? undefined : new Date(),
customersCursor: cursor,
lastSyncResult: result as unknown as Prisma.InputJsonValue,
lastHealthAt: new Date(),
lastHealthOk: !fatal,
lastError: fatal
? fatal.slice(0, 500)
: result.errors.length
? result.errors.slice(0, 3).join(' | ').slice(0, 500)
: null,
},
});
}
/**
* گام ۲ استراتژی مستند: برای هر مشتری، آدرس‌ها، سفارش‌ها، تراکنش‌ها و داده‌های تعاملی
* جداگانه دریافت و Upsert می‌شوند.
*/
private async syncCustomerDeep(
row: MontifyConnection,
client: MontifyClient,
customer: MontifyCustomer,
mode: 'incremental' | 'full',
result: MontifySyncResultDto | null,
endpoints: EndpointStats = new Map(),
): Promise<'created' | 'updated'> {
const externalId = String(customer.id);
const step = async <T>(label: string, fn: () => Promise<T>): Promise<T | null> => {
const stats = endpoints.get(label) ?? { ok: 0, notFound: 0 };
endpoints.set(label, stats);
if (stats.ok === 0 && stats.notFound >= SKIP_AFTER_NOT_FOUND) return null;
try {
const value = await fn();
stats.ok += 1;
return value;
} catch (e) {
if (e instanceof MontifyHttpError && e.status === 401) throw e;
if (e instanceof MontifyHttpError && e.status === 404) {
stats.notFound += 1;
return null;
}
const msg = e instanceof Error ? e.message : String(e);
result?.errors.push(`مشتری ${externalId} (${label}): ${msg}`);
return null;
}
};
const addresses =
(await step('آدرس‌ها', () => client.listCustomerAddresses(externalId))) ??
customer.addresses ??
[];
const engagement =
(await step('تعاملات', () => client.getCustomerEngagement(externalId))) ??
customer.engagement ??
null;
const { personId, outcome, ordersSyncedAt } = await this.upsertCustomer(
row,
customer,
addresses,
engagement,
);
const ordersStartedAt = new Date();
const ordersOk = await step('سفارش‌ها', async () => {
const since = mode === 'full' ? null : ordersSyncedAt;
await forEachPage(
(page) =>
client.listCustomerOrders(externalId, {
page,
updatedAfter: since ? new Date(since.getTime() - 60_000).toISOString() : undefined,
}),
async (res) => {
if (result) result.ordersFetched += res.data.length;
for (const order of res.data) await this.upsertOrder(row, order, personId, externalId);
},
200,
);
return true;
});
if (ordersOk) {
await this.prisma.montifyLink.update({
where: customerLinkKey(row.id, externalId),
data: { ordersSyncedAt: ordersStartedAt },
});
}
await step('تراکنش‌ها', async () => {
const list = await client.listCustomerTransactions(externalId);
if (result) result.transactionsFetched += list.length;
for (const t of list) await this.upsertTransaction(row, t, personId, externalId);
});
return outcome;
}
/* ——— مشتری → شخص ——— */
private async upsertCustomer(
row: MontifyConnection,
c: MontifyCustomer,
addresses: MontifyAddress[],
engagement: MontifyEngagement | null,
): Promise<{
personId: string;
outcome: 'created' | 'updated';
ordersSyncedAt: Date | null;
}> {
const externalId = String(c.id);
const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null);
const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = {
fullName: str(c.name) ?? (`${firstName} ${lastName}`.trim() || null),
username: str(c.username),
avatar: str(c.avatar),
birthday: str(c.birthday),
gender: label(c, 'gender'),
type: label(c, 'type'),
job: str(c.job),
storeId: str(c.store_id),
walletBalance: num(c.cash_balance),
goldWalletBalance: num(c.gold_balance),
summary: {
totalOrders: num(c.summary?.total_orders),
totalSpent: num(c.summary?.total_spent),
},
addresses,
engagement: engagement
? {
saved: arr(engagement.saved).slice(0, ENGAGEMENT_KEEP),
likes: arr(engagement.likes).slice(0, ENGAGEMENT_KEEP),
comments: arr(engagement.comments).slice(0, ENGAGEMENT_KEEP),
}
: null,
};
const existingLink = await this.prisma.montifyLink.findUnique({
where: customerLinkKey(row.id, externalId),
});
let person = existingLink
? await this.prisma.person.findFirst({
where: {
id: existingLink.entityId,
organizationId: row.organizationId,
},
})
: null;
if (!person && (mobile || email)) {
person = await this.prisma.person.findFirst({
where: {
organizationId: row.organizationId,
OR: [
...(mobile ? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }]) : []),
...(email ? [{ email }] : []),
],
},
orderBy: { createdAt: 'asc' },
});
}
let personId: string;
let outcome: 'created' | 'updated';
if (person) {
const ownedByStore = person.importSource === 'montify';
const current = Array.isArray(person.addresses)
? (person.addresses as unknown[]).map((a) => String(a))
: [];
const merged = [...current];
for (const a of addressLines) if (!merged.includes(a)) merged.push(a);
const tags = person.tags.includes(row.label) ? person.tags : [...person.tags, row.label];
await this.prisma.person.update({
where: { id: person.id },
data: {
...(ownedByStore ? { firstName, lastName } : {}),
...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}),
...(email && (ownedByStore || !person.email) ? { email } : {}),
...(str(c.job) && !person.position ? { position: str(c.job) } : {}),
...(!person.birthDate && parseBirthDate(profile.birthday)
? { birthDate: parseBirthDate(profile.birthday) }
: {}),
addresses: merged as Prisma.InputJsonValue,
tags,
...(!existingLink && !ownedByStore && !person.previousImportSource
? carryPreviousSource(person, 'montify')
: {}),
},
});
personId = person.id;
outcome = 'updated';
} else {
const notes = [str(c.bio), str(c.description)].filter(Boolean).join('\n') || null;
const created = await this.prisma.person.create({
data: {
organizationId: row.organizationId,
firstName,
lastName,
mobile,
email,
position: str(c.job),
notes,
addresses: addressLines as Prisma.InputJsonValue,
tags: [row.label],
importSource: 'montify',
sourceRef: externalId,
birthDate: parseBirthDate(profile.birthday),
},
});
personId = created.id;
outcome = 'created';
}
const link = await this.prisma.montifyLink.upsert({
where: customerLinkKey(row.id, externalId),
create: {
organizationId: row.organizationId,
connectionId: row.id,
entityType: 'customer',
externalId,
entityId: personId,
externalUpdatedAt: parseDate(c.updated_at),
data: profile as unknown as Prisma.InputJsonValue,
},
update: {
entityId: personId,
externalUpdatedAt: parseDate(c.updated_at),
data: profile as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
},
});
if (!existingLink || existingLink.entityId !== personId) {
await Promise.all([
this.prisma.montifyOrder.updateMany({
where: { connectionId: row.id, customerExternalId: externalId },
data: { personId },
}),
this.prisma.montifyTransaction.updateMany({
where: { connectionId: row.id, customerExternalId: externalId },
data: { personId },
}),
]);
}
return { personId, outcome, ordersSyncedAt: link.ordersSyncedAt };
}
/* ——— سفارش و تراکنش ——— */
private async personForCustomer(connectionId: string, customerExternalId: string | null) {
if (!customerExternalId) return null;
const link = await this.prisma.montifyLink.findUnique({
where: customerLinkKey(connectionId, customerExternalId),
select: { entityId: true },
});
return link?.entityId ?? null;
}
private async upsertOrder(
row: MontifyConnection,
o: MontifyOrder,
personIdHint: string | null,
customerHint: string | null,
): Promise<void> {
if (!o?.id) return;
const externalId = String(o.id);
const customerExternalId = str(o.user_id) ?? customerHint;
const personId = personIdHint ?? (await this.personForCustomer(row.id, customerExternalId));
const items = (o.order_items ?? []).map((i) => ({
id: str(i.id),
productId: str(i.product_id),
productName: str(i.product_name),
qty: num(i.qty),
price: num(i.price),
weight: num(i.weight),
totalPrice: num(i.total_price),
}));
const data = {
customerExternalId,
personId,
orderReference: str(o.code),
orderStatus: str(o.sts),
paymentStatus: str(o.payment_sts),
paymentType: str(o.payment_type),
totalPrice: dec(o.total_price),
discountTotalPrice: dec(o.discount_total_price),
totalDiscounts: dec(o.total_discounts),
totalTax: dec(o.total_tax),
totalQty: dec(o.total_qty),
profit: dec(o.profit),
percentProfit: dec(o.percent_profit),
sumGold: dec(o.sum_gold),
sumSilver: dec(o.sum_silver),
address: (o.address ?? Prisma.JsonNull) as Prisma.InputJsonValue,
note: str(o.note),
cancelReason: str(o.cancel_reason),
cancelledAt: parseDate(o.cancelled_at),
closedAt: parseDate(o.closed_at),
codeDiscount: str(o.code_discount),
items: items as unknown as Prisma.InputJsonValue,
externalCreatedAt: parseDate(o.created_at),
externalUpdatedAt: parseDate(o.updated_at),
deletedAt: parseDate(o.deleted_at),
raw: o as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
};
await this.prisma.montifyOrder.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
create: {
organizationId: row.organizationId,
connectionId: row.id,
externalId,
...data,
},
update: data,
});
}
private async upsertTransaction(
row: MontifyConnection,
t: MontifyTransaction,
personId: string,
customerExternalId: string,
): Promise<void> {
if (!t?.id) return;
const externalId = String(t.id);
const data = {
customerExternalId: str(t.user_id) ?? customerExternalId,
personId,
type: str(t.type),
amount: dec(t.amount),
currency: str(t.currency),
status: str(t.status),
referenceCode: str(t.reference_code),
externalCreatedAt: parseDate(t.created_at),
raw: t as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
};
await this.prisma.montifyTransaction.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
create: {
organizationId: row.organizationId,
connectionId: row.id,
externalId,
...data,
},
update: data,
});
}
/* ——— فاز ۳: وب‌هوک ——— */
async processWebhook(
row: MontifyConnection,
deliveryRowId: string,
event: string,
payload: { data?: Record<string, unknown> } | null,
): Promise<void> {
try {
const data = payload?.data ?? {};
const id = str(data.id);
if (!id) throw new Error('شناسه رکورد در payload نیست');
const client = this.connections.clientFor(row);
if (event.startsWith('customer.')) {
const customer = await client.getCustomer(id);
await this.syncCustomerDeep(row, client, customer, 'incremental', null);
} else if (event.startsWith('order.')) {
let order: MontifyOrder;
try {
order = await client.getOrder(id);
} catch {
order = data as unknown as MontifyOrder;
}
const customerId = str(order.user_id);
const personId = await this.personForCustomer(row.id, customerId);
if (!personId && customerId) {
const customer = await client.getCustomer(customerId);
await this.syncCustomerDeep(row, client, customer, 'incremental', null);
}
await this.upsertOrder(row, order, null, customerId);
}
await this.prisma.montifyWebhookDelivery.update({
where: { id: deliveryRowId },
data: { status: 'processed' },
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(`Montify webhook ${row.id} ${event}: ${msg}`);
await this.prisma.montifyWebhookDelivery.update({
where: { id: deliveryRowId },
data: { status: 'failed', error: msg.slice(0, 500) },
});
}
}
/* ——— نمایش ——— */
async listOrders(organizationId: string, connectionId?: string): Promise<MontifyOrderDto[]> {
const rows = await this.prisma.montifyOrder.findMany({
where: { organizationId, ...(connectionId ? { connectionId } : {}) },
include: { connection: { select: { label: true } } },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 50,
});
const personIds = [...new Set(rows.map((r) => r.personId).filter((x): x is string => !!x))];
const people = personIds.length
? await this.prisma.person.findMany({
where: { id: { in: personIds } },
select: { id: true, firstName: true, lastName: true },
})
: [];
const nameOf = new Map(people.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]));
return rows.map((r) => this.orderDto(r, r.connection.label, nameOf));
}
async personStores(actor: Actor, personId: string): Promise<MontifyPersonStoreDto[]> {
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
select: { id: true, firstName: true, lastName: true },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const links = await this.prisma.montifyLink.findMany({
where: {
organizationId: actor.organizationId,
entityType: 'customer',
entityId: personId,
},
include: { connection: { select: { label: true } } },
orderBy: { syncedAt: 'desc' },
});
const nameOf = new Map([[person.id, `${person.firstName} ${person.lastName}`.trim()]]);
const out: MontifyPersonStoreDto[] = [];
for (const l of links) {
const [orders, transactions] = await Promise.all([
this.prisma.montifyOrder.findMany({
where: {
connectionId: l.connectionId,
customerExternalId: l.externalId,
},
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 20,
}),
this.prisma.montifyTransaction.findMany({
where: {
connectionId: l.connectionId,
customerExternalId: l.externalId,
},
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 20,
}),
]);
const p = (l.data ?? {}) as Partial<CustomerProfile>;
const engagement: MontifyEngagementDto | null = p.engagement
? {
saved: arr(p.engagement.saved).length,
likes: arr(p.engagement.likes).length,
comments: arr(p.engagement.comments).length,
}
: null;
out.push({
connectionId: l.connectionId,
connectionLabel: l.connection.label,
externalCustomerId: l.externalId,
fullName: p.fullName ?? null,
username: p.username ?? null,
customerType: p.type ?? null,
walletBalance: p.walletBalance ?? null,
goldWalletBalance: p.goldWalletBalance ?? null,
totalOrders: p.summary?.totalOrders ?? null,
totalSpent: p.summary?.totalSpent ?? null,
engagement,
orders: orders.map((o) => this.orderDto(o, l.connection.label, nameOf)),
transactions: transactions.map((t) => this.transactionDto(t)),
syncedAt: l.syncedAt.toISOString(),
});
}
return out;
}
private orderDto(
r: MontifyOrderRow,
connectionLabel: string,
nameOf: Map<string, string>,
): MontifyOrderDto {
const items = (Array.isArray(r.items) ? r.items : []) as unknown as MontifyOrderItemDto[];
const raw = r.raw;
return {
id: r.id,
connectionId: r.connectionId,
connectionLabel,
externalId: r.externalId,
customerExternalId: r.customerExternalId,
personId: r.personId,
personName: r.personId ? (nameOf.get(r.personId) ?? null) : null,
orderReference: r.orderReference,
orderStatus: label(raw, 'sts') ?? r.orderStatus,
paymentStatus: label(raw, 'payment_sts') ?? r.paymentStatus,
paymentType: label(raw, 'payment_type') ?? r.paymentType,
totalPrice: decNum(r.totalPrice),
discountTotalPrice: decNum(r.discountTotalPrice),
totalDiscounts: decNum(r.totalDiscounts),
totalQty: decNum(r.totalQty),
sumGold: decNum(r.sumGold),
sumSilver: decNum(r.sumSilver),
note: r.note,
cancelReason: r.cancelReason,
cancelledAt: r.cancelledAt?.toISOString() ?? null,
closedAt: r.closedAt?.toISOString() ?? null,
items: items.map((i) => ({
productId: i.productId ?? null,
productName: i.productName ?? null,
qty: i.qty ?? null,
price: i.price ?? null,
weight: i.weight ?? null,
totalPrice: i.totalPrice ?? null,
})),
createdAt: r.externalCreatedAt?.toISOString() ?? null,
updatedAt: r.externalUpdatedAt?.toISOString() ?? null,
};
}
private transactionDto(t: MontifyTransactionRow): MontifyTransactionDto {
return {
id: t.id,
externalId: t.externalId,
type: label(t.raw, 'type') ?? t.type,
amount: decNum(t.amount),
currency: label(t.raw, 'currency') ?? t.currency,
status: label(t.raw, 'status') ?? t.status,
referenceCode: t.referenceCode,
createdAt: t.externalCreatedAt?.toISOString() ?? null,
};
}
}