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,128 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import {
|
||||
assertFactorCode,
|
||||
buildFactorCode,
|
||||
FACTOR_CODE_MIN_LEN,
|
||||
normalizeIranMobile,
|
||||
normalizeMandehResponse,
|
||||
SanadBuilders,
|
||||
} from '../tahesab-methods';
|
||||
|
||||
function assert(cond: unknown, msg: string): asserts cond {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
function testCryptoRoundtrip() {
|
||||
const key = randomBytes(32);
|
||||
const plain = 'test-tahesab-token-secret-value';
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const out = Buffer.concat([
|
||||
decipher.update(enc),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
assert(out === plain, 'crypto roundtrip failed');
|
||||
}
|
||||
|
||||
function testFactorCode() {
|
||||
const code = buildFactorCode('crm');
|
||||
assert(code.length >= FACTOR_CODE_MIN_LEN, 'factor code too short');
|
||||
assertFactorCode(code);
|
||||
let threw = false;
|
||||
try {
|
||||
assertFactorCode('short');
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assert(threw, 'short factor code should throw');
|
||||
}
|
||||
|
||||
function testMobile() {
|
||||
assert(normalizeIranMobile('09121234567') === '9121234567', 'mobile 09');
|
||||
assert(normalizeIranMobile('989121234567') === '9121234567', 'mobile 98');
|
||||
assert(normalizeIranMobile('9121234567') === '9121234567', 'mobile bare');
|
||||
}
|
||||
|
||||
function testMandehNormalize() {
|
||||
const raw = {
|
||||
MandeHesab: [
|
||||
{
|
||||
MandeyeVazni: 12.5,
|
||||
MandeyeMali: -1000,
|
||||
MandeyeKolBePool: 0,
|
||||
MandeyeKolBeTALA: 12.5,
|
||||
details: [
|
||||
{
|
||||
Name: 'طلا',
|
||||
Value1: 12.5,
|
||||
Value2: null,
|
||||
Value3: null,
|
||||
Value4: null,
|
||||
},
|
||||
{
|
||||
Name: 'ریال',
|
||||
Value1: null,
|
||||
Value2: null,
|
||||
Value3: null,
|
||||
Value4: -1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const n = normalizeMandehResponse(raw);
|
||||
assert(n.goldBalance === 12.5, 'gold');
|
||||
assert(n.rialBalance === -1000, 'rial');
|
||||
assert(n.mandehDetail.length === 2, 'detail count');
|
||||
assert(n.mandehDetail[1]!.values[0]!.status === 'بس', 'debtor status');
|
||||
}
|
||||
|
||||
function testSanadBuilders() {
|
||||
const fc = buildFactorCode('crm');
|
||||
const gold = SanadBuilders.buySaleGold({
|
||||
moshtariCode: 1,
|
||||
year: 1405,
|
||||
month: 1,
|
||||
day: 1,
|
||||
vazn: 1,
|
||||
ayar: 750,
|
||||
buyOrSale: 0,
|
||||
mazaneh: 100,
|
||||
mazanehIsGeram: 0,
|
||||
isAbshode: 1,
|
||||
mablaghKol: 1000,
|
||||
sharh: 't',
|
||||
factorCode: fc,
|
||||
});
|
||||
assert(gold.length >= 20, 'gold args length');
|
||||
assert(gold[17] === fc, 'factor at index 17');
|
||||
|
||||
const bank = SanadBuilders.vkhBank({
|
||||
moshtariCode: 1,
|
||||
year: 1405,
|
||||
month: 1,
|
||||
day: 1,
|
||||
isDaryaftOrPardakht: 1,
|
||||
mablagh: 5000,
|
||||
sharh: 't',
|
||||
nameBank: 'سامان',
|
||||
codeRahgiri: '1',
|
||||
factorCode: fc,
|
||||
});
|
||||
assert(bank[12] === fc, 'bank factor index');
|
||||
}
|
||||
|
||||
function main() {
|
||||
testCryptoRoundtrip();
|
||||
testFactorCode();
|
||||
testMobile();
|
||||
testMandehNormalize();
|
||||
testSanadBuilders();
|
||||
console.log('tahesab unit checks: OK');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import { normalizeMandehResponse } from './tahesab-methods';
|
||||
|
||||
@Injectable()
|
||||
export class TahesabBalanceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly connections: TahesabConnectionsService,
|
||||
) {}
|
||||
|
||||
async getAssets(
|
||||
organizationId: string,
|
||||
personId: string,
|
||||
connectionId?: string,
|
||||
) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
});
|
||||
if (!person) throw new NotFoundException('شخص یافت نشد.');
|
||||
|
||||
const resolvedId = await this.connections.resolveConnectionId(
|
||||
organizationId,
|
||||
connectionId,
|
||||
);
|
||||
|
||||
const link = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId, connectionId: resolvedId },
|
||||
include: {
|
||||
connection: {
|
||||
select: { id: true, label: true, dbName: true, isDefault: true },
|
||||
},
|
||||
balanceCache: true,
|
||||
},
|
||||
});
|
||||
if (!link) {
|
||||
throw new BadRequestException(
|
||||
'این شخص به اتصال تهحساب لینک نشده است. ابتدا تطبیق یا ثبت در تهحساب را انجام دهید.',
|
||||
);
|
||||
}
|
||||
|
||||
const { api } = await this.connections.getApi(organizationId, resolvedId);
|
||||
const raw = await api.getMandeHesabByCode(link.moshtariCode);
|
||||
const normalized = normalizeMandehResponse(raw);
|
||||
|
||||
const cache = await this.prisma.accountingBalanceCache.upsert({
|
||||
where: { linkId: link.id },
|
||||
create: {
|
||||
linkId: link.id,
|
||||
connectionId: resolvedId,
|
||||
goldBalance: normalized.goldBalance,
|
||||
rialBalance: normalized.rialBalance,
|
||||
currencyBalance: normalized.currencyBalance,
|
||||
totalGoldEquiv: normalized.totalGoldEquiv,
|
||||
mandehDetail: normalized.mandehDetail as unknown as Prisma.InputJsonValue,
|
||||
rawResponse: normalized.raw as Prisma.InputJsonValue,
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
goldBalance: normalized.goldBalance,
|
||||
rialBalance: normalized.rialBalance,
|
||||
currencyBalance: normalized.currencyBalance,
|
||||
totalGoldEquiv: normalized.totalGoldEquiv,
|
||||
mandehDetail: normalized.mandehDetail as unknown as Prisma.InputJsonValue,
|
||||
rawResponse: normalized.raw as Prisma.InputJsonValue,
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.personAccountingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { lastBalanceSyncedAt: cache.fetchedAt },
|
||||
});
|
||||
|
||||
return {
|
||||
connectionId: resolvedId,
|
||||
connectionLabel: link.connection.label,
|
||||
dbName: link.connection.dbName,
|
||||
moshtariCode: link.moshtariCode,
|
||||
goldBalance: normalized.goldBalance,
|
||||
rialBalance: normalized.rialBalance,
|
||||
currencyBalance: normalized.currencyBalance,
|
||||
totalGoldEquiv: normalized.totalGoldEquiv,
|
||||
mandehDetail: normalized.mandehDetail,
|
||||
fetchedAt: cache.fetchedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getCachedOrFetch(
|
||||
organizationId: string,
|
||||
personId: string,
|
||||
connectionId?: string,
|
||||
refresh = true,
|
||||
) {
|
||||
if (refresh) {
|
||||
return this.getAssets(organizationId, personId, connectionId);
|
||||
}
|
||||
const resolvedId = await this.connections.resolveConnectionId(
|
||||
organizationId,
|
||||
connectionId,
|
||||
);
|
||||
const link = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId, connectionId: resolvedId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true } },
|
||||
balanceCache: true,
|
||||
},
|
||||
});
|
||||
if (!link?.balanceCache) {
|
||||
return this.getAssets(organizationId, personId, connectionId);
|
||||
}
|
||||
const c = link.balanceCache;
|
||||
return {
|
||||
connectionId: resolvedId,
|
||||
connectionLabel: link.connection.label,
|
||||
dbName: link.connection.dbName,
|
||||
moshtariCode: link.moshtariCode,
|
||||
goldBalance: c.goldBalance,
|
||||
rialBalance: c.rialBalance,
|
||||
currencyBalance: c.currencyBalance,
|
||||
totalGoldEquiv: c.totalGoldEquiv,
|
||||
mandehDetail: c.mandehDetail,
|
||||
fetchedAt: c.fetchedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabCryptoService } from './tahesab-crypto.service';
|
||||
import { TahesabHttpClient } from './tahesab-http.client';
|
||||
import { TahesabApi } from './tahesab-methods';
|
||||
import {
|
||||
CreateTahesabConnectionDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
} from './dto';
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
let u = url.trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
throw new BadRequestException('baseUrl باید با http:// یا https:// شروع شود.');
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabConnectionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: TahesabCryptoService,
|
||||
private readonly http: TahesabHttpClient,
|
||||
) {}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
dbName: string;
|
||||
tokenLast4: string;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
allowInsecureTls: boolean;
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
provider: row.provider,
|
||||
label: row.label,
|
||||
baseUrl: row.baseUrl,
|
||||
dbName: row.dbName,
|
||||
tokenMasked: row.tokenLast4 ? `••••••${row.tokenLast4}` : '••••',
|
||||
enabled: row.enabled,
|
||||
isDefault: row.isDefault,
|
||||
allowInsecureTls: row.allowInsecureTls,
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async list(organizationId: string) {
|
||||
const rows = await this.prisma.accountingConnection.findMany({
|
||||
where: { organizationId, provider: 'tahesab' },
|
||||
orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async getOrThrow(organizationId: string, id: string) {
|
||||
const row = await this.prisma.accountingConnection.findFirst({
|
||||
where: { id, organizationId, provider: 'tahesab' },
|
||||
});
|
||||
if (!row) throw new NotFoundException('اتصال تهحساب یافت نشد.');
|
||||
return row;
|
||||
}
|
||||
|
||||
async getApi(organizationId: string, connectionId: string) {
|
||||
const row = await this.getOrThrow(organizationId, connectionId);
|
||||
if (!row.enabled) {
|
||||
throw new BadRequestException('این اتصال غیرفعال است.');
|
||||
}
|
||||
const token = this.crypto.decrypt({
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
tag: row.tokenTag,
|
||||
});
|
||||
return {
|
||||
row,
|
||||
api: new TahesabApi(this.http, {
|
||||
baseUrl: row.baseUrl,
|
||||
token,
|
||||
dbName: row.dbName,
|
||||
allowInsecureTls: row.allowInsecureTls,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async resolveConnectionId(
|
||||
organizationId: string,
|
||||
connectionId?: string | null,
|
||||
) {
|
||||
if (connectionId) {
|
||||
await this.getOrThrow(organizationId, connectionId);
|
||||
return connectionId;
|
||||
}
|
||||
const def = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, provider: 'tahesab', enabled: true, isDefault: true },
|
||||
});
|
||||
if (def) return def.id;
|
||||
const any = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, provider: 'tahesab', enabled: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!any) {
|
||||
throw new BadRequestException(
|
||||
'هیچ اتصال فعالی به تهحساب تعریف نشده است. از تنظیمات یک اتصال اضافه کنید.',
|
||||
);
|
||||
}
|
||||
return any.id;
|
||||
}
|
||||
|
||||
private async clearOtherDefaults(organizationId: string, exceptId?: string) {
|
||||
await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
organizationId,
|
||||
provider: 'tahesab',
|
||||
...(exceptId ? { id: { not: exceptId } } : {}),
|
||||
},
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
async create(organizationId: string, dto: CreateTahesabConnectionDto) {
|
||||
const baseUrl = normalizeBaseUrl(dto.baseUrl);
|
||||
const existing = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, baseUrl, dbName: dto.dbName.trim() },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.',
|
||||
);
|
||||
}
|
||||
|
||||
const count = await this.prisma.accountingConnection.count({
|
||||
where: { organizationId, provider: 'tahesab' },
|
||||
});
|
||||
const makeDefault = dto.isDefault === true || count === 0;
|
||||
if (makeDefault) await this.clearOtherDefaults(organizationId);
|
||||
|
||||
const enc = this.crypto.encrypt(dto.token.trim());
|
||||
const row = await this.prisma.accountingConnection.create({
|
||||
data: {
|
||||
organizationId,
|
||||
provider: 'tahesab',
|
||||
label: dto.label.trim(),
|
||||
baseUrl,
|
||||
dbName: dto.dbName.trim(),
|
||||
tokenCiphertext: enc.ciphertext,
|
||||
tokenIv: enc.iv,
|
||||
tokenTag: enc.tag,
|
||||
tokenLast4: this.crypto.last4(dto.token),
|
||||
enabled: dto.enabled ?? true,
|
||||
isDefault: makeDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? false,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateTahesabConnectionDto,
|
||||
) {
|
||||
const row = await this.getOrThrow(organizationId, id);
|
||||
const baseUrl = dto.baseUrl ? normalizeBaseUrl(dto.baseUrl) : row.baseUrl;
|
||||
const dbName = dto.dbName?.trim() ?? row.dbName;
|
||||
|
||||
if (baseUrl !== row.baseUrl || dbName !== row.dbName) {
|
||||
const clash = await this.prisma.accountingConnection.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
baseUrl,
|
||||
dbName,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
if (clash) {
|
||||
throw new ConflictException(
|
||||
'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
tag: row.tokenTag,
|
||||
};
|
||||
let tokenLast4 = row.tokenLast4;
|
||||
if (dto.token?.trim()) {
|
||||
enc = this.crypto.encrypt(dto.token.trim());
|
||||
tokenLast4 = this.crypto.last4(dto.token);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.accountingConnection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
label: dto.label?.trim() ?? row.label,
|
||||
baseUrl,
|
||||
dbName,
|
||||
tokenCiphertext: enc.ciphertext,
|
||||
tokenIv: enc.iv,
|
||||
tokenTag: enc.tag,
|
||||
tokenLast4,
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string) {
|
||||
const row = await this.getOrThrow(organizationId, id);
|
||||
await this.prisma.accountingConnection.delete({ where: { id: row.id } });
|
||||
if (row.isDefault) {
|
||||
const next = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, provider: 'tahesab', enabled: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (next) {
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: next.id },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async test(organizationId: string, id: string) {
|
||||
const { row, api } = await this.getApi(organizationId, id);
|
||||
try {
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await api.checkHealthSsl();
|
||||
} catch {
|
||||
result = await api.checkHealthNoSsl();
|
||||
}
|
||||
const updated = await this.prisma.accountingConnection.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: true,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return { ok: true, connection: this.toDto(updated), health: result };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? 'خطای ناشناخته';
|
||||
const updated = await this.prisma.accountingConnection.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: false,
|
||||
lastError: message.slice(0, 500),
|
||||
},
|
||||
});
|
||||
return { ok: false, connection: this.toDto(updated), error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateTahesabConnectionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(500)
|
||||
baseUrl!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
dbName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(2000)
|
||||
token!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateTahesabConnectionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(500)
|
||||
baseUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
dbName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(2000)
|
||||
token?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
}
|
||||
|
||||
export class ManualLinkDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
moshtariCode!: number;
|
||||
}
|
||||
|
||||
export class MatchPersonDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
/** اگر true باشد و دقیقاً یک نتیجه باشد، لینک میشود */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoLinkIfUnique?: boolean;
|
||||
}
|
||||
|
||||
export class CreateInTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
jensFelez?: number;
|
||||
}
|
||||
|
||||
export class ImportFromTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
moshtariCode!: number;
|
||||
|
||||
/** اگر داده شود به این شخص لینک میشود؛ وگرنه شخص جدید ساخته میشود */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
}
|
||||
|
||||
export class SearchTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
moshtariCode?: number;
|
||||
}
|
||||
|
||||
export class ListAsnadQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
connectionId?: string;
|
||||
|
||||
/** تاریخ شمسی YYYY-MM-DD */
|
||||
@IsString()
|
||||
from!: string;
|
||||
|
||||
@IsString()
|
||||
to!: string;
|
||||
}
|
||||
|
||||
const SANAD_TYPES = [
|
||||
'buy_sale_gold',
|
||||
'buy_sale_sekeh',
|
||||
'talab_bedehi',
|
||||
'vkh_gold',
|
||||
'vkh_bank',
|
||||
] as const;
|
||||
|
||||
export class PushSanadDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@IsIn(SANAD_TYPES)
|
||||
docType!: (typeof SANAD_TYPES)[number];
|
||||
|
||||
@IsInt()
|
||||
year!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
month!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
day!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
sharh?: string;
|
||||
|
||||
// gold / sekeh / vkh gold
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
vazn?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
ayar?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([0, 1])
|
||||
buyOrSale?: 0 | 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
mazaneh?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([0, 1])
|
||||
mazanehIsGeram?: 0 | 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([0, 1])
|
||||
isAbshode?: 0 | 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
mablaghKol?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
count?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameSekeh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([0, 1])
|
||||
isBedehiOrTalab?: 0 | 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
mablagh?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baabat?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
havalehBeMcode?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
angNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameAz?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([0, 1])
|
||||
isVoroodOrKhorooj?: 0 | 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([0, 1])
|
||||
isDaryaftOrPardakht?: 0 | 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameBank?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
codeRahgiri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
jensFelez?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
arzName?: string;
|
||||
|
||||
/** اگر ارسال شود و قبلاً success بوده، همان پاسخ برمیگردد (retry امن) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
factorCode?: string;
|
||||
}
|
||||
|
||||
// silence unused import warnings for ValidateNested/Type if unused
|
||||
void ValidateNested;
|
||||
void Type;
|
||||
void IsUrl;
|
||||
@@ -0,0 +1,438 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type EncryptedSecret = {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TahesabCryptoService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private getKey(): Buffer {
|
||||
const raw = this.config.get<string>('INTEGRATIONS_ENCRYPTION_KEY')?.trim();
|
||||
if (!raw) {
|
||||
throw new ServiceUnavailableException(
|
||||
'INTEGRATIONS_ENCRYPTION_KEY تنظیم نشده است. بدون این کلید ذخیره توکن تهحساب ممکن نیست.',
|
||||
);
|
||||
}
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
return Buffer.from(raw, 'hex');
|
||||
}
|
||||
const b64 = Buffer.from(raw, 'base64');
|
||||
if (b64.length === 32) return b64;
|
||||
throw new ServiceUnavailableException(
|
||||
'INTEGRATIONS_ENCRYPTION_KEY باید ۳۲ بایت باشد (۶۴ کاراکتر hex یا base64 معادل).',
|
||||
);
|
||||
}
|
||||
|
||||
encrypt(plain: string): EncryptedSecret {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', this.getKey(), iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
ciphertext: enc.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: tag.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
decrypt(secret: EncryptedSecret): string {
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.getKey(),
|
||||
Buffer.from(secret.iv, 'base64'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(secret.tag, 'base64'));
|
||||
const dec = Buffer.concat([
|
||||
decipher.update(Buffer.from(secret.ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return dec.toString('utf8');
|
||||
}
|
||||
|
||||
maskToken(token: string): string {
|
||||
const t = token.trim();
|
||||
if (t.length <= 4) return '••••';
|
||||
return `••••••${t.slice(-4)}`;
|
||||
}
|
||||
|
||||
last4(token: string): string {
|
||||
const t = token.trim();
|
||||
return t.length <= 4 ? t : t.slice(-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { URL } from 'url';
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
Logger,
|
||||
RequestTimeoutException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
export type TahesabCallOptions = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
dbName: string;
|
||||
/** مسیر OpenAPI مثل DoNewMoshtari یا CheckHealth-SSL */
|
||||
path: string;
|
||||
/** کلید body؛ اگر خالی باشد فقط GET بدون body */
|
||||
bodyKey?: string;
|
||||
args?: unknown[];
|
||||
method?: 'GET' | 'POST';
|
||||
allowInsecureTls?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TahesabHttpClient {
|
||||
private readonly logger = new Logger(TahesabHttpClient.name);
|
||||
|
||||
async call<T = unknown>(opts: TahesabCallOptions): Promise<T> {
|
||||
const base = opts.baseUrl.replace(/\/+$/, '');
|
||||
const path = opts.path.replace(/^\/+/, '');
|
||||
const url = new URL(`${base}/${path}`);
|
||||
const method = opts.method ?? (opts.bodyKey ? 'POST' : 'GET');
|
||||
const timeoutMs = opts.timeoutMs ?? 30_000;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${opts.token}`,
|
||||
DBname: opts.dbName,
|
||||
DBName: opts.dbName,
|
||||
Accept: 'application/json',
|
||||
Host: url.host,
|
||||
};
|
||||
|
||||
let body: string | undefined;
|
||||
if (method === 'POST' && opts.bodyKey) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify({ [opts.bodyKey]: opts.args ?? [] });
|
||||
headers['Content-Length'] = Buffer.byteLength(body).toString();
|
||||
}
|
||||
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
const agent =
|
||||
isHttps && opts.allowInsecureTls
|
||||
? new https.Agent({ rejectUnauthorized: false })
|
||||
: undefined;
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const req = lib.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method,
|
||||
headers,
|
||||
timeout: timeoutMs,
|
||||
agent,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
let json: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = { raw: text };
|
||||
}
|
||||
}
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status < 200 || status >= 300) {
|
||||
this.logger.warn(
|
||||
`Tahesab ${method} ${path} → ${status}: ${text.slice(0, 400)}`,
|
||||
);
|
||||
reject(
|
||||
new BadGatewayException(
|
||||
`پاسخ نامعتبر تهحساب (${status}) برای ${path}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(json as T);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(
|
||||
new RequestTimeoutException(
|
||||
`مهلت اتصال به تهحساب برای ${path} به پایان رسید.`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
this.logger.error(`Tahesab call failed: ${path}`, err);
|
||||
reject(
|
||||
new BadGatewayException(
|
||||
`اتصال به تهحساب ناموفق بود: ${err.message}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { TahesabHttpClient } from './tahesab-http.client';
|
||||
|
||||
export type ConnCreds = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
dbName: string;
|
||||
allowInsecureTls?: boolean;
|
||||
};
|
||||
|
||||
/** حداقل طول Factor_Code طبق مستندات تهحساب */
|
||||
export const FACTOR_CODE_MIN_LEN = 20;
|
||||
|
||||
export function buildFactorCode(prefix = 'crm'): string {
|
||||
const stamp = Date.now().toString(36);
|
||||
const rand = randomBytes(12).toString('hex');
|
||||
const code = `${prefix}${stamp}${rand}`;
|
||||
return code.length >= FACTOR_CODE_MIN_LEN
|
||||
? code
|
||||
: code.padEnd(FACTOR_CODE_MIN_LEN, '0');
|
||||
}
|
||||
|
||||
export function assertFactorCode(code: string): void {
|
||||
if (!code || code.trim().length < FACTOR_CODE_MIN_LEN) {
|
||||
throw new Error(
|
||||
`Factor_Code باید حداقل ${FACTOR_CODE_MIN_LEN} کاراکتر باشد.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** نرمالسازی موبایل ایران برای جستجو در تهحساب */
|
||||
export function normalizeIranMobile(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
let d = input.replace(/[^\d]/g, '');
|
||||
if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
|
||||
if (d.startsWith('0') && d.length >= 11) d = d.slice(1);
|
||||
if (d.length < 10) return null;
|
||||
return d;
|
||||
}
|
||||
|
||||
export type MandehValue = {
|
||||
amount: string | number;
|
||||
unit: string;
|
||||
status: 'بس' | 'بد';
|
||||
};
|
||||
|
||||
export type MandehDetailItem = {
|
||||
Name: string;
|
||||
values: MandehValue[];
|
||||
};
|
||||
|
||||
export type NormalizedAssets = {
|
||||
goldBalance: number | null;
|
||||
rialBalance: number | null;
|
||||
currencyBalance: number | null;
|
||||
totalGoldEquiv: number | null;
|
||||
mandehDetail: MandehDetailItem[];
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function detailStatus(amount: number): 'بس' | 'بد' {
|
||||
return amount < 0 ? 'بس' : 'بد';
|
||||
}
|
||||
|
||||
/** نرمالسازی پاسخ getmandehesabbycode مثل همکار */
|
||||
export function normalizeMandehResponse(raw: unknown): NormalizedAssets {
|
||||
const root = raw as {
|
||||
MandeHesab?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const row = root?.MandeHesab?.[0];
|
||||
if (!row) {
|
||||
return {
|
||||
goldBalance: null,
|
||||
rialBalance: null,
|
||||
currencyBalance: null,
|
||||
totalGoldEquiv: null,
|
||||
mandehDetail: [],
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
const details = Array.isArray(row.details) ? row.details : [];
|
||||
const mandehDetail: MandehDetailItem[] = details.map((item) => {
|
||||
const it = item as Record<string, unknown>;
|
||||
const values: MandehValue[] = [];
|
||||
if (it.Value1 !== null && it.Value1 !== undefined) {
|
||||
const a = Number(it.Value1);
|
||||
values.push({
|
||||
amount: it.Value1 as string | number,
|
||||
unit: 'گرم',
|
||||
status: detailStatus(a),
|
||||
});
|
||||
}
|
||||
if (it.Value2 !== null && it.Value2 !== undefined) {
|
||||
const a = Number(it.Value2);
|
||||
values.push({
|
||||
amount: it.Value2 as string | number,
|
||||
unit: 'عدد',
|
||||
status: detailStatus(a),
|
||||
});
|
||||
}
|
||||
if (it.Value3 !== null && it.Value3 !== undefined) {
|
||||
const a = Number(it.Value3);
|
||||
values.push({
|
||||
amount: it.Value3 as string | number,
|
||||
unit: 'ارز',
|
||||
status: detailStatus(a),
|
||||
});
|
||||
}
|
||||
if (it.Value4 !== null && it.Value4 !== undefined) {
|
||||
const a = Number(it.Value4);
|
||||
values.push({
|
||||
amount: it.Value4 as string | number,
|
||||
unit: 'ریال',
|
||||
status: detailStatus(a),
|
||||
});
|
||||
}
|
||||
return { Name: String(it.Name ?? ''), values };
|
||||
});
|
||||
|
||||
return {
|
||||
goldBalance: num(row.MandeyeVazni),
|
||||
rialBalance: num(row.MandeyeMali),
|
||||
currencyBalance: num(row.MandeyeKolBePool),
|
||||
totalGoldEquiv: num(row.MandeyeKolBeTALA),
|
||||
mandehDetail,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export class TahesabApi {
|
||||
constructor(
|
||||
private readonly http: TahesabHttpClient,
|
||||
private readonly creds: ConnCreds,
|
||||
) {}
|
||||
|
||||
private call<T>(
|
||||
path: string,
|
||||
bodyKey?: string,
|
||||
args?: unknown[],
|
||||
method?: 'GET' | 'POST',
|
||||
) {
|
||||
return this.http.call<T>({
|
||||
...this.creds,
|
||||
path,
|
||||
bodyKey,
|
||||
args,
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
checkHealthSsl() {
|
||||
return this.call<{ Status?: string; Api_Status?: string }>(
|
||||
'CheckHealth-SSL',
|
||||
undefined,
|
||||
undefined,
|
||||
'GET',
|
||||
);
|
||||
}
|
||||
|
||||
checkHealthNoSsl() {
|
||||
return this.call<{ Status?: string; Api_Status?: string }>(
|
||||
'CheckHealth-No-SSL',
|
||||
undefined,
|
||||
undefined,
|
||||
'GET',
|
||||
);
|
||||
}
|
||||
|
||||
/** ثبت مشتری — پاسخ { OK: moshtariCode } */
|
||||
doNewMoshtari(args: unknown[]) {
|
||||
return this.call<{ OK?: number }>('DoNewMoshtari', 'DoNewMoshtari', args);
|
||||
}
|
||||
|
||||
doEditMoshtari(args: unknown[]) {
|
||||
return this.call('DoEditMoshtari', 'DoEditMoshtari', args);
|
||||
}
|
||||
|
||||
doListMoshtariByCode(fromCode: number, toCode: number) {
|
||||
return this.call(
|
||||
'DoListMoshtari-ByCode',
|
||||
'DoListMoshtari',
|
||||
[fromCode, toCode],
|
||||
);
|
||||
}
|
||||
|
||||
doListMoshtariByPhone(phone: string | number) {
|
||||
return this.call('DoListMoshtari-ByPhone', 'DoListMoshtari', [phone]);
|
||||
}
|
||||
|
||||
doListMoshtariByName(name: string) {
|
||||
return this.call('DoListMoshtari-ByName', 'DoListMoshtari', [name]);
|
||||
}
|
||||
|
||||
/** body key حساس به حروف کوچک مثل همکار */
|
||||
getMandeHesabByCode(moshtariCode: number | string) {
|
||||
return this.call(
|
||||
'GetMandeHesabByCode',
|
||||
'getmandehesabbycode',
|
||||
[String(moshtariCode)],
|
||||
);
|
||||
}
|
||||
|
||||
doListAsnad(
|
||||
countLast: number,
|
||||
moshtariCode: number,
|
||||
fromShamsi: string,
|
||||
toShamsi: string,
|
||||
) {
|
||||
return this.call('DoListAsnad', 'DoListAsnad', [
|
||||
countLast,
|
||||
moshtariCode,
|
||||
fromShamsi,
|
||||
toShamsi,
|
||||
]);
|
||||
}
|
||||
|
||||
doListNameSekeh() {
|
||||
return this.call('DoListNameSekeh', 'DoListNameSekeh', []);
|
||||
}
|
||||
|
||||
doListNameArz() {
|
||||
return this.call('DoListNameARZ', 'DoListNameARZ', []);
|
||||
}
|
||||
|
||||
doListHesabBanki() {
|
||||
return this.call('DoListHesabBanki', 'DoListHesabBanki', []);
|
||||
}
|
||||
|
||||
doNewSanadBuySaleGold(args: unknown[]) {
|
||||
return this.call('DoNewSanadBuySaleGOLD', 'DoNewSanadBuySaleGOLD', args);
|
||||
}
|
||||
|
||||
doNewSanadBuySaleSekeh(args: unknown[]) {
|
||||
return this.call('DoNewSanadBuySaleSEKEH', 'DoNewSanadBuySaleSEKEH', args);
|
||||
}
|
||||
|
||||
doNewSanadTalabBedehi(args: unknown[]) {
|
||||
return this.call('DoNewSanadTalabBedehi', 'DoNewSanadTalabBedehi', args);
|
||||
}
|
||||
|
||||
doNewSanadVkhGold(args: unknown[]) {
|
||||
return this.call('DoNewSanadVKHGOLD', 'DoNewSanadVKHGOLD', args);
|
||||
}
|
||||
|
||||
doNewSanadVkhBank(args: unknown[]) {
|
||||
return this.call('DoNewSanadVKHBank', 'DoNewSanadVKHBank', args);
|
||||
}
|
||||
}
|
||||
|
||||
/** سازندههای آرایه سند — ترتیب پارامترها قفلشده با OpenAPI/همکار */
|
||||
export const SanadBuilders = {
|
||||
buySaleGold(p: {
|
||||
moshtariCode: number;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
vazn: number;
|
||||
ayar: number;
|
||||
buyOrSale: 0 | 1;
|
||||
mazaneh: number;
|
||||
mazanehIsGeram: 0 | 1;
|
||||
isAbshode: 0 | 1;
|
||||
mablaghKol: number;
|
||||
sharh: string;
|
||||
factorCode: string;
|
||||
havalehBeMcode?: number;
|
||||
jensFelez?: number;
|
||||
zamanTasvie?: string;
|
||||
}): unknown[] {
|
||||
assertFactorCode(p.factorCode);
|
||||
return [
|
||||
1,
|
||||
p.moshtariCode,
|
||||
-1,
|
||||
1,
|
||||
p.year,
|
||||
p.month,
|
||||
p.day,
|
||||
p.vazn,
|
||||
p.ayar,
|
||||
123456,
|
||||
'Reygiri',
|
||||
p.buyOrSale,
|
||||
p.mazaneh,
|
||||
p.mazanehIsGeram,
|
||||
p.isAbshode,
|
||||
p.mablaghKol,
|
||||
p.sharh,
|
||||
p.factorCode,
|
||||
p.havalehBeMcode ?? -1,
|
||||
-1,
|
||||
p.jensFelez ?? 0,
|
||||
p.zamanTasvie ?? '',
|
||||
];
|
||||
},
|
||||
|
||||
buySaleSekeh(p: {
|
||||
moshtariCode: number;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
vazn: number;
|
||||
ayar: number;
|
||||
count: number;
|
||||
nameSekeh: string;
|
||||
buyOrSale: 0 | 1;
|
||||
mazaneh: number;
|
||||
mablaghKol: number;
|
||||
sharh: string;
|
||||
factorCode: string;
|
||||
jensFelez?: number;
|
||||
arzName?: string;
|
||||
}): unknown[] {
|
||||
assertFactorCode(p.factorCode);
|
||||
return [
|
||||
1,
|
||||
p.moshtariCode,
|
||||
-1,
|
||||
1,
|
||||
p.year,
|
||||
p.month,
|
||||
p.day,
|
||||
p.vazn,
|
||||
p.ayar,
|
||||
p.count,
|
||||
p.nameSekeh,
|
||||
p.buyOrSale,
|
||||
p.mazaneh,
|
||||
p.mablaghKol,
|
||||
p.sharh,
|
||||
p.factorCode,
|
||||
-1,
|
||||
-1,
|
||||
p.jensFelez ?? 0,
|
||||
p.arzName ?? 'IRR',
|
||||
];
|
||||
},
|
||||
|
||||
/** قرارداد همکار HavalehTahesabService */
|
||||
talabBedehi(p: {
|
||||
moshtariCode: number;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
isBedehiOrTalab: 0 | 1;
|
||||
mablagh: number;
|
||||
sharh: string;
|
||||
baabat: string;
|
||||
vazn: number;
|
||||
factorCode: string;
|
||||
havalehBeMcode?: number;
|
||||
jensFelez?: number;
|
||||
arzName?: string;
|
||||
}): unknown[] {
|
||||
assertFactorCode(p.factorCode);
|
||||
return [
|
||||
1,
|
||||
p.moshtariCode,
|
||||
-1,
|
||||
0,
|
||||
p.year,
|
||||
p.month,
|
||||
p.day,
|
||||
p.isBedehiOrTalab,
|
||||
p.mablagh,
|
||||
p.sharh,
|
||||
p.baabat,
|
||||
p.vazn,
|
||||
p.factorCode,
|
||||
p.havalehBeMcode ?? -1,
|
||||
0,
|
||||
p.jensFelez ?? 0,
|
||||
p.arzName ?? 'ریال',
|
||||
];
|
||||
},
|
||||
|
||||
vkhGold(p: {
|
||||
moshtariCode: number;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
vazn: number;
|
||||
ayar: number;
|
||||
angNumber: string | number;
|
||||
nameAz: string;
|
||||
isVoroodOrKhorooj: 0 | 1;
|
||||
isAbshode: 0 | 1;
|
||||
sharh: string;
|
||||
factorCode: string;
|
||||
isSharti?: boolean;
|
||||
}): unknown[] {
|
||||
assertFactorCode(p.factorCode);
|
||||
return [
|
||||
1,
|
||||
p.moshtariCode,
|
||||
-1,
|
||||
1,
|
||||
p.year,
|
||||
p.month,
|
||||
p.day,
|
||||
p.vazn,
|
||||
p.ayar,
|
||||
p.angNumber,
|
||||
p.nameAz,
|
||||
p.isVoroodOrKhorooj,
|
||||
p.isAbshode,
|
||||
p.sharh,
|
||||
p.factorCode,
|
||||
-1,
|
||||
-1,
|
||||
0,
|
||||
p.isSharti ?? false,
|
||||
];
|
||||
},
|
||||
|
||||
vkhBank(p: {
|
||||
moshtariCode: number;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
isDaryaftOrPardakht: 0 | 1;
|
||||
mablagh: number;
|
||||
sharh: string;
|
||||
nameBank: string;
|
||||
codeRahgiri: string;
|
||||
factorCode: string;
|
||||
}): unknown[] {
|
||||
assertFactorCode(p.factorCode);
|
||||
return [
|
||||
1,
|
||||
p.moshtariCode,
|
||||
-1,
|
||||
1,
|
||||
p.year,
|
||||
p.month,
|
||||
p.day,
|
||||
p.isDaryaftOrPardakht,
|
||||
p.mablagh,
|
||||
p.sharh,
|
||||
p.nameBank,
|
||||
p.codeRahgiri,
|
||||
p.factorCode,
|
||||
-1,
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
newMoshtari(p: {
|
||||
name: string;
|
||||
groupName?: string;
|
||||
tel?: string;
|
||||
address?: string;
|
||||
codeMeli?: string;
|
||||
tarikhTavalod?: string;
|
||||
moaref?: string;
|
||||
codeMoaref?: string;
|
||||
moshtariCode?: number;
|
||||
jensFelez?: number;
|
||||
}): unknown[] {
|
||||
return [
|
||||
p.name,
|
||||
p.groupName ?? 'CRM',
|
||||
p.tel ?? '',
|
||||
p.address ?? '',
|
||||
p.codeMeli ?? '',
|
||||
p.tarikhTavalod ?? '',
|
||||
p.moaref ?? '',
|
||||
p.codeMoaref ?? '-1',
|
||||
p.moshtariCode ?? -1,
|
||||
p.jensFelez ?? 0,
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../auth/current-user.decorator';
|
||||
import { Roles } from '../../auth/roles.decorator';
|
||||
import { RolesGuard } from '../../auth/roles.guard';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import {
|
||||
CreateInTahesabDto,
|
||||
CreateTahesabConnectionDto,
|
||||
ImportFromTahesabDto,
|
||||
ManualLinkDto,
|
||||
MatchPersonDto,
|
||||
PushSanadDto,
|
||||
SearchTahesabDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('integrations/tahesab')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class TahesabController {
|
||||
constructor(
|
||||
private readonly connections: TahesabConnectionsService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
private readonly balance: TahesabBalanceService,
|
||||
private readonly documents: TahesabDocumentsService,
|
||||
) {}
|
||||
|
||||
// —— Connections ——
|
||||
/** لیست اتصالات برای انتخاب در پروفایل (بدون توکن خام) */
|
||||
@Get('connections/public')
|
||||
listPublicConnections(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.connections.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Get('connections')
|
||||
@Roles('owner', 'admin')
|
||||
listConnections(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.connections.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('connections')
|
||||
@Roles('owner', 'admin')
|
||||
createConnection(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateTahesabConnectionDto,
|
||||
) {
|
||||
return this.connections.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('connections/:id')
|
||||
@Roles('owner', 'admin')
|
||||
updateConnection(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTahesabConnectionDto,
|
||||
) {
|
||||
return this.connections.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('connections/:id')
|
||||
@Roles('owner', 'admin')
|
||||
removeConnection(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.connections.remove(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Post('connections/:id/test')
|
||||
@Roles('owner', 'admin')
|
||||
testConnection(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.connections.test(user.organizationId, id);
|
||||
}
|
||||
|
||||
// —— Person links (all authenticated CRM users in org) ——
|
||||
@Get('people/:personId/links')
|
||||
listLinks(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
) {
|
||||
return this.links.listLinks(user.organizationId, personId);
|
||||
}
|
||||
|
||||
@Post('people/:personId/links')
|
||||
manualLink(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: ManualLinkDto,
|
||||
) {
|
||||
return this.links.manualLink(user.organizationId, personId, dto);
|
||||
}
|
||||
|
||||
@Delete('people/:personId/links/:connectionId')
|
||||
unlink(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
@Param('connectionId') connectionId: string,
|
||||
) {
|
||||
return this.links.unlink(user.organizationId, personId, connectionId);
|
||||
}
|
||||
|
||||
@Post('people/:personId/match')
|
||||
match(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: MatchPersonDto,
|
||||
) {
|
||||
return this.links.match(user.organizationId, personId, dto);
|
||||
}
|
||||
|
||||
@Post('people/:personId/create-in-tahesab')
|
||||
createInTahesab(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: CreateInTahesabDto,
|
||||
) {
|
||||
return this.links.createInTahesab(user.organizationId, personId, dto);
|
||||
}
|
||||
|
||||
@Get('people/:personId/assets')
|
||||
getAssets(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('refresh') refresh?: string,
|
||||
) {
|
||||
return this.balance.getCachedOrFetch(
|
||||
user.organizationId,
|
||||
personId,
|
||||
connectionId,
|
||||
refresh !== '0',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('people/:personId/asnad')
|
||||
listAsnad(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
@Query('from') from: string,
|
||||
@Query('to') to: string,
|
||||
@Query('connectionId') connectionId?: string,
|
||||
) {
|
||||
return this.documents.listAsnad(
|
||||
user.organizationId,
|
||||
personId,
|
||||
from,
|
||||
to,
|
||||
connectionId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('lookups')
|
||||
lookups(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
) {
|
||||
return this.documents.listLookups(user.organizationId, connectionId);
|
||||
}
|
||||
|
||||
@Post('people/:personId/sanad')
|
||||
@Roles('owner', 'admin', 'sales')
|
||||
pushSanad(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: PushSanadDto,
|
||||
) {
|
||||
return this.documents.pushSanad(user.organizationId, personId, dto);
|
||||
}
|
||||
|
||||
@Post('sync/search')
|
||||
@Roles('owner', 'admin', 'sales')
|
||||
search(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: SearchTahesabDto,
|
||||
) {
|
||||
return this.links.search(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Post('sync/import')
|
||||
@Roles('owner', 'admin', 'sales')
|
||||
importFromTahesab(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ImportFromTahesabDto,
|
||||
) {
|
||||
return this.links.importFromTahesab(user.organizationId, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { TahesabCryptoService } from './tahesab-crypto.service';
|
||||
import { TahesabHttpClient } from './tahesab-http.client';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [TahesabController],
|
||||
providers: [
|
||||
TahesabCryptoService,
|
||||
TahesabHttpClient,
|
||||
TahesabConnectionsService,
|
||||
TahesabPersonLinkService,
|
||||
TahesabBalanceService,
|
||||
TahesabDocumentsService,
|
||||
],
|
||||
exports: [
|
||||
TahesabConnectionsService,
|
||||
TahesabPersonLinkService,
|
||||
TahesabBalanceService,
|
||||
TahesabDocumentsService,
|
||||
],
|
||||
})
|
||||
export class TahesabModule {}
|
||||
Reference in New Issue
Block a user