feat: customer groups for people and companies with tahesab group sync and bulk register
Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user