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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user