|
|
|
@@ -12,13 +12,16 @@ import { carryPreviousSource } from '../../contacts/person-source';
|
|
|
|
|
import { TahesabConnectionsService } from './connections.service';
|
|
|
|
|
import {
|
|
|
|
|
CreateInTahesabDto,
|
|
|
|
|
ExportPeopleToTahesabDto,
|
|
|
|
|
ImportBulkFromTahesabDto,
|
|
|
|
|
ImportFromTahesabDto,
|
|
|
|
|
ManualLinkDto,
|
|
|
|
|
MatchPersonDto,
|
|
|
|
|
SearchTahesabDto,
|
|
|
|
|
SyncGroupsPageDto,
|
|
|
|
|
} from './dto';
|
|
|
|
|
import { parseBirthDate } from '../../common/excel';
|
|
|
|
|
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
|
|
|
|
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
|
|
|
|
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
|
|
|
|
|
|
|
|
|
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
|
|
|
@@ -71,6 +74,23 @@ function pickBirthDate(row: Record<string, unknown>): Date | 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);
|
|
|
|
@@ -78,8 +98,37 @@ export class TahesabPersonLinkService {
|
|
|
|
|
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 },
|
|
|
|
@@ -305,10 +354,17 @@ export class TahesabPersonLinkService {
|
|
|
|
|
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: dto.groupName ?? 'CRM',
|
|
|
|
|
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);
|
|
|
|
@@ -628,6 +684,7 @@ export class TahesabPersonLinkService {
|
|
|
|
|
connection: { select: { label: true, dbName: true, isDefault: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
|
|
|
|
|
|
|
|
|
await this.prisma.accountingSyncJob.create({
|
|
|
|
|
data: {
|
|
|
|
@@ -645,6 +702,7 @@ export class TahesabPersonLinkService {
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
@@ -656,10 +714,11 @@ export class TahesabPersonLinkService {
|
|
|
|
|
dto.page ?? 1,
|
|
|
|
|
dto.pageSize,
|
|
|
|
|
);
|
|
|
|
|
const listedCodes = listed.rows
|
|
|
|
|
.map((row) => pickCode(row))
|
|
|
|
|
.filter((c): c is number => c != null);
|
|
|
|
|
codes = [...new Set([...codes, ...listedCodes])];
|
|
|
|
|
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('کد پایان باید بزرگتر یا مساوی کد شروع باشد.');
|
|
|
|
@@ -674,10 +733,11 @@ export class TahesabPersonLinkService {
|
|
|
|
|
dto.connectionId,
|
|
|
|
|
);
|
|
|
|
|
const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
|
|
|
|
|
const listed = asMoshtariList(raw)
|
|
|
|
|
.map((row) => pickCode(row))
|
|
|
|
|
.filter((c): c is number => c != null);
|
|
|
|
|
codes = [...new Set([...codes, ...listed])];
|
|
|
|
|
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) {
|
|
|
|
@@ -708,11 +768,13 @@ export class TahesabPersonLinkService {
|
|
|
|
|
const pending = codes.filter((c) => !linkedByCode.has(c));
|
|
|
|
|
|
|
|
|
|
let bulkApi: TahesabApi | undefined;
|
|
|
|
|
const rowsByCode = new Map<number, Record<string, unknown>>();
|
|
|
|
|
const missingRows = pending.filter((c) => !rowsByCode.has(c));
|
|
|
|
|
if (pending.length) {
|
|
|
|
|
bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api;
|
|
|
|
|
const minCode = Math.min(...pending);
|
|
|
|
|
const maxCode = Math.max(...pending);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
@@ -737,6 +799,12 @@ export class TahesabPersonLinkService {
|
|
|
|
|
data: { birthDate },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await this.applyTahesabGroup(
|
|
|
|
|
organizationId,
|
|
|
|
|
dto.connectionId,
|
|
|
|
|
linkedPersonId,
|
|
|
|
|
rowsByCode.get(moshtariCode),
|
|
|
|
|
);
|
|
|
|
|
items.push({
|
|
|
|
|
moshtariCode,
|
|
|
|
|
ok: true,
|
|
|
|
@@ -776,6 +844,146 @@ export class TahesabPersonLinkService {
|
|
|
|
|
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({
|
|
|
|
|