A Tahesab import now updates the person who already has that mobile instead of creating another, and the boot merge runs once more so existing pairs collapse. Co-authored-by: Cursor <cursoragent@cursor.com>
1278 lines
43 KiB
TypeScript
1278 lines
43 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
type OnModuleInit,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
|
|
import {
|
|
canonicalIranMobile,
|
|
phoneIdentityKeys,
|
|
storedPhoneNumber,
|
|
} from '../../contacts/normalize';
|
|
import { applyPersonGenderTitle } from '@mento/shared';
|
|
import { TahesabConnectionsService } from './connections.service';
|
|
import {
|
|
CreateInTahesabDto,
|
|
ExportPeopleToTahesabDto,
|
|
ImportBulkFromTahesabDto,
|
|
ImportFromTahesabDto,
|
|
ManualLinkDto,
|
|
MatchPersonDto,
|
|
SearchTahesabDto,
|
|
SyncGroupsPageDto,
|
|
} from './dto';
|
|
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
|
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
|
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
|
|
|
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
|
function canonicalMobileUpdate(
|
|
incoming: string | null,
|
|
current: { mobile: string | null; phone: string | null },
|
|
): { mobile?: string; phone?: string } {
|
|
const mobile =
|
|
canonicalIranMobile(incoming) ??
|
|
canonicalIranMobile(current.mobile) ??
|
|
canonicalIranMobile(current.phone);
|
|
if (!mobile) return {};
|
|
const phone = canonicalIranMobile(current.phone);
|
|
return phone ? { mobile, phone } : { mobile };
|
|
}
|
|
|
|
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
|
const LIST_PAGE_SIZE = 2000;
|
|
const LIST_TIMEOUT_MS = 55_000;
|
|
|
|
function asMoshtariList(raw: unknown): Array<Record<string, unknown>> {
|
|
if (!raw) return [];
|
|
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
|
|
if (typeof raw === 'object') {
|
|
const obj = raw as Record<string, unknown>;
|
|
// پاسخ گاهی دیکشنری با کلید عددی است
|
|
const vals = Object.values(obj);
|
|
if (vals.every((v) => v && typeof v === 'object')) {
|
|
return vals as Array<Record<string, unknown>>;
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function pickCode(row: Record<string, unknown>): number | null {
|
|
const c =
|
|
row.Moshtari_Code ??
|
|
row.Code ??
|
|
row.MCode ??
|
|
row.code ??
|
|
row.moshtari_code;
|
|
const n = Number(c);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function pickName(row: Record<string, unknown>): string {
|
|
return String(
|
|
row.Moshtari_Name ?? row.Name ?? row.name ?? row.FullName ?? '',
|
|
);
|
|
}
|
|
|
|
function pickTel(row: Record<string, unknown>): string {
|
|
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
|
|
}
|
|
|
|
function pickBirthDate(row: Record<string, unknown>): Date | null {
|
|
const raw =
|
|
row.BDate ??
|
|
row.bDate ??
|
|
row.Tarikh_Tavalod ??
|
|
row.tarikh_tavalod ??
|
|
row.BirthDate;
|
|
if (raw == null) return null;
|
|
return parseBirthDate(String(raw));
|
|
}
|
|
|
|
function firstAddress(raw: unknown): string | undefined {
|
|
if (!Array.isArray(raw)) return undefined;
|
|
const first = raw.find((a) => typeof a === 'string' && a.trim());
|
|
return typeof first === 'string' ? first.trim() : undefined;
|
|
}
|
|
|
|
/** گروه مشتری در تهحساب (فیلد نام در پاسخ تهحساب «GoroupName» غلطاملایی دارد) */
|
|
function pickGroup(row: Record<string, unknown>): { gid: string | null; name: string } | null {
|
|
const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId;
|
|
const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup;
|
|
if (rawGid == null && rawName == null) return null;
|
|
const gid = rawGid == null ? null : String(rawGid).trim() || null;
|
|
const name = rawName == null ? '' : String(rawName).trim();
|
|
if (!gid && !name) return null;
|
|
return { gid, name };
|
|
}
|
|
|
|
@Injectable()
|
|
export class TahesabPersonLinkService {
|
|
private readonly logger = new Logger(TahesabPersonLinkService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly connections: TahesabConnectionsService,
|
|
private readonly groups: CustomerGroupsService,
|
|
) {}
|
|
|
|
/**
|
|
* گروه تهحساب روی شخص: فقط گروهِ آمده از تهحساب عوض میشود و گروههای دستی میمانند.
|
|
* @returns آیا گروه شخص عوض شد
|
|
*/
|
|
private async applyTahesabGroup(
|
|
organizationId: string,
|
|
connectionId: string,
|
|
personId: string,
|
|
row: Record<string, unknown> | undefined,
|
|
): Promise<boolean> {
|
|
const group = row ? pickGroup(row) : null;
|
|
if (!group) return false;
|
|
try {
|
|
const groupId = await this.groups.ensureTahesabGroup(
|
|
organizationId,
|
|
connectionId,
|
|
group.gid,
|
|
group.name,
|
|
);
|
|
return await this.groups.setPersonTahesabGroup(personId, groupId);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`گروه تهحساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async getPerson(organizationId: string, personId: string) {
|
|
const person = await this.prisma.person.findFirst({
|
|
where: { id: personId, organizationId },
|
|
});
|
|
if (!person) throw new NotFoundException('شخص یافت نشد.');
|
|
return person;
|
|
}
|
|
|
|
private linkDto(link: {
|
|
id: string;
|
|
personId: string;
|
|
connectionId: string;
|
|
moshtariCode: number;
|
|
matchMethod: string;
|
|
linkedAt: Date;
|
|
lastBalanceSyncedAt: Date | null;
|
|
connection?: { label: string; dbName: string; isDefault: boolean };
|
|
}) {
|
|
return {
|
|
id: link.id,
|
|
personId: link.personId,
|
|
connectionId: link.connectionId,
|
|
moshtariCode: link.moshtariCode,
|
|
matchMethod: link.matchMethod,
|
|
linkedAt: link.linkedAt.toISOString(),
|
|
lastBalanceSyncedAt: link.lastBalanceSyncedAt?.toISOString() ?? null,
|
|
connectionLabel: link.connection?.label,
|
|
dbName: link.connection?.dbName,
|
|
isDefaultConnection: link.connection?.isDefault,
|
|
};
|
|
}
|
|
|
|
async listLinks(organizationId: string, personId: string) {
|
|
await this.getPerson(organizationId, personId);
|
|
const links = await this.prisma.personAccountingLink.findMany({
|
|
where: {
|
|
personId,
|
|
connection: { organizationId, provider: 'tahesab' },
|
|
},
|
|
include: {
|
|
connection: { select: { label: true, dbName: true, isDefault: true } },
|
|
},
|
|
orderBy: { linkedAt: 'desc' },
|
|
});
|
|
return links.map((l) => this.linkDto(l));
|
|
}
|
|
|
|
async manualLink(
|
|
organizationId: string,
|
|
personId: string,
|
|
dto: ManualLinkDto,
|
|
) {
|
|
await this.getPerson(organizationId, personId);
|
|
await this.connections.getOrThrow(organizationId, dto.connectionId);
|
|
|
|
const clash = await this.prisma.personAccountingLink.findFirst({
|
|
where: {
|
|
connectionId: dto.connectionId,
|
|
moshtariCode: dto.moshtariCode,
|
|
personId: { not: personId },
|
|
},
|
|
});
|
|
if (clash) {
|
|
throw new ConflictException(
|
|
'این کد مشتری تهحساب قبلاً به شخص دیگری لینک شده است.',
|
|
);
|
|
}
|
|
|
|
const link = await this.prisma.personAccountingLink.upsert({
|
|
where: {
|
|
personId_connectionId: {
|
|
personId,
|
|
connectionId: dto.connectionId,
|
|
},
|
|
},
|
|
create: {
|
|
personId,
|
|
connectionId: dto.connectionId,
|
|
moshtariCode: dto.moshtariCode,
|
|
matchMethod: 'manual',
|
|
},
|
|
update: {
|
|
moshtariCode: dto.moshtariCode,
|
|
matchMethod: 'manual',
|
|
linkedAt: new Date(),
|
|
},
|
|
include: {
|
|
connection: { select: { label: true, dbName: true, isDefault: true } },
|
|
},
|
|
});
|
|
return this.linkDto(link);
|
|
}
|
|
|
|
async unlink(
|
|
organizationId: string,
|
|
personId: string,
|
|
connectionId: string,
|
|
) {
|
|
await this.getPerson(organizationId, personId);
|
|
const link = await this.prisma.personAccountingLink.findFirst({
|
|
where: {
|
|
personId,
|
|
connectionId,
|
|
connection: { organizationId },
|
|
},
|
|
});
|
|
if (!link) throw new NotFoundException('لینک یافت نشد.');
|
|
await this.prisma.personAccountingLink.delete({ where: { id: link.id } });
|
|
return { ok: true };
|
|
}
|
|
|
|
async match(
|
|
organizationId: string,
|
|
personId: string,
|
|
dto: MatchPersonDto,
|
|
) {
|
|
const person = await this.getPerson(organizationId, personId);
|
|
const { api } = await this.connections.getApi(
|
|
organizationId,
|
|
dto.connectionId,
|
|
);
|
|
|
|
const candidates: Array<{
|
|
moshtariCode: number;
|
|
name: string;
|
|
tel: string;
|
|
matchBy: 'mobile' | 'name';
|
|
raw: Record<string, unknown>;
|
|
}> = [];
|
|
|
|
const mobile = normalizeIranMobile(person.mobile ?? person.phone);
|
|
if (mobile) {
|
|
const raw = await api.doListMoshtariByPhone(mobile);
|
|
for (const row of asMoshtariList(raw)) {
|
|
const code = pickCode(row);
|
|
if (code == null) continue;
|
|
candidates.push({
|
|
moshtariCode: code,
|
|
name: pickName(row),
|
|
tel: pickTel(row),
|
|
matchBy: 'mobile',
|
|
raw: row,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
const name = `${person.firstName} ${person.lastName}`.trim();
|
|
if (name) {
|
|
const raw = await api.doListMoshtariByName(name);
|
|
for (const row of asMoshtariList(raw)) {
|
|
const code = pickCode(row);
|
|
if (code == null) continue;
|
|
candidates.push({
|
|
moshtariCode: code,
|
|
name: pickName(row),
|
|
tel: pickTel(row),
|
|
matchBy: 'name',
|
|
raw: row,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// unique by code
|
|
const uniq = new Map<number, (typeof candidates)[0]>();
|
|
for (const c of candidates) uniq.set(c.moshtariCode, c);
|
|
const list = [...uniq.values()];
|
|
|
|
if (dto.autoLinkIfUnique && list.length === 1) {
|
|
const only = list[0]!;
|
|
const link = await this.manualLink(organizationId, personId, {
|
|
connectionId: dto.connectionId,
|
|
moshtariCode: only.moshtariCode,
|
|
});
|
|
// override match method
|
|
const updated = await this.prisma.personAccountingLink.update({
|
|
where: { id: link.id },
|
|
data: { matchMethod: only.matchBy },
|
|
include: {
|
|
connection: {
|
|
select: { label: true, dbName: true, isDefault: true },
|
|
},
|
|
},
|
|
});
|
|
return {
|
|
status: 'linked' as const,
|
|
candidates: list,
|
|
link: this.linkDto(updated),
|
|
};
|
|
}
|
|
|
|
if (list.length === 0) {
|
|
return { status: 'none' as const, candidates: list, link: null };
|
|
}
|
|
if (list.length === 1) {
|
|
return { status: 'unique' as const, candidates: list, link: null };
|
|
}
|
|
return { status: 'ambiguous' as const, candidates: list, link: null };
|
|
}
|
|
|
|
async createInTahesab(
|
|
organizationId: string,
|
|
personId: string,
|
|
dto: CreateInTahesabDto,
|
|
) {
|
|
const person = await this.getPerson(organizationId, personId);
|
|
const existing = await this.prisma.personAccountingLink.findFirst({
|
|
where: { personId, connectionId: dto.connectionId },
|
|
});
|
|
if (existing) {
|
|
throw new ConflictException(
|
|
'این شخص از قبل به این اتصال لینک شده است.',
|
|
);
|
|
}
|
|
|
|
const { api } = await this.connections.getApi(
|
|
organizationId,
|
|
dto.connectionId,
|
|
);
|
|
const tel =
|
|
normalizeIranMobile(person.mobile ?? person.phone) ??
|
|
person.mobile ??
|
|
person.phone ??
|
|
'';
|
|
const extra = (person.customFields ?? {}) as Record<string, unknown>;
|
|
const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : '';
|
|
const groupName =
|
|
dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM';
|
|
const args = SanadBuilders.newMoshtari({
|
|
name: `${person.firstName} ${person.lastName}`.trim(),
|
|
groupName,
|
|
tel,
|
|
address: firstAddress(person.addresses),
|
|
codeMeli: nationalId || undefined,
|
|
tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined,
|
|
jensFelez: dto.jensFelez ?? 0,
|
|
});
|
|
const res = await api.doNewMoshtari(args);
|
|
const code = Number(res?.OK);
|
|
if (!Number.isFinite(code) || code <= 0) {
|
|
throw new BadRequestException(
|
|
'تهحساب کد مشتری معتبر برنگرداند. پاسخ: ' + JSON.stringify(res),
|
|
);
|
|
}
|
|
|
|
const link = await this.prisma.personAccountingLink.create({
|
|
data: {
|
|
personId,
|
|
connectionId: dto.connectionId,
|
|
moshtariCode: code,
|
|
matchMethod: 'created',
|
|
},
|
|
include: {
|
|
connection: { select: { label: true, dbName: true, isDefault: true } },
|
|
},
|
|
});
|
|
return this.linkDto(link);
|
|
}
|
|
|
|
/**
|
|
* یک صفحه از مشتریان بر اساس بازهٔ کد:
|
|
* صفحه ۱ → ۱…pageSize، صفحه ۲ → pageSize+1…۲×pageSize، …
|
|
* سقف ۵۰۰۰ حذف شده؛ با پیجینیت میتوان همه را مرور کرد.
|
|
*/
|
|
private async listMoshtariPage(
|
|
api: TahesabApi,
|
|
page: number,
|
|
pageSize?: number,
|
|
) {
|
|
const size = Math.min(
|
|
LIST_PAGE_SIZE,
|
|
Math.max(100, Math.floor(pageSize ?? LIST_PAGE_SIZE) || LIST_PAGE_SIZE),
|
|
);
|
|
const p = Math.max(1, Math.floor(page) || 1);
|
|
const fromCode = (p - 1) * size + 1;
|
|
const toCode = fromCode + size - 1;
|
|
const raw = await api.doListMoshtariByCode(fromCode, toCode, {
|
|
timeoutMs: LIST_TIMEOUT_MS,
|
|
});
|
|
return {
|
|
rows: asMoshtariList(raw),
|
|
fromCode,
|
|
toCode,
|
|
page: p,
|
|
pageSize: size,
|
|
};
|
|
}
|
|
|
|
async search(organizationId: string, dto: SearchTahesabDto) {
|
|
const { api } = await this.connections.getApi(
|
|
organizationId,
|
|
dto.connectionId,
|
|
);
|
|
let raw: unknown = [];
|
|
let listMeta:
|
|
| {
|
|
page: number;
|
|
pageSize: number;
|
|
fromCode: number;
|
|
toCode: number;
|
|
}
|
|
| undefined;
|
|
|
|
if (dto.listAll) {
|
|
const listed = await this.listMoshtariPage(
|
|
api,
|
|
dto.page ?? 1,
|
|
dto.pageSize,
|
|
);
|
|
raw = listed.rows;
|
|
listMeta = {
|
|
page: listed.page,
|
|
pageSize: listed.pageSize,
|
|
fromCode: listed.fromCode,
|
|
toCode: listed.toCode,
|
|
};
|
|
} else if (dto.fromCode != null && dto.toCode != null) {
|
|
if (dto.toCode < dto.fromCode) {
|
|
throw new BadRequestException('کد پایان باید بزرگتر یا مساوی کد شروع باشد.');
|
|
}
|
|
if (dto.toCode - dto.fromCode > 2000) {
|
|
throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است (مثلاً ۱ تا ۲۰۰۰). برای همه، بازه را خالی بگذارید.');
|
|
}
|
|
raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode, {
|
|
timeoutMs: LIST_TIMEOUT_MS,
|
|
});
|
|
} else if (dto.moshtariCode) {
|
|
raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode);
|
|
} else if (dto.phone) {
|
|
const m = normalizeIranMobile(dto.phone) ?? dto.phone;
|
|
raw = await api.doListMoshtariByPhone(m);
|
|
} else if (dto.name?.trim()) {
|
|
raw = await api.doListMoshtariByName(dto.name.trim());
|
|
} else {
|
|
throw new BadRequestException(
|
|
'یکی از موارد لازم است: بازه کد (از–تا)، کد مشتری، موبایل یا نام. برای همهٔ مشتریان بازه را خالی بگذارید.',
|
|
);
|
|
}
|
|
|
|
const mapped = asMoshtariList(raw).map((row) => ({
|
|
moshtariCode: pickCode(row),
|
|
name: pickName(row),
|
|
tel: pickTel(row),
|
|
// raw فقط برای جستجوی محدود؛ در listAll حجم پاسخ را باد میکند و پروکسی را میترکاند
|
|
...(dto.listAll ? {} : { raw: row }),
|
|
}));
|
|
|
|
const codes = mapped
|
|
.map((r) => r.moshtariCode)
|
|
.filter((c): c is number => c != null);
|
|
const existing =
|
|
codes.length === 0
|
|
? []
|
|
: await this.prisma.personAccountingLink.findMany({
|
|
where: {
|
|
connectionId: dto.connectionId,
|
|
moshtariCode: { in: codes },
|
|
},
|
|
select: {
|
|
moshtariCode: true,
|
|
personId: true,
|
|
person: { select: { firstName: true, lastName: true } },
|
|
},
|
|
});
|
|
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
|
|
|
|
const results = mapped.map((row) => {
|
|
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
|
|
return {
|
|
...row,
|
|
alreadyLinked: Boolean(link),
|
|
personId: link?.personId ?? null,
|
|
personName: link
|
|
? `${link.person.firstName} ${link.person.lastName}`.trim()
|
|
: null,
|
|
};
|
|
});
|
|
|
|
await this.prisma.accountingSyncJob.create({
|
|
data: {
|
|
organizationId,
|
|
connectionId: dto.connectionId,
|
|
kind: 'search',
|
|
status: 'success',
|
|
request: dto as unknown as Prisma.InputJsonValue,
|
|
result: {
|
|
count: results.length,
|
|
...(listMeta ?? {}),
|
|
} as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return {
|
|
results,
|
|
...(listMeta
|
|
? {
|
|
...listMeta,
|
|
hasMore: results.length > 0,
|
|
note: `صفحه ${listMeta.page}: کد ${listMeta.fromCode} تا ${listMeta.toCode}. برای ادامه صفحه بعد را بزنید.`,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async importFromTahesab(
|
|
organizationId: string,
|
|
dto: ImportFromTahesabDto,
|
|
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
|
|
options?: { skipJob?: boolean },
|
|
) {
|
|
const clash = await this.prisma.personAccountingLink.findFirst({
|
|
where: {
|
|
connectionId: dto.connectionId,
|
|
moshtariCode: dto.moshtariCode,
|
|
},
|
|
include: { person: true },
|
|
});
|
|
if (clash && (!dto.personId || clash.personId !== dto.personId)) {
|
|
throw new ConflictException(
|
|
`این کد تهحساب قبلاً به «${clash.person.firstName} ${clash.person.lastName}» لینک شده است.`,
|
|
);
|
|
}
|
|
|
|
let row: Record<string, unknown>;
|
|
if (prefetched?.row) {
|
|
row = prefetched.row;
|
|
} else {
|
|
const api =
|
|
prefetched?.api ??
|
|
(await this.connections.getApi(organizationId, dto.connectionId)).api;
|
|
const raw = await api.doListMoshtariByCode(
|
|
dto.moshtariCode,
|
|
dto.moshtariCode,
|
|
);
|
|
const found = asMoshtariList(raw)[0];
|
|
if (!found) {
|
|
throw new NotFoundException(
|
|
`مشتری با کد ${dto.moshtariCode} در تهحساب یافت نشد.`,
|
|
);
|
|
}
|
|
row = found as Record<string, unknown>;
|
|
}
|
|
const name = pickName(row) || `مشتری تهحساب ${dto.moshtariCode}`;
|
|
const tel = pickTel(row);
|
|
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
|
const firstName = named.firstName;
|
|
const lastName = named.lastName;
|
|
const mobile = storedPhoneNumber(tel);
|
|
const sourceRef = String(dto.moshtariCode);
|
|
|
|
let personId = dto.personId;
|
|
let preserveExistingLink = false;
|
|
if (personId) {
|
|
const existing = await this.getPerson(organizationId, personId);
|
|
const prevFirst = existing.firstName;
|
|
const prevLast = existing.lastName;
|
|
const nameChanged =
|
|
`${prevFirst} ${prevLast}`.trim() !== `${firstName} ${lastName}`.trim();
|
|
await this.prisma.person.update({
|
|
where: { id: personId },
|
|
data: {
|
|
firstName,
|
|
lastName,
|
|
gender: named.gender,
|
|
showGenderInName: named.showGenderInName,
|
|
...(nameChanged
|
|
? {
|
|
previousFirstName: prevFirst || null,
|
|
previousLastName: prevLast || null,
|
|
}
|
|
: {}),
|
|
...carryPreviousSource(existing, 'tahesab'),
|
|
importSource: 'tahesab',
|
|
sourceRef,
|
|
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
|
...canonicalMobileUpdate(mobile, existing),
|
|
},
|
|
});
|
|
} else {
|
|
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
|
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابهجا نمیشود
|
|
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
|
|
const mobileKeys = phoneIdentityKeys(mobile);
|
|
const matches = mobileKeys.length
|
|
? await this.prisma.person.findMany({
|
|
where: {
|
|
organizationId,
|
|
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
|
},
|
|
include: {
|
|
accountingLinks: {
|
|
where: { connectionId: dto.connectionId },
|
|
select: { moshtariCode: true },
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
: [];
|
|
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
|
if (byMobile) {
|
|
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
|
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
|
|
const nameChanged =
|
|
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
|
`${firstName} ${lastName}`.trim();
|
|
await this.prisma.person.update({
|
|
where: { id: byMobile.id },
|
|
data: {
|
|
firstName,
|
|
lastName,
|
|
gender: named.gender,
|
|
showGenderInName: named.showGenderInName,
|
|
...(nameChanged
|
|
? {
|
|
previousFirstName: byMobile.firstName || null,
|
|
previousLastName: byMobile.lastName || null,
|
|
}
|
|
: {}),
|
|
...carryPreviousSource(byMobile, 'tahesab'),
|
|
importSource: 'tahesab',
|
|
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
|
|
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
|
...canonicalMobileUpdate(mobile, byMobile),
|
|
},
|
|
});
|
|
personId = byMobile.id;
|
|
} else {
|
|
const created = await this.prisma.person.create({
|
|
data: {
|
|
organizationId,
|
|
firstName,
|
|
lastName,
|
|
gender: named.gender,
|
|
showGenderInName: named.showGenderInName,
|
|
mobile,
|
|
phone: mobile,
|
|
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
|
importSource: 'tahesab',
|
|
sourceRef,
|
|
birthDate: pickBirthDate(row),
|
|
},
|
|
});
|
|
personId = created.id;
|
|
}
|
|
}
|
|
|
|
if (preserveExistingLink) {
|
|
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
|
const existing = await this.prisma.personAccountingLink.findFirst({
|
|
where: { personId, connectionId: dto.connectionId },
|
|
include: {
|
|
connection: { select: { label: true, dbName: true, isDefault: true } },
|
|
},
|
|
});
|
|
if (!existing) {
|
|
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
|
}
|
|
if (!options?.skipJob) {
|
|
await this.prisma.accountingSyncJob.create({
|
|
data: {
|
|
organizationId,
|
|
connectionId: dto.connectionId,
|
|
kind: 'import',
|
|
status: 'success',
|
|
request: dto as unknown as Prisma.InputJsonValue,
|
|
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
}
|
|
return { personId, link: this.linkDto(existing) };
|
|
}
|
|
|
|
const link = await this.prisma.personAccountingLink.upsert({
|
|
where: {
|
|
personId_connectionId: {
|
|
personId,
|
|
connectionId: dto.connectionId,
|
|
},
|
|
},
|
|
create: {
|
|
personId,
|
|
connectionId: dto.connectionId,
|
|
moshtariCode: dto.moshtariCode,
|
|
matchMethod: 'manual',
|
|
},
|
|
update: {
|
|
moshtariCode: dto.moshtariCode,
|
|
matchMethod: 'manual',
|
|
linkedAt: new Date(),
|
|
},
|
|
include: {
|
|
connection: { select: { label: true, dbName: true, isDefault: true } },
|
|
},
|
|
});
|
|
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
|
|
|
if (!options?.skipJob) {
|
|
await this.prisma.accountingSyncJob.create({
|
|
data: {
|
|
organizationId,
|
|
connectionId: dto.connectionId,
|
|
kind: 'import',
|
|
status: 'success',
|
|
request: dto as unknown as Prisma.InputJsonValue,
|
|
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
}
|
|
|
|
return { personId, link: this.linkDto(link) };
|
|
}
|
|
|
|
/** نام، موبایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
|
private async refreshLinkedCustomer(
|
|
organizationId: string,
|
|
connectionId: string,
|
|
personId: string,
|
|
moshtariCode: number,
|
|
row: Record<string, unknown>,
|
|
) {
|
|
const existing = await this.getPerson(organizationId, personId);
|
|
const picked = pickName(row).trim();
|
|
const named = picked
|
|
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
|
|
: null;
|
|
const nameChanged =
|
|
named != null &&
|
|
namesDiffer(
|
|
existing.firstName,
|
|
existing.lastName,
|
|
named.firstName,
|
|
named.lastName,
|
|
);
|
|
await this.prisma.person.update({
|
|
where: { id: personId },
|
|
data: {
|
|
...(named
|
|
? {
|
|
firstName: named.firstName,
|
|
lastName: named.lastName,
|
|
gender: named.gender,
|
|
showGenderInName: named.showGenderInName,
|
|
}
|
|
: {}),
|
|
...(nameChanged
|
|
? {
|
|
previousFirstName: existing.firstName || null,
|
|
previousLastName: existing.lastName || null,
|
|
}
|
|
: {}),
|
|
...carryPreviousSource(existing, 'tahesab'),
|
|
importSource: 'tahesab',
|
|
sourceRef: String(moshtariCode),
|
|
...(!existing.birthDate && pickBirthDate(row)
|
|
? { birthDate: pickBirthDate(row) }
|
|
: {}),
|
|
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
|
},
|
|
});
|
|
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
|
}
|
|
|
|
/**
|
|
* همهٔ مشتریان تهحساب را صفحه به صفحه میخواند.
|
|
* کد تازه وارد میشود؛ کد لینکشده نام و شماره و گروهش تازه میشود.
|
|
*/
|
|
async syncAllCustomers(organizationId: string, connectionId: string) {
|
|
const { api } = await this.connections.getApi(organizationId, connectionId);
|
|
let page = 1;
|
|
let emptyStreak = 0;
|
|
let imported = 0;
|
|
let updated = 0;
|
|
let failed = 0;
|
|
const errors: string[] = [];
|
|
const maxPages = 300;
|
|
|
|
while (emptyStreak < 2 && page <= maxPages) {
|
|
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
|
|
try {
|
|
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'خواندن تهحساب ناموفق بود';
|
|
errors.push(message);
|
|
break;
|
|
}
|
|
if (!listed.rows.length) {
|
|
emptyStreak += 1;
|
|
page += 1;
|
|
continue;
|
|
}
|
|
emptyStreak = 0;
|
|
const rowsByCode = new Map<number, Record<string, unknown>>();
|
|
for (const row of listed.rows) {
|
|
const code = pickCode(row);
|
|
if (code != null && code >= 1) rowsByCode.set(code, row);
|
|
}
|
|
const links = rowsByCode.size
|
|
? await this.prisma.personAccountingLink.findMany({
|
|
where: {
|
|
connectionId,
|
|
moshtariCode: { in: [...rowsByCode.keys()] },
|
|
},
|
|
select: { moshtariCode: true, personId: true },
|
|
})
|
|
: [];
|
|
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
|
|
for (const [code, row] of rowsByCode) {
|
|
try {
|
|
const personId = linkedByCode.get(code);
|
|
if (personId) {
|
|
await this.refreshLinkedCustomer(
|
|
organizationId,
|
|
connectionId,
|
|
personId,
|
|
code,
|
|
row,
|
|
);
|
|
updated += 1;
|
|
} else {
|
|
await this.importFromTahesab(
|
|
organizationId,
|
|
{ connectionId, moshtariCode: code },
|
|
{ api, row },
|
|
{ skipJob: true },
|
|
);
|
|
imported += 1;
|
|
}
|
|
} catch (err) {
|
|
failed += 1;
|
|
if (errors.length < 5) {
|
|
errors.push(err instanceof Error ? err.message : 'خطا');
|
|
}
|
|
}
|
|
}
|
|
page += 1;
|
|
}
|
|
|
|
if (page > maxPages && emptyStreak < 2) {
|
|
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
|
}
|
|
|
|
const summary = `${imported} وارد شد، ${updated} بهروز شد، ${failed} خطا`;
|
|
await this.prisma.accountingSyncJob.create({
|
|
data: {
|
|
organizationId,
|
|
connectionId,
|
|
kind: 'customer-sync',
|
|
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
|
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
|
return {
|
|
imported,
|
|
updated,
|
|
failed,
|
|
summary,
|
|
error: errors.length ? errors.join('؛ ') : null,
|
|
};
|
|
}
|
|
|
|
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
|
let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1);
|
|
const rowsByCode = new Map<number, Record<string, unknown>>();
|
|
|
|
if (dto.listAll) {
|
|
const { api } = await this.connections.getApi(
|
|
organizationId,
|
|
dto.connectionId,
|
|
);
|
|
const listed = await this.listMoshtariPage(
|
|
api,
|
|
dto.page ?? 1,
|
|
dto.pageSize,
|
|
);
|
|
for (const row of listed.rows) {
|
|
const code = pickCode(row);
|
|
if (code != null) rowsByCode.set(code, row);
|
|
}
|
|
codes = [...new Set([...codes, ...rowsByCode.keys()])];
|
|
} else if (dto.fromCode != null && dto.toCode != null) {
|
|
if (dto.toCode < dto.fromCode) {
|
|
throw new BadRequestException('کد پایان باید بزرگتر یا مساوی کد شروع باشد.');
|
|
}
|
|
if (dto.toCode - dto.fromCode > 2000) {
|
|
throw new BadRequestException(
|
|
'بازهٔ کد حداکثر ۲۰۰۰ عدد است. برای همه، بازه را خالی بگذارید.',
|
|
);
|
|
}
|
|
const { api } = await this.connections.getApi(
|
|
organizationId,
|
|
dto.connectionId,
|
|
);
|
|
const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
|
|
for (const row of asMoshtariList(raw)) {
|
|
const code = pickCode(row);
|
|
if (code != null) rowsByCode.set(code, row);
|
|
}
|
|
codes = [...new Set([...codes, ...rowsByCode.keys()])];
|
|
}
|
|
|
|
if (!codes.length) {
|
|
throw new BadRequestException(
|
|
'کد مشتری یا بازهٔ کد برای وارد کردن مشخص نشده است.',
|
|
);
|
|
}
|
|
const maxImport = dto.listAll || (dto.fromCode != null && dto.toCode != null) ? 2000 : 500;
|
|
if (codes.length > maxImport) {
|
|
throw new BadRequestException(
|
|
`حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). صفحه یا بازه را کوچکتر کنید.`,
|
|
);
|
|
}
|
|
|
|
const items: Array<{
|
|
moshtariCode: number;
|
|
ok: boolean;
|
|
personId?: string;
|
|
skipped?: boolean;
|
|
error?: string;
|
|
}> = [];
|
|
|
|
const existingLinks = await this.prisma.personAccountingLink.findMany({
|
|
where: { connectionId: dto.connectionId, moshtariCode: { in: codes } },
|
|
select: { moshtariCode: true, personId: true },
|
|
});
|
|
const linkedByCode = new Map(existingLinks.map((l) => [l.moshtariCode, l.personId]));
|
|
const pending = codes.filter((c) => !linkedByCode.has(c));
|
|
|
|
let bulkApi: TahesabApi | undefined;
|
|
const missingRows = pending.filter((c) => !rowsByCode.has(c));
|
|
if (pending.length) {
|
|
bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api;
|
|
}
|
|
if (bulkApi && missingRows.length) {
|
|
const minCode = Math.min(...missingRows);
|
|
const maxCode = Math.max(...missingRows);
|
|
if (maxCode - minCode <= 2000) {
|
|
try {
|
|
const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode);
|
|
for (const row of asMoshtariList(raw)) {
|
|
const code = pickCode(row);
|
|
if (code != null) rowsByCode.set(code, row);
|
|
}
|
|
} catch {
|
|
/* fallback: per-code fetch inside importFromTahesab */
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const moshtariCode of codes) {
|
|
try {
|
|
const linkedPersonId = linkedByCode.get(moshtariCode);
|
|
if (linkedPersonId) {
|
|
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
|
|
if (birthDate) {
|
|
await this.prisma.person.updateMany({
|
|
where: { id: linkedPersonId, birthDate: null },
|
|
data: { birthDate },
|
|
});
|
|
}
|
|
await this.applyTahesabGroup(
|
|
organizationId,
|
|
dto.connectionId,
|
|
linkedPersonId,
|
|
rowsByCode.get(moshtariCode),
|
|
);
|
|
items.push({
|
|
moshtariCode,
|
|
ok: true,
|
|
skipped: true,
|
|
personId: linkedPersonId,
|
|
});
|
|
continue;
|
|
}
|
|
const r = await this.importFromTahesab(
|
|
organizationId,
|
|
{ connectionId: dto.connectionId, moshtariCode },
|
|
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
|
|
);
|
|
items.push({ moshtariCode, ok: true, personId: r.personId });
|
|
} catch (e) {
|
|
const message =
|
|
e instanceof Error ? e.message : 'وارد کردن ناموفق بود';
|
|
items.push({ moshtariCode, ok: false, error: message });
|
|
}
|
|
}
|
|
|
|
const imported = items.filter((i) => i.ok && !i.skipped).length;
|
|
const skipped = items.filter((i) => i.skipped).length;
|
|
const failed = items.filter((i) => !i.ok).length;
|
|
|
|
await this.prisma.accountingSyncJob.create({
|
|
data: {
|
|
organizationId,
|
|
connectionId: dto.connectionId,
|
|
kind: 'import',
|
|
status: failed && !imported ? 'error' : 'success',
|
|
request: dto as unknown as Prisma.InputJsonValue,
|
|
result: { imported, skipped, failed, count: items.length } as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return { imported, skipped, failed, items };
|
|
}
|
|
|
|
/** یک صفحه (بازهٔ کد) از تهحساب: گروه مشتریهای لینکشده بهروز میشود */
|
|
async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) {
|
|
const { api } = await this.connections.getApi(organizationId, dto.connectionId);
|
|
const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize);
|
|
const rowsByCode = new Map<number, Record<string, unknown>>();
|
|
for (const row of listed.rows) {
|
|
const code = pickCode(row);
|
|
if (code != null) rowsByCode.set(code, row);
|
|
}
|
|
const links = rowsByCode.size
|
|
? await this.prisma.personAccountingLink.findMany({
|
|
where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } },
|
|
select: { moshtariCode: true, personId: true },
|
|
})
|
|
: [];
|
|
let updated = 0;
|
|
for (const link of links) {
|
|
const changed = await this.applyTahesabGroup(
|
|
organizationId,
|
|
dto.connectionId,
|
|
link.personId,
|
|
rowsByCode.get(link.moshtariCode),
|
|
);
|
|
if (changed) updated += 1;
|
|
}
|
|
return {
|
|
page: listed.page,
|
|
fromCode: listed.fromCode,
|
|
toCode: listed.toCode,
|
|
rows: listed.rows.length,
|
|
linked: links.length,
|
|
updated,
|
|
};
|
|
}
|
|
|
|
/** اشخاصی که روی این اتصال به تهحساب لینک نیستند */
|
|
async unlinkedPeople(organizationId: string, connectionId: string) {
|
|
await this.connections.getOrThrow(organizationId, connectionId);
|
|
const rows = await this.prisma.person.findMany({
|
|
where: { organizationId, accountingLinks: { none: { connectionId } } },
|
|
select: { id: true },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
return { ids: rows.map((r) => r.id), total: rows.length };
|
|
}
|
|
|
|
/**
|
|
* ثبت گروهی در تهحساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛
|
|
* تطبیق یکتا → لینک، چند نتیجه → رد میشود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری.
|
|
*/
|
|
async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) {
|
|
await this.connections.getOrThrow(organizationId, dto.connectionId);
|
|
const ids = [...new Set(dto.personIds)];
|
|
const people = await this.prisma.person.findMany({
|
|
where: { organizationId, id: { in: ids } },
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
accountingLinks: {
|
|
where: { connectionId: dto.connectionId },
|
|
select: { moshtariCode: true },
|
|
},
|
|
},
|
|
});
|
|
const byId = new Map(people.map((p) => [p.id, p]));
|
|
|
|
const items: Array<{
|
|
personId: string;
|
|
name: string;
|
|
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
|
|
moshtariCode?: number;
|
|
error?: string;
|
|
}> = [];
|
|
|
|
for (const personId of ids) {
|
|
const person = byId.get(personId);
|
|
if (!person) {
|
|
items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' });
|
|
continue;
|
|
}
|
|
const name = `${person.firstName} ${person.lastName}`.trim();
|
|
const existing = person.accountingLinks[0];
|
|
if (existing) {
|
|
items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode });
|
|
continue;
|
|
}
|
|
try {
|
|
const matched = await this.match(organizationId, personId, {
|
|
connectionId: dto.connectionId,
|
|
autoLinkIfUnique: true,
|
|
});
|
|
if (matched.status === 'linked' && matched.link) {
|
|
items.push({
|
|
personId,
|
|
name,
|
|
status: 'matched',
|
|
moshtariCode: matched.link.moshtariCode,
|
|
});
|
|
} else if (matched.status === 'none') {
|
|
const link = await this.createInTahesab(organizationId, personId, {
|
|
connectionId: dto.connectionId,
|
|
});
|
|
items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode });
|
|
} else {
|
|
items.push({ personId, name, status: 'ambiguous' });
|
|
}
|
|
} catch (e) {
|
|
items.push({
|
|
personId,
|
|
name,
|
|
status: 'error',
|
|
error: e instanceof Error ? e.message : 'ثبت ناموفق بود',
|
|
});
|
|
}
|
|
}
|
|
|
|
const count = (s: (typeof items)[number]['status']) =>
|
|
items.filter((i) => i.status === s).length;
|
|
const summary = {
|
|
matched: count('matched'),
|
|
created: count('created'),
|
|
ambiguous: count('ambiguous'),
|
|
skipped: count('skipped'),
|
|
failed: count('error'),
|
|
};
|
|
await this.prisma.accountingSyncJob.create({
|
|
data: {
|
|
organizationId,
|
|
connectionId: dto.connectionId,
|
|
kind: 'export',
|
|
status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success',
|
|
request: { count: ids.length } as Prisma.InputJsonValue,
|
|
result: summary as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return { ...summary, items };
|
|
}
|
|
|
|
/** مشتریهای قبلاً لینکشده که تاریخ تولد ندارند را از فهرست تهحساب پر میکند. */
|
|
async fillMissingBirthDates() {
|
|
const connections = await this.prisma.accountingConnection.findMany({
|
|
where: { enabled: true },
|
|
select: { id: true, organizationId: true },
|
|
});
|
|
let filled = 0;
|
|
for (const connection of connections) {
|
|
try {
|
|
const links = await this.prisma.personAccountingLink.findMany({
|
|
where: { connectionId: connection.id, person: { birthDate: null } },
|
|
select: { personId: true, moshtariCode: true },
|
|
});
|
|
if (!links.length) continue;
|
|
const recent = await this.prisma.accountingSyncJob.findFirst({
|
|
where: {
|
|
connectionId: connection.id,
|
|
kind: 'birthdate',
|
|
createdAt: { gte: new Date(Date.now() - 12 * 60 * 60 * 1000) },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (recent) continue;
|
|
await this.prisma.accountingSyncJob.create({
|
|
data: {
|
|
organizationId: connection.organizationId,
|
|
connectionId: connection.id,
|
|
kind: 'birthdate',
|
|
status: 'success',
|
|
},
|
|
});
|
|
const { api } = await this.connections.getApi(connection.organizationId, connection.id);
|
|
const codes = [...new Set(links.map((link) => link.moshtariCode))].sort((a, b) => a - b);
|
|
const personByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
|
|
let index = 0;
|
|
while (index < codes.length) {
|
|
const from = codes[index]!;
|
|
let end = index;
|
|
while (
|
|
end + 1 < codes.length &&
|
|
codes[end + 1]! - from < 2000 &&
|
|
end - index < 400
|
|
) {
|
|
end += 1;
|
|
}
|
|
const to = codes[end]!;
|
|
const raw = await api.doListMoshtariByCode(from, to);
|
|
for (const row of asMoshtariList(raw)) {
|
|
const code = pickCode(row);
|
|
const personId = code == null ? undefined : personByCode.get(code);
|
|
const birthDate = pickBirthDate(row);
|
|
if (!personId || !birthDate) continue;
|
|
const updated = await this.prisma.person.updateMany({
|
|
where: { id: personId, birthDate: null },
|
|
data: { birthDate },
|
|
});
|
|
filled += updated.count;
|
|
}
|
|
index = end + 1;
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`تاریخ تولد تهحساب برای اتصال ${connection.id} خوانده نشد: ${
|
|
err instanceof Error ? err.message : err
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از تهحساب پر شد`);
|
|
}
|
|
}
|
|
|
|
@Injectable()
|
|
export class TahesabBirthDateBackfill implements OnModuleInit {
|
|
constructor(private readonly links: TahesabPersonLinkService) {}
|
|
|
|
onModuleInit() {
|
|
void this.links.fillMissingBirthDates();
|
|
}
|
|
}
|