Add Tahesab jewelry accounting integration with multi-DB connections.

Encrypt tokens server-side, sync customers both ways, show balances/documents on the person Assets tab, and push sanads with idempotent Factor_Code logging.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-04 22:00:23 +03:30
co-authored by Cursor
parent d2f66d11fc
commit 4b47b753c3
26 changed files with 13784 additions and 8 deletions
@@ -0,0 +1,386 @@
import { Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabConnectionsService } from './connections.service';
import { PushSanadDto } from './dto';
import {
buildFactorCode,
SanadBuilders,
} from './tahesab-methods';
function asAsnadList(raw: unknown): Array<Record<string, unknown>> {
if (!raw) return [];
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
if (typeof raw === 'object') {
return Object.values(raw as Record<string, unknown>).filter(
(v) => v && typeof v === 'object',
) as Array<Record<string, unknown>>;
}
return [];
}
@Injectable()
export class TahesabDocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
) {}
private async requireLink(
organizationId: string,
personId: string,
connectionId: string,
) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) throw new NotFoundException('شخص یافت نشد.');
const link = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId },
});
if (!link) {
throw new BadRequestException(
'شخص به این اتصال ته‌حساب لینک نشده است.',
);
}
return link;
}
async listAsnad(
organizationId: string,
personId: string,
from: string,
to: string,
connectionId?: string,
) {
if (!/^\d{4}-\d{1,2}-\d{1,2}$/.test(from) || !/^\d{4}-\d{1,2}-\d{1,2}$/.test(to)) {
throw new BadRequestException(
'تاریخ از/تا باید شمسی با قالب YYYY-MM-DD باشد.',
);
}
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const link = await this.requireLink(organizationId, personId, resolvedId);
const { api } = await this.connections.getApi(organizationId, resolvedId);
// Count_Last = -1 برای مانده ردیف‌به‌ردیف مثل همکار
const raw = await api.doListAsnad(-1, link.moshtariCode, from, to);
const items = asAsnadList(raw);
return {
connectionId: resolvedId,
moshtariCode: link.moshtariCode,
from,
to,
items,
count: items.length,
};
}
async listLookups(organizationId: string, connectionId?: string) {
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const { api } = await this.connections.getApi(organizationId, resolvedId);
const [sekeh, arz, banks] = await Promise.all([
api.doListNameSekeh().catch(() => []),
api.doListNameArz().catch(() => []),
api.doListHesabBanki().catch(() => []),
]);
return { connectionId: resolvedId, sekeh, arz, banks };
}
async pushSanad(
organizationId: string,
personId: string,
dto: PushSanadDto,
) {
const link = await this.requireLink(
organizationId,
personId,
dto.connectionId,
);
const factorCode = dto.factorCode?.trim() || buildFactorCode('crm');
const existing = await this.prisma.accountingDocumentLog.findUnique({
where: {
connectionId_factorCode: {
connectionId: dto.connectionId,
factorCode,
},
},
});
if (existing?.status === 'success') {
return {
idempotent: true,
log: this.logDto(existing),
response: existing.responsePayload,
};
}
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const code = link.moshtariCode;
const sharh = dto.sharh ?? '';
let docTypeApi = '';
let args: unknown[] = [];
switch (dto.docType) {
case 'buy_sale_gold': {
if (dto.vazn == null || dto.ayar == null || dto.mazaneh == null || dto.mablaghKol == null || dto.buyOrSale == null) {
throw new BadRequestException(
'برای خرید/فروش آبشده: vazn، ayar، mazaneh، mablaghKol، buyOrSale الزامی است.',
);
}
docTypeApi = 'DoNewSanadBuySaleGOLD';
args = SanadBuilders.buySaleGold({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
vazn: dto.vazn,
ayar: dto.ayar,
buyOrSale: dto.buyOrSale,
mazaneh: dto.mazaneh,
mazanehIsGeram: dto.mazanehIsGeram ?? 0,
isAbshode: dto.isAbshode ?? 1,
mablaghKol: dto.mablaghKol,
sharh,
factorCode,
havalehBeMcode: dto.havalehBeMcode,
jensFelez: dto.jensFelez,
});
break;
}
case 'buy_sale_sekeh': {
if (
dto.vazn == null ||
dto.ayar == null ||
dto.count == null ||
!dto.nameSekeh ||
dto.buyOrSale == null ||
dto.mazaneh == null ||
dto.mablaghKol == null
) {
throw new BadRequestException(
'برای خرید/فروش سکه فیلدهای وزن، عیار، تعداد، نام سکه، خرید/فروش، مظنه و مبلغ کل الزامی است.',
);
}
docTypeApi = 'DoNewSanadBuySaleSEKEH';
args = SanadBuilders.buySaleSekeh({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
vazn: dto.vazn,
ayar: dto.ayar,
count: dto.count,
nameSekeh: dto.nameSekeh,
buyOrSale: dto.buyOrSale,
mazaneh: dto.mazaneh,
mablaghKol: dto.mablaghKol,
sharh,
factorCode,
jensFelez: dto.jensFelez,
arzName: dto.arzName,
});
break;
}
case 'talab_bedehi': {
if (dto.isBedehiOrTalab == null || dto.mablagh == null) {
throw new BadRequestException(
'برای طلب/بدهی: isBedehiOrTalab و mablagh الزامی است.',
);
}
docTypeApi = 'DoNewSanadTalabBedehi';
args = SanadBuilders.talabBedehi({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
isBedehiOrTalab: dto.isBedehiOrTalab,
mablagh: dto.mablagh,
sharh,
baabat: dto.baabat ?? 'CRM',
vazn: dto.vazn ?? 0,
factorCode,
havalehBeMcode: dto.havalehBeMcode,
jensFelez: dto.jensFelez,
arzName: dto.arzName,
});
break;
}
case 'vkh_gold': {
if (
dto.vazn == null ||
dto.ayar == null ||
dto.isVoroodOrKhorooj == null
) {
throw new BadRequestException(
'برای واریز/خروج طلا: vazn، ayar، isVoroodOrKhorooj الزامی است.',
);
}
docTypeApi = 'DoNewSanadVKHGOLD';
args = SanadBuilders.vkhGold({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
vazn: dto.vazn,
ayar: dto.ayar,
angNumber: dto.angNumber ?? '0',
nameAz: dto.nameAz ?? '',
isVoroodOrKhorooj: dto.isVoroodOrKhorooj,
isAbshode: dto.isAbshode ?? 1,
sharh,
factorCode,
});
break;
}
case 'vkh_bank': {
if (
dto.isDaryaftOrPardakht == null ||
dto.mablagh == null ||
!dto.nameBank
) {
throw new BadRequestException(
'برای واریز/خروج بانکی: isDaryaftOrPardakht، mablagh و nameBank الزامی است.',
);
}
docTypeApi = 'DoNewSanadVKHBank';
args = SanadBuilders.vkhBank({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
isDaryaftOrPardakht: dto.isDaryaftOrPardakht,
mablagh: dto.mablagh,
sharh,
nameBank: dto.nameBank,
codeRahgiri: dto.codeRahgiri ?? '',
factorCode,
});
break;
}
default:
throw new BadRequestException('نوع سند نامعتبر است.');
}
const requestPayload = { [docTypeApi]: args } as Prisma.InputJsonValue;
const log = existing
? await this.prisma.accountingDocumentLog.update({
where: { id: existing.id },
data: {
docType: docTypeApi,
requestPayload,
status: 'pending',
errorMessage: null,
personId,
},
})
: await this.prisma.accountingDocumentLog.create({
data: {
connectionId: dto.connectionId,
personId,
docType: docTypeApi,
factorCode,
requestPayload,
status: 'pending',
},
});
try {
let response: unknown;
switch (dto.docType) {
case 'buy_sale_gold':
response = await api.doNewSanadBuySaleGold(args);
break;
case 'buy_sale_sekeh':
response = await api.doNewSanadBuySaleSekeh(args);
break;
case 'talab_bedehi':
response = await api.doNewSanadTalabBedehi(args);
break;
case 'vkh_gold':
response = await api.doNewSanadVkhGold(args);
break;
case 'vkh_bank':
response = await api.doNewSanadVkhBank(args);
break;
}
const resObj = (response ?? {}) as Record<string, unknown>;
const okVal = resObj.OK ?? resObj.Factor_Code;
if (okVal === undefined || okVal === null || okVal === '') {
throw new BadRequestException(
'ته‌حساب پاسخ موفق معتبر برنگرداند: ' + JSON.stringify(response),
);
}
const updated = await this.prisma.accountingDocumentLog.update({
where: { id: log.id },
data: {
status: 'success',
responsePayload: response as Prisma.InputJsonValue,
tahesabOk: String(okVal),
tahesabFactorId:
typeof resObj.ID === 'number' ? resObj.ID : undefined,
errorMessage: null,
},
});
return {
idempotent: false,
log: this.logDto(updated),
response,
};
} catch (err) {
const message = (err as Error).message ?? 'خطای ناشناخته';
await this.prisma.accountingDocumentLog.update({
where: { id: log.id },
data: {
status: 'failed',
errorMessage: message.slice(0, 1000),
},
});
throw err;
}
}
private logDto(log: {
id: string;
connectionId: string;
personId: string | null;
docType: string;
factorCode: string;
status: string;
tahesabOk: string | null;
tahesabFactorId: number | null;
errorMessage: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: log.id,
connectionId: log.connectionId,
personId: log.personId,
docType: log.docType,
factorCode: log.factorCode,
status: log.status,
tahesabOk: log.tahesabOk,
tahesabFactorId: log.tahesabFactorId,
errorMessage: log.errorMessage,
createdAt: log.createdAt.toISOString(),
updatedAt: log.updatedAt.toISOString(),
};
}
}