439 lines
12 KiB
TypeScript
439 lines
12 KiB
TypeScript
import {
|
|||
|
|
BadRequestException,
|
||
|
|
ConflictException,
|
||
|
|
Injectable,
|
||
|
|
NotFoundException,
|
||
|
|
} from '@nestjs/common';
|
||
|
|
import { Prisma } from '@prisma/client';
|
||
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
||
|
|
import { TahesabConnectionsService } from './connections.service';
|
||
|
|
import {
|
||
|
|
CreateInTahesabDto,
|
||
|
|
ImportFromTahesabDto,
|
||
|
|
ManualLinkDto,
|
||
|
|
MatchPersonDto,
|
||
|
|
SearchTahesabDto,
|
||
|
|
} from './dto';
|
||
|
|
import { normalizeIranMobile, SanadBuilders } from './tahesab-methods';
|
||
|
|
|
||
|
|
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 ?? '');
|
||
|
|
}
|
||
|
|
|
||
|
|
@Injectable()
|
||
|
|
export class TahesabPersonLinkService {
|
||
|
|
constructor(
|
||
|
|
private readonly prisma: PrismaService,
|
||
|
|
private readonly connections: TahesabConnectionsService,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
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 args = SanadBuilders.newMoshtari({
|
||
|
|
name: `${person.firstName} ${person.lastName}`.trim(),
|
||
|
|
groupName: dto.groupName ?? 'CRM',
|
||
|
|
tel,
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
async search(organizationId: string, dto: SearchTahesabDto) {
|
||
|
|
const { api } = await this.connections.getApi(
|
||
|
|
organizationId,
|
||
|
|
dto.connectionId,
|
||
|
|
);
|
||
|
|
let raw: unknown = [];
|
||
|
|
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('یکی از phone، name یا moshtariCode لازم است.');
|
||
|
|
}
|
||
|
|
|
||
|
|
const results = asMoshtariList(raw).map((row) => ({
|
||
|
|
moshtariCode: pickCode(row),
|
||
|
|
name: pickName(row),
|
||
|
|
tel: pickTel(row),
|
||
|
|
raw: row,
|
||
|
|
}));
|
||
|
|
|
||
|
|
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 } as Prisma.InputJsonValue,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return { results };
|
||
|
|
}
|
||
|
|
|
||
|
|
async importFromTahesab(
|
||
|
|
organizationId: string,
|
||
|
|
dto: ImportFromTahesabDto,
|
||
|
|
) {
|
||
|
|
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}» لینک شده است.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const { api } = await this.connections.getApi(
|
||
|
|
organizationId,
|
||
|
|
dto.connectionId,
|
||
|
|
);
|
||
|
|
const raw = await api.doListMoshtariByCode(
|
||
|
|
dto.moshtariCode,
|
||
|
|
dto.moshtariCode,
|
||
|
|
);
|
||
|
|
const rows = asMoshtariList(raw);
|
||
|
|
const row = rows[0] ?? {};
|
||
|
|
const name = pickName(row) || `مشتری تهحساب ${dto.moshtariCode}`;
|
||
|
|
const tel = pickTel(row);
|
||
|
|
const parts = name.trim().split(/\s+/);
|
||
|
|
const firstName = parts[0] || name;
|
||
|
|
const lastName = parts.slice(1).join(' ') || '—';
|
||
|
|
|
||
|
|
let personId = dto.personId;
|
||
|
|
if (personId) {
|
||
|
|
await this.getPerson(organizationId, personId);
|
||
|
|
} else {
|
||
|
|
const created = await this.prisma.person.create({
|
||
|
|
data: {
|
||
|
|
organizationId,
|
||
|
|
firstName,
|
||
|
|
lastName,
|
||
|
|
mobile: tel || null,
|
||
|
|
phone: tel || null,
|
||
|
|
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
personId = created.id;
|
||
|
|
}
|
||
|
|
|
||
|
|
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.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) };
|
||
|
|
}
|
||
|
|
}
|