2026-09-04 22:00:23 +03:30
|
|
|
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,
|
2026-09-05 09:33:12 +03:30
|
|
|
ImportBulkFromTahesabDto,
|
2026-09-04 22:00:23 +03:30
|
|
|
ImportFromTahesabDto,
|
|
|
|
|
ManualLinkDto,
|
|
|
|
|
MatchPersonDto,
|
|
|
|
|
SearchTahesabDto,
|
|
|
|
|
} from './dto';
|
2026-09-05 10:30:54 +03:30
|
|
|
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
|
|
|
|
|
2026-10-04 11:09:40 +03:30
|
|
|
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
|
|
|
|
const LIST_PAGE_SIZE = 2000;
|
|
|
|
|
const LIST_TIMEOUT_MS = 55_000;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 10:41:12 +03:30
|
|
|
/**
|
2026-10-04 11:09:40 +03:30
|
|
|
* یک صفحه از مشتریان بر اساس بازهٔ کد:
|
|
|
|
|
* صفحه ۱ → ۱…pageSize، صفحه ۲ → pageSize+1…۲×pageSize، …
|
|
|
|
|
* سقف ۵۰۰۰ حذف شده؛ با پیجینیت میتوان همه را مرور کرد.
|
2026-09-05 10:41:12 +03:30
|
|
|
*/
|
2026-10-04 11:09:40 +03:30
|
|
|
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,
|
|
|
|
|
};
|
2026-09-05 10:30:54 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-04 22:00:23 +03:30
|
|
|
async search(organizationId: string, dto: SearchTahesabDto) {
|
|
|
|
|
const { api } = await this.connections.getApi(
|
|
|
|
|
organizationId,
|
|
|
|
|
dto.connectionId,
|
|
|
|
|
);
|
|
|
|
|
let raw: unknown = [];
|
2026-10-04 11:09:40 +03:30
|
|
|
let listMeta:
|
|
|
|
|
| {
|
|
|
|
|
page: number;
|
|
|
|
|
pageSize: number;
|
|
|
|
|
fromCode: number;
|
|
|
|
|
toCode: number;
|
|
|
|
|
}
|
|
|
|
|
| undefined;
|
|
|
|
|
|
2026-09-05 10:30:54 +03:30
|
|
|
if (dto.listAll) {
|
2026-10-04 11:09:40 +03:30
|
|
|
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,
|
|
|
|
|
};
|
2026-09-05 10:30:54 +03:30
|
|
|
} else if (dto.fromCode != null && dto.toCode != null) {
|
2026-09-05 09:33:12 +03:30
|
|
|
if (dto.toCode < dto.fromCode) {
|
|
|
|
|
throw new BadRequestException('کد پایان باید بزرگتر یا مساوی کد شروع باشد.');
|
|
|
|
|
}
|
|
|
|
|
if (dto.toCode - dto.fromCode > 2000) {
|
2026-09-05 10:30:54 +03:30
|
|
|
throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است (مثلاً ۱ تا ۲۰۰۰). برای همه، بازه را خالی بگذارید.');
|
2026-09-05 09:33:12 +03:30
|
|
|
}
|
2026-10-04 11:09:40 +03:30
|
|
|
raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode, {
|
|
|
|
|
timeoutMs: LIST_TIMEOUT_MS,
|
|
|
|
|
});
|
2026-09-05 09:33:12 +03:30
|
|
|
} else if (dto.moshtariCode) {
|
2026-09-04 22:00:23 +03:30
|
|
|
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 {
|
2026-09-05 09:33:12 +03:30
|
|
|
throw new BadRequestException(
|
2026-09-05 10:30:54 +03:30
|
|
|
'یکی از موارد لازم است: بازه کد (از–تا)، کد مشتری، موبایل یا نام. برای همهٔ مشتریان بازه را خالی بگذارید.',
|
2026-09-05 09:33:12 +03:30
|
|
|
);
|
2026-09-04 22:00:23 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-05 09:33:12 +03:30
|
|
|
const mapped = asMoshtariList(raw).map((row) => ({
|
2026-09-04 22:00:23 +03:30
|
|
|
moshtariCode: pickCode(row),
|
|
|
|
|
name: pickName(row),
|
|
|
|
|
tel: pickTel(row),
|
2026-09-05 10:41:12 +03:30
|
|
|
// raw فقط برای جستجوی محدود؛ در listAll حجم پاسخ را باد میکند و پروکسی را میترکاند
|
|
|
|
|
...(dto.listAll ? {} : { raw: row }),
|
2026-09-04 22:00:23 +03:30
|
|
|
}));
|
|
|
|
|
|
2026-09-05 09:33:12 +03:30
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 22:00:23 +03:30
|
|
|
await this.prisma.accountingSyncJob.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
connectionId: dto.connectionId,
|
|
|
|
|
kind: 'search',
|
|
|
|
|
status: 'success',
|
|
|
|
|
request: dto as unknown as Prisma.InputJsonValue,
|
2026-10-04 11:09:40 +03:30
|
|
|
result: {
|
|
|
|
|
count: results.length,
|
|
|
|
|
...(listMeta ?? {}),
|
|
|
|
|
} as Prisma.InputJsonValue,
|
2026-09-04 22:00:23 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-04 11:09:40 +03:30
|
|
|
return {
|
|
|
|
|
results,
|
|
|
|
|
...(listMeta
|
|
|
|
|
? {
|
|
|
|
|
...listMeta,
|
|
|
|
|
hasMore: results.length > 0,
|
|
|
|
|
note: `صفحه ${listMeta.page}: کد ${listMeta.fromCode} تا ${listMeta.toCode}. برای ادامه صفحه بعد را بزنید.`,
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
2026-09-04 22:00:23 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) };
|
|
|
|
|
}
|
2026-09-05 09:33:12 +03:30
|
|
|
|
|
|
|
|
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
|
|
|
|
let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1);
|
|
|
|
|
|
2026-09-05 10:30:54 +03:30
|
|
|
if (dto.listAll) {
|
|
|
|
|
const { api } = await this.connections.getApi(
|
|
|
|
|
organizationId,
|
|
|
|
|
dto.connectionId,
|
|
|
|
|
);
|
2026-10-04 11:09:40 +03:30
|
|
|
const listed = await this.listMoshtariPage(
|
|
|
|
|
api,
|
|
|
|
|
dto.page ?? 1,
|
|
|
|
|
dto.pageSize,
|
|
|
|
|
);
|
|
|
|
|
const listedCodes = listed.rows
|
2026-09-05 10:30:54 +03:30
|
|
|
.map((row) => pickCode(row))
|
|
|
|
|
.filter((c): c is number => c != null);
|
2026-10-04 11:09:40 +03:30
|
|
|
codes = [...new Set([...codes, ...listedCodes])];
|
2026-09-05 10:30:54 +03:30
|
|
|
} else if (dto.fromCode != null && dto.toCode != null) {
|
2026-09-05 09:33:12 +03:30
|
|
|
if (dto.toCode < dto.fromCode) {
|
|
|
|
|
throw new BadRequestException('کد پایان باید بزرگتر یا مساوی کد شروع باشد.');
|
|
|
|
|
}
|
|
|
|
|
if (dto.toCode - dto.fromCode > 2000) {
|
2026-09-05 10:30:54 +03:30
|
|
|
throw new BadRequestException(
|
|
|
|
|
'بازهٔ کد حداکثر ۲۰۰۰ عدد است. برای همه، بازه را خالی بگذارید.',
|
|
|
|
|
);
|
2026-09-05 09:33:12 +03:30
|
|
|
}
|
|
|
|
|
const { api } = await this.connections.getApi(
|
|
|
|
|
organizationId,
|
|
|
|
|
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])];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!codes.length) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'کد مشتری یا بازهٔ کد برای وارد کردن مشخص نشده است.',
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-04 11:09:40 +03:30
|
|
|
const maxImport = dto.listAll || (dto.fromCode != null && dto.toCode != null) ? 2000 : 500;
|
2026-09-05 10:30:54 +03:30
|
|
|
if (codes.length > maxImport) {
|
|
|
|
|
throw new BadRequestException(
|
2026-10-04 11:09:40 +03:30
|
|
|
`حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). صفحه یا بازه را کوچکتر کنید.`,
|
2026-09-05 10:30:54 +03:30
|
|
|
);
|
2026-09-05 09:33:12 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items: Array<{
|
|
|
|
|
moshtariCode: number;
|
|
|
|
|
ok: boolean;
|
|
|
|
|
personId?: string;
|
|
|
|
|
skipped?: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
for (const moshtariCode of codes) {
|
|
|
|
|
try {
|
|
|
|
|
const existing = await this.prisma.personAccountingLink.findFirst({
|
|
|
|
|
where: { connectionId: dto.connectionId, moshtariCode },
|
|
|
|
|
});
|
|
|
|
|
if (existing) {
|
|
|
|
|
items.push({
|
|
|
|
|
moshtariCode,
|
|
|
|
|
ok: true,
|
|
|
|
|
skipped: true,
|
|
|
|
|
personId: existing.personId,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const r = await this.importFromTahesab(organizationId, {
|
|
|
|
|
connectionId: dto.connectionId,
|
|
|
|
|
moshtariCode,
|
|
|
|
|
});
|
|
|
|
|
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 };
|
|
|
|
|
}
|
2026-09-04 22:00:23 +03:30
|
|
|
}
|