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
+4
View File
@@ -20,4 +20,8 @@ SMS_SENDER=
# SMS_OTP_TEMPLATE=کد ورود پرتال منتو: {code}
# SMS_TIMEOUT_MS=20000
# رمزنگاری توکن‌های اتصال حسابداری (ته‌حساب) — ۳۲ بایت به‌صورت hex (۶۴ کاراکتر)
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INTEGRATIONS_ENCRYPTION_KEY=
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
@@ -0,0 +1,156 @@
-- CreateTable
CREATE TABLE "AccountingConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'tahesab',
"label" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"dbName" TEXT NOT NULL,
"tokenCiphertext" TEXT NOT NULL,
"tokenIv" TEXT NOT NULL,
"tokenTag" TEXT NOT NULL,
"tokenLast4" TEXT NOT NULL DEFAULT '',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"allowInsecureTls" BOOLEAN NOT NULL DEFAULT false,
"lastHealthAt" TIMESTAMP(3),
"lastHealthOk" BOOLEAN,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PersonAccountingLink" (
"id" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"moshtariCode" INTEGER NOT NULL,
"matchMethod" TEXT NOT NULL,
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastBalanceSyncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonAccountingLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingDocumentLog" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"personId" TEXT,
"docType" TEXT NOT NULL,
"factorCode" TEXT NOT NULL,
"requestPayload" JSONB NOT NULL,
"responsePayload" JSONB,
"status" TEXT NOT NULL DEFAULT 'pending',
"tahesabOk" TEXT,
"tahesabFactorId" INTEGER,
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingDocumentLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingBalanceCache" (
"id" TEXT NOT NULL,
"linkId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"goldBalance" DOUBLE PRECISION,
"rialBalance" DOUBLE PRECISION,
"currencyBalance" DOUBLE PRECISION,
"totalGoldEquiv" DOUBLE PRECISION,
"mandehDetail" JSONB NOT NULL DEFAULT '[]',
"rawResponse" JSONB,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingBalanceCache_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingSyncJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT,
"kind" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"request" JSONB NOT NULL DEFAULT '{}',
"result" JSONB,
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingSyncJob_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AccountingConnection_organizationId_enabled_idx" ON "AccountingConnection"("organizationId", "enabled");
-- CreateIndex
CREATE INDEX "AccountingConnection_organizationId_isDefault_idx" ON "AccountingConnection"("organizationId", "isDefault");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingConnection_organizationId_baseUrl_dbName_key" ON "AccountingConnection"("organizationId", "baseUrl", "dbName");
-- CreateIndex
CREATE INDEX "PersonAccountingLink_personId_idx" ON "PersonAccountingLink"("personId");
-- CreateIndex
CREATE INDEX "PersonAccountingLink_connectionId_idx" ON "PersonAccountingLink"("connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "PersonAccountingLink_personId_connectionId_key" ON "PersonAccountingLink"("personId", "connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "PersonAccountingLink_connectionId_moshtariCode_key" ON "PersonAccountingLink"("connectionId", "moshtariCode");
-- CreateIndex
CREATE INDEX "AccountingDocumentLog_connectionId_status_idx" ON "AccountingDocumentLog"("connectionId", "status");
-- CreateIndex
CREATE INDEX "AccountingDocumentLog_personId_idx" ON "AccountingDocumentLog"("personId");
-- CreateIndex
CREATE INDEX "AccountingDocumentLog_docType_idx" ON "AccountingDocumentLog"("docType");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingDocumentLog_connectionId_factorCode_key" ON "AccountingDocumentLog"("connectionId", "factorCode");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingBalanceCache_linkId_key" ON "AccountingBalanceCache"("linkId");
-- CreateIndex
CREATE INDEX "AccountingBalanceCache_connectionId_idx" ON "AccountingBalanceCache"("connectionId");
-- CreateIndex
CREATE INDEX "AccountingSyncJob_organizationId_createdAt_idx" ON "AccountingSyncJob"("organizationId", "createdAt");
-- AddForeignKey
ALTER TABLE "AccountingConnection" ADD CONSTRAINT "AccountingConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PersonAccountingLink" ADD CONSTRAINT "PersonAccountingLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PersonAccountingLink" ADD CONSTRAINT "PersonAccountingLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingDocumentLog" ADD CONSTRAINT "AccountingDocumentLog_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingDocumentLog" ADD CONSTRAINT "AccountingDocumentLog_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingBalanceCache" ADD CONSTRAINT "AccountingBalanceCache_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingBalanceCache" ADD CONSTRAINT "AccountingBalanceCache_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingSyncJob" ADD CONSTRAINT "AccountingSyncJob_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+129 -6
View File
@@ -162,9 +162,11 @@ model Organization {
projectBoards ProjectBoard[]
projectCards ProjectCard[]
loyaltyTiers LoyaltyTier[]
automationRules AutomationRule[]
memoryItems MemoryItem[]
noteAiSuggestions NoteAiSuggestion[]
automationRules AutomationRule[]
memoryItems MemoryItem[]
noteAiSuggestions NoteAiSuggestion[]
accountingConnections AccountingConnection[]
accountingSyncJobs AccountingSyncJob[]
}
model User {
@@ -257,9 +259,11 @@ model Person {
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
projectCards ProjectCard[]
memoryItems MemoryItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memoryItems MemoryItem[]
accountingLinks PersonAccountingLink[]
accountingDocuments AccountingDocumentLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([organizationId, email])
@@ -929,3 +933,122 @@ model AutomationRule {
@@index([organizationId, isActive, trigger])
}
/// اتصال به نرم‌افزار حسابداری خارجی (ته‌حساب) — چند اتصال/DB به‌ازای هر سازمان
model AccountingConnection {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// فعلاً فقط tahesab
provider String @default("tahesab")
label String
baseUrl String
dbName String
/// AES-256-GCM ciphertext (base64)
tokenCiphertext String
tokenIv String
tokenTag String
/// چهار کاراکتر آخر توکن برای نمایش ماسک‌شده
tokenLast4 String @default("")
enabled Boolean @default(true)
isDefault Boolean @default(false)
/// اگر true باشد verify SSL نادیده گرفته می‌شود (فقط برای نصب‌های داخلی با گواهی خودامضا)
allowInsecureTls Boolean @default(false)
lastHealthAt DateTime?
lastHealthOk Boolean?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[]
documentLogs AccountingDocumentLog[]
balanceCaches AccountingBalanceCache[]
@@unique([organizationId, baseUrl, dbName])
@@index([organizationId, enabled])
@@index([organizationId, isDefault])
}
model PersonAccountingLink {
id String @id @default(cuid())
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
/// کد مشتری در ته‌حساب (Moshtari_Code)
moshtariCode Int
/// manual | mobile | name | created
matchMethod String
linkedAt DateTime @default(now())
lastBalanceSyncedAt DateTime?
balanceCache AccountingBalanceCache?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([personId, connectionId])
@@unique([connectionId, moshtariCode])
@@index([personId])
@@index([connectionId])
}
model AccountingDocumentLog {
id String @id @default(cuid())
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
/// مثلاً DoNewSanadBuySaleGOLD
docType String
/// کد یکتای ≥۲۰ کاراکتر — idempotency
factorCode String
requestPayload Json
responsePayload Json?
/// pending | success | failed
status String @default("pending")
tahesabOk String?
tahesabFactorId Int?
errorMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, factorCode])
@@index([connectionId, status])
@@index([personId])
@@index([docType])
}
model AccountingBalanceCache {
id String @id @default(cuid())
linkId String @unique
link PersonAccountingLink @relation(fields: [linkId], references: [id], onDelete: Cascade)
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
goldBalance Float?
rialBalance Float?
currencyBalance Float?
/// مانده هم‌ارز طلا (MandeyeKolBeTALA) در صورت وجود
totalGoldEquiv Float?
/// جزئیات گروه‌بندی‌شده برای UI
mandehDetail Json @default("[]")
rawResponse Json?
fetchedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([connectionId])
}
model AccountingSyncJob {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
connectionId String?
/// search | import | match
kind String
status String @default("pending")
request Json @default("{}")
result Json?
errorMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, createdAt])
}
+2
View File
@@ -26,6 +26,7 @@ import { LoyaltyModule } from './loyalty/loyalty.module';
import { AutomationsModule } from './automations/automations.module';
import { AiModule } from './ai/ai.module';
import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
@Module({
imports: [
@@ -59,6 +60,7 @@ import { MemoryModule } from './memory/memory.module';
LoyaltyModule,
AutomationsModule,
MemoryModule,
TahesabModule,
],
})
export class AppModule {}
@@ -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(),
};
}
}
+290
View File
@@ -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 {}
@@ -0,0 +1,868 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import type {
PersonAccountingLinkDto,
PersonAssetsDto,
TahesabConnectionDto,
TahesabSanadType,
} from '@mento/shared';
import { TAHSAB_SANAD_LABELS } from '@/lib/tahesab';
const props = defineProps<{
personId: string;
}>();
const loading = ref(false);
const error = ref('');
const ok = ref('');
const connections = ref<TahesabConnectionDto[]>([]);
const links = ref<PersonAccountingLinkDto[]>([]);
const connectionId = ref('');
const assets = ref<PersonAssetsDto | null>(null);
const activeTab = ref<'مانده حساب' | 'جزئیات حساب' | 'ثبت سند'>('مانده حساب');
const asnadLoading = ref(false);
const asnadItems = ref<Record<string, unknown>[]>([]);
const fromDate = ref('');
const toDate = ref('');
const manualCode = ref('');
const matchCandidates = ref<
Array<{ moshtariCode: number; name: string; tel: string; matchBy: string }>
>([]);
const sanadBusy = ref(false);
const sanad = ref({
docType: 'buy_sale_gold' as TahesabSanadType,
year: 1405,
month: 1,
day: 1,
sharh: '',
vazn: 0,
ayar: 750,
buyOrSale: 0 as 0 | 1,
mazaneh: 0,
mazanehIsGeram: 0 as 0 | 1,
isAbshode: 1 as 0 | 1,
mablaghKol: 0,
count: 1,
nameSekeh: '',
isBedehiOrTalab: 0 as 0 | 1,
mablagh: 0,
baabat: 'CRM',
isVoroodOrKhorooj: 0 as 0 | 1,
angNumber: '',
nameAz: '',
isDaryaftOrPardakht: 1 as 0 | 1,
nameBank: '',
codeRahgiri: '',
});
const currentLink = computed(() =>
links.value.find((l) => l.connectionId === connectionId.value) ?? null,
);
const hasMultipleConnections = computed(() => connections.value.length > 1);
const goldBalance = computed(() => assets.value?.goldBalance ?? null);
const rialBalance = computed(() => assets.value?.rialBalance ?? null);
const mandehGroups = computed(() => {
const items = assets.value?.mandehDetail ?? [];
const groups: Record<string, typeof items> = {
مالی: [],
'فلزات/وزنی': [],
سکه: [],
کالا: [],
ارز: [],
سایر: [],
};
for (const item of items) {
const name = item.Name || '';
if (/ریال|تومان|مالی|پول|وجه/i.test(name)) groups['مالی']!.push(item);
else if (/سکه/i.test(name)) groups['سکه']!.push(item);
else if (/ارز|دلار|یورو/i.test(name)) groups['ارز']!.push(item);
else if (/طلا|نقره|پلاتین|وزن|آبشده|شمش/i.test(name))
groups['فلزات/وزنی']!.push(item);
else if (/کار|اتیکت|کالا/i.test(name)) groups['کالا']!.push(item);
else groups['سایر']!.push(item);
}
return Object.entries(groups)
.filter(([, list]) => list.length > 0)
.map(([label, list]) => ({ label, items: list }));
});
function balanceClass(v: number | null) {
if (v == null || v === 0) return '';
return v > 0 ? 'credit' : 'debt';
}
function balanceLabel(v: number | null) {
if (v == null || v === 0) return '';
return v > 0 ? 'بستانکار' : 'بدهکار';
}
function formatAmt(v: number | null, digits = 3) {
if (v == null) return '—';
return Math.abs(v).toLocaleString('fa-IR', {
maximumFractionDigits: digits,
minimumFractionDigits: 0,
});
}
function formatMandehAmount(amount: string | number, unit: string) {
const n = Number(amount);
if (!Number.isFinite(n)) return String(amount);
const abs = Math.abs(n);
if (unit === 'ریال') return formatMoney(abs, 'IRR');
return abs.toLocaleString('fa-IR', { maximumFractionDigits: 3 });
}
function defaultJalaliRange() {
// تقریبی: کاربر می‌تواند عوض کند؛ پیش‌فرض بازه یک‌ماهه متنی
const now = new Date();
// بدون وابستگی سنگین: مقادیر نمونه قابل ویرایش
toDate.value = `${now.getFullYear() - 621}-01-01`;
// بهتر: از API تاریخ نخواهیم؛ فیلدها را خالی‌تر با placeholder بگذاریم
const y = 1405;
fromDate.value = `${y}-01-01`;
toDate.value = `${y}-12-29`;
sanad.value.year = y;
sanad.value.month = 1;
sanad.value.day = 1;
}
async function loadConnectionsAndLinks() {
loading.value = true;
error.value = '';
try {
const [connRes, linkRes] = await Promise.all([
api.get<TahesabConnectionDto[]>('/integrations/tahesab/connections/public'),
api.get<PersonAccountingLinkDto[]>(
`/integrations/tahesab/people/${props.personId}/links`,
),
]);
connections.value = connRes.data.filter((c) => c.enabled);
links.value = linkRes.data;
const def =
connections.value.find((c) => c.isDefault) ?? connections.value[0];
if (!connectionId.value && def) connectionId.value = def.id;
else if (
connectionId.value &&
!connections.value.some((c) => c.id === connectionId.value)
) {
connectionId.value = def?.id ?? '';
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری اتصال ته‌حساب ناموفق بود.',
)
: 'بارگذاری اتصال ته‌حساب ناموفق بود.';
} finally {
loading.value = false;
}
}
async function refreshAssets() {
if (!connectionId.value || !currentLink.value) {
assets.value = null;
return;
}
loading.value = true;
error.value = '';
try {
const { data } = await api.get<PersonAssetsDto>(
`/integrations/tahesab/people/${props.personId}/assets`,
{ params: { connectionId: connectionId.value, refresh: '1' } },
);
assets.value = data;
} catch (e) {
assets.value = null;
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'دریافت مانده ناموفق بود.',
)
: 'دریافت مانده ناموفق بود.';
} finally {
loading.value = false;
}
}
async function loadAsnad() {
if (!connectionId.value || !currentLink.value) return;
if (!fromDate.value || !toDate.value) {
error.value = 'تاریخ از و تا را وارد کنید (شمسی YYYY-MM-DD).';
return;
}
asnadLoading.value = true;
error.value = '';
try {
const { data } = await api.get<{ items: Record<string, unknown>[] }>(
`/integrations/tahesab/people/${props.personId}/asnad`,
{
params: {
connectionId: connectionId.value,
from: fromDate.value,
to: toDate.value,
},
},
);
asnadItems.value = data.items ?? [];
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'دریافت اسناد ناموفق بود.',
)
: 'دریافت اسناد ناموفق بود.';
} finally {
asnadLoading.value = false;
}
}
async function runMatch() {
if (!connectionId.value) return;
error.value = '';
ok.value = '';
try {
const { data } = await api.post<{
status: string;
candidates: typeof matchCandidates.value;
link: PersonAccountingLinkDto | null;
}>(`/integrations/tahesab/people/${props.personId}/match`, {
connectionId: connectionId.value,
autoLinkIfUnique: true,
});
matchCandidates.value = data.candidates ?? [];
if (data.status === 'linked') {
ok.value = 'مشتری با موفقیت لینک شد.';
await loadConnectionsAndLinks();
await refreshAssets();
} else if (data.status === 'none') {
error.value =
'مشتری در ته‌حساب پیدا نشد. کد را دستی وارد کنید یا «ثبت در ته‌حساب» را بزنید.';
} else if (data.status === 'ambiguous') {
error.value = 'چند مشتری پیدا شد — یکی را انتخاب کنید.';
} else if (data.status === 'unique') {
ok.value = 'یک مشتری پیدا شد — برای لینک تأیید کنید.';
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'تطبیق ناموفق')
: 'تطبیق ناموفق';
}
}
async function linkCode(code: number) {
error.value = '';
ok.value = '';
try {
await api.post(`/integrations/tahesab/people/${props.personId}/links`, {
connectionId: connectionId.value,
moshtariCode: code,
});
ok.value = `لینک به کد ${code} انجام شد.`;
matchCandidates.value = [];
await loadConnectionsAndLinks();
await refreshAssets();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'لینک ناموفق')
: 'لینک ناموفق';
}
}
async function manualLink() {
const code = Number(manualCode.value);
if (!Number.isFinite(code) || code <= 0) {
error.value = 'کد مشتری معتبر وارد کنید.';
return;
}
await linkCode(code);
}
async function createInTahesab() {
if (!connectionId.value) return;
if (!confirm('این شخص به‌عنوان مشتری جدید در ته‌حساب ثبت شود؟')) return;
error.value = '';
ok.value = '';
try {
await api.post(
`/integrations/tahesab/people/${props.personId}/create-in-tahesab`,
{ connectionId: connectionId.value },
);
ok.value = 'مشتری در ته‌حساب ثبت و لینک شد.';
await loadConnectionsAndLinks();
await refreshAssets();
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق',
)
: 'ثبت ناموفق';
}
}
async function unlink() {
if (!connectionId.value || !currentLink.value) return;
if (!confirm('لینک این شخص با ته‌حساب قطع شود؟')) return;
await api.delete(
`/integrations/tahesab/people/${props.personId}/links/${connectionId.value}`,
);
assets.value = null;
await loadConnectionsAndLinks();
}
async function pushSanad() {
if (!connectionId.value || !currentLink.value) return;
if (!confirm('سند در ته‌حساب ثبت شود؟ این عمل مالی است و قابل بازگشت از CRM نیست.')) {
return;
}
sanadBusy.value = true;
error.value = '';
ok.value = '';
try {
const body = {
connectionId: connectionId.value,
...sanad.value,
};
const { data } = await api.post<{
idempotent?: boolean;
log: { factorCode: string; status: string; tahesabOk: string | null };
}>(`/integrations/tahesab/people/${props.personId}/sanad`, body);
ok.value = data.idempotent
? `سند تکراری نبود — همان Factor_Code قبلی: ${data.log.factorCode}`
: `سند ثبت شد. کد: ${data.log.tahesabOk ?? data.log.factorCode}`;
await refreshAssets();
if (activeTab.value === 'جزئیات حساب') await loadAsnad();
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string | string[] })?.message ??
'ثبت سند ناموفق',
)
: 'ثبت سند ناموفق';
} finally {
sanadBusy.value = false;
}
}
watch(connectionId, async () => {
matchCandidates.value = [];
asnadItems.value = [];
if (currentLink.value) await refreshAssets();
else assets.value = null;
});
onMounted(async () => {
defaultJalaliRange();
await loadConnectionsAndLinks();
if (currentLink.value) await refreshAssets();
});
</script>
<template>
<div class="assets">
<p v-if="!connections.length && !loading" class="empty">
اتصال ته‌حساب تعریف نشده است.
<RouterLink to="/settings/tahesab">تنظیمات ته‌حساب</RouterLink>
</p>
<template v-else>
<div class="toolbar">
<label v-if="hasMultipleConnections" class="conn-pick">
<span>اتصال</span>
<select v-model="connectionId">
<option v-for="c in connections" :key="c.id" :value="c.id">
{{ c.label }} ({{ c.dbName }})
</option>
</select>
</label>
<div v-else-if="connections[0]" class="muted">
اتصال: {{ connections[0].label }} · DB {{ connections[0].dbName }}
</div>
</div>
<div class="link-bar card">
<template v-if="currentLink">
<div>
<strong>لینک‌شده</strong>
<span class="muted">
کد ته‌حساب: {{ currentLink.moshtariCode }} · روش:
{{ currentLink.matchMethod }}
</span>
</div>
<div class="actions">
<button type="button" class="btn ghost" @click="refreshAssets">بروزرسانی مانده</button>
<button type="button" class="btn ghost" @click="unlink">قطع لینک</button>
</div>
</template>
<template v-else>
<p class="muted">این مشتری هنوز به ته‌حساب لینک نشده است.</p>
<div class="actions wrap">
<button type="button" class="btn" @click="runMatch">تطبیق خودکار</button>
<button type="button" class="btn ghost" @click="createInTahesab">ثبت در ته‌حساب</button>
<div class="manual">
<input
v-model="manualCode"
type="number"
min="1"
placeholder="کد مشتری"
dir="ltr"
/>
<button type="button" class="btn ghost" @click="manualLink">لینک دستی</button>
</div>
</div>
<ul v-if="matchCandidates.length" class="candidates">
<li v-for="c in matchCandidates" :key="c.moshtariCode">
<button type="button" class="btn ghost" @click="linkCode(c.moshtariCode)">
{{ c.moshtariCode }} — {{ c.name || 'بدون نام' }}
<span class="muted">({{ c.tel || c.matchBy }})</span>
</button>
</li>
</ul>
</template>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<p v-if="loading" class="muted">در حال دریافت اطلاعات…</p>
<template v-if="currentLink && assets">
<section class="summary card">
<h3>دارایی</h3>
<div class="balance-row">
<span>دارایی طلایی</span>
<div class="val" :class="balanceClass(goldBalance)" dir="ltr">
<small v-if="balanceLabel(goldBalance)">{{ balanceLabel(goldBalance) }}</small>
{{ formatAmt(goldBalance) }}
<em>(گرم)</em>
</div>
</div>
<div class="balance-row">
<span>دارایی ریالی</span>
<div class="val" :class="balanceClass(rialBalance)" dir="ltr">
<small v-if="balanceLabel(rialBalance)">{{ balanceLabel(rialBalance) }}</small>
{{ rialBalance != null ? formatMoney(Math.abs(rialBalance), 'IRR') : '—' }}
</div>
</div>
<div v-if="assets.totalGoldEquiv != null" class="balance-row">
<span>دارایی کل به طلا</span>
<div class="val" :class="balanceClass(assets.totalGoldEquiv)" dir="ltr">
{{ formatAmt(assets.totalGoldEquiv) }}
<em>(گرم)</em>
</div>
</div>
<p class="muted tiny">آخرین دریافت: {{ assets.fetchedAt }}</p>
</section>
<div class="tabs">
<button
v-for="t in ['مانده حساب', 'جزئیات حساب', 'ثبت سند'] as const"
:key="t"
type="button"
class="tab"
:class="{ on: activeTab === t }"
@click="activeTab = t"
>
{{ t }}
</button>
</div>
<div v-show="activeTab === 'مانده حساب'" class="detail">
<template v-for="group in mandehGroups" :key="group.label">
<div class="group-title">
<span class="line" />
<strong>{{ group.label }}</strong>
<span class="line" />
</div>
<div
v-for="(item, idx) in group.items"
:key="idx"
class="mandeh-card card"
>
<div class="name">{{ item.Name }}:</div>
<div class="vals">
<template v-for="(v, vi) in item.values" :key="vi">
<span :class="Number(v.amount) >= 0 ? 'credit' : 'debt'">
{{ formatMandehAmount(v.amount, v.unit) }}
</span>
<span class="unit">{{ v.unit }}</span>
<span class="st">{{ v.status === 'بس' ? 'بد' : 'بس' }}</span>
<span v-if="vi < item.values.length - 1" class="sep">|</span>
</template>
</div>
</div>
</template>
<p v-if="!mandehGroups.length" class="muted">جزئیات مانده‌ای نیست.</p>
</div>
<div v-show="activeTab === 'جزئیات حساب'" class="asnad">
<div class="date-row">
<label>
از
<input v-model="fromDate" dir="ltr" placeholder="1405-01-01" />
</label>
<label>
تا
<input v-model="toDate" dir="ltr" placeholder="1405-12-29" />
</label>
<button type="button" class="btn" :disabled="asnadLoading" @click="loadAsnad">
{{ asnadLoading ? '…' : 'نمایش اسناد' }}
</button>
</div>
<div class="asnad-grid">
<article
v-for="(item, idx) in asnadItems"
:key="String(item.Factor_Code ?? item.ID ?? idx)"
class="sanad-card card"
>
<div class="sc-head">
<span>شماره سند: {{ item.Sh_Factor ?? item.Sh_factor ?? '—' }}</span>
<span>تاریخ: {{ item.Tarikh ?? '—' }}</span>
</div>
<div class="sc-row gold">
<span>نوع</span>
<strong>{{ item.NO ?? '—' }}</strong>
</div>
<div class="sc-row gold">
<span>وزن / عیار</span>
<strong dir="ltr">{{ item.Vazn ?? '—' }} / {{ item.Ayar ?? '—' }}</strong>
</div>
<div class="sc-row cash">
<span>مالی</span>
<strong dir="ltr">{{ item.Mali ?? '—' }}</strong>
</div>
<div class="sc-row">
<span>شرح</span>
<span>{{ item.Sharh1 ?? item.Sharh ?? '—' }}</span>
</div>
</article>
</div>
<p v-if="!asnadLoading && !asnadItems.length" class="muted">سندی نیست.</p>
</div>
<form v-show="activeTab === 'ثبت سند'" class="sanad-form card" @submit.prevent="pushSanad">
<p class="warn">
ثبت سند مستقیماً در ته‌حساب انجام می‌شود. قبل از تأیید مقادیر را دوباره بررسی کنید.
</p>
<label>
نوع سند
<select v-model="sanad.docType">
<option v-for="(label, key) in TAHSAB_SANAD_LABELS" :key="key" :value="key">
{{ label }}
</option>
</select>
</label>
<div class="triple">
<label>سال<input v-model.number="sanad.year" type="number" required /></label>
<label>ماه<input v-model.number="sanad.month" type="number" min="1" max="12" required /></label>
<label>روز<input v-model.number="sanad.day" type="number" min="1" max="31" required /></label>
</div>
<label>شرح<input v-model="sanad.sharh" /></label>
<template v-if="sanad.docType === 'buy_sale_gold' || sanad.docType === 'buy_sale_sekeh'">
<div class="triple">
<label>وزن<input v-model.number="sanad.vazn" type="number" step="any" required /></label>
<label>عیار<input v-model.number="sanad.ayar" type="number" step="any" required /></label>
<label>
خرید/فروش
<select v-model.number="sanad.buyOrSale">
<option :value="1">خرید</option>
<option :value="0">فروش</option>
</select>
</label>
</div>
<div class="triple">
<label>مظنه<input v-model.number="sanad.mazaneh" type="number" step="any" required /></label>
<label>مبلغ کل<input v-model.number="sanad.mablaghKol" type="number" step="any" required /></label>
<label v-if="sanad.docType === 'buy_sale_gold'">
آبشده؟
<select v-model.number="sanad.isAbshode">
<option :value="1">آبشده</option>
<option :value="0">متفرقه</option>
</select>
</label>
</div>
<template v-if="sanad.docType === 'buy_sale_sekeh'">
<label>تعداد<input v-model.number="sanad.count" type="number" step="any" required /></label>
<label>نام سکه<input v-model="sanad.nameSekeh" required /></label>
</template>
</template>
<template v-else-if="sanad.docType === 'talab_bedehi'">
<label>
نوع
<select v-model.number="sanad.isBedehiOrTalab">
<option :value="0">طلب</option>
<option :value="1">بدهی</option>
</select>
</label>
<label>مبلغ<input v-model.number="sanad.mablagh" type="number" step="any" required /></label>
<label>وزن (اختیاری)<input v-model.number="sanad.vazn" type="number" step="any" /></label>
<label>بابت<input v-model="sanad.baabat" /></label>
</template>
<template v-else-if="sanad.docType === 'vkh_gold'">
<div class="triple">
<label>وزن<input v-model.number="sanad.vazn" type="number" step="any" required /></label>
<label>عیار<input v-model.number="sanad.ayar" type="number" step="any" required /></label>
<label>
ورود/خروج
<select v-model.number="sanad.isVoroodOrKhorooj">
<option :value="0">ورود</option>
<option :value="1">خروج</option>
</select>
</label>
</div>
<label>شماره انگ<input v-model="sanad.angNumber" /></label>
<label>آزمایشگاه<input v-model="sanad.nameAz" /></label>
</template>
<template v-else-if="sanad.docType === 'vkh_bank'">
<label>
دریافت/پرداخت
<select v-model.number="sanad.isDaryaftOrPardakht">
<option :value="1">دریافت از مشتری</option>
<option :value="0">پرداخت به مشتری</option>
</select>
</label>
<label>مبلغ<input v-model.number="sanad.mablagh" type="number" step="any" required /></label>
<label>نام بانک<input v-model="sanad.nameBank" required /></label>
<label>کد رهگیری<input v-model="sanad.codeRahgiri" /></label>
</template>
<button class="btn" type="submit" :disabled="sanadBusy">
{{ sanadBusy ? 'در حال ثبت…' : 'ثبت سند در ته‌حساب' }}
</button>
</form>
</template>
</template>
</div>
</template>
<style scoped>
.assets {
display: grid;
gap: 0.9rem;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.9rem;
}
.toolbar,
.link-bar,
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
.actions.wrap {
justify-content: flex-start;
}
.conn-pick {
display: flex;
gap: 0.5rem;
align-items: center;
}
.manual {
display: flex;
gap: 0.35rem;
}
.candidates {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
width: 100%;
}
.summary h3 {
margin: 0 0 0.75rem;
}
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.45rem 0;
border-bottom: 1px dashed var(--line);
}
.balance-row:last-of-type {
border-bottom: 0;
}
.val {
display: flex;
align-items: baseline;
gap: 0.35rem;
font-weight: 700;
}
.val em {
font-style: normal;
font-weight: 500;
font-size: 0.75rem;
color: var(--muted);
}
.val small {
font-size: 0.7rem;
font-weight: 600;
}
.credit {
color: #15803d;
}
.debt {
color: #b91c1c;
}
.tabs {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.tab {
border: 1px solid var(--line);
background: var(--bg);
color: var(--text);
border-radius: 10px;
padding: 0.45rem 0.75rem;
cursor: pointer;
}
.tab.on {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.group-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 0 0.4rem;
}
.group-title .line {
flex: 1;
border-top: 1px dashed var(--line);
}
.mandeh-card {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.45rem;
}
.vals {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.unit,
.st,
.sep,
.muted,
.tiny {
color: var(--muted);
font-size: 0.85rem;
}
.date-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: end;
margin-bottom: 0.75rem;
}
.date-row label,
.sanad-form label {
display: grid;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--muted);
}
.asnad-grid {
display: grid;
gap: 0.65rem;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.sc-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.sc-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.3rem 0.4rem;
border-radius: 8px;
margin-top: 0.25rem;
font-size: 0.9rem;
}
.sc-row.gold {
background: color-mix(in srgb, #ca8a04 12%, transparent);
}
.sc-row.cash {
background: color-mix(in srgb, #16a34a 12%, transparent);
}
.sanad-form {
display: grid;
gap: 0.65rem;
}
.triple {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.warn {
margin: 0;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
padding: 0.55rem 0.7rem;
font-size: 0.88rem;
}
input,
select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.45rem 0.6rem;
background: var(--bg);
color: var(--text);
}
.btn {
border: 0;
border-radius: 10px;
padding: 0.45rem 0.8rem;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.error {
color: #b91c1c;
}
.ok {
color: #15803d;
}
.empty a {
color: var(--accent);
}
@media (max-width: 640px) {
.triple {
grid-template-columns: 1fr;
}
}
</style>
+2
View File
@@ -42,6 +42,7 @@ const settingsPaths = [
'/settings/custom-fields',
'/settings/products',
'/settings/currencies',
'/settings/tahesab',
'/settings/faq',
];
@@ -327,6 +328,7 @@ onUnmounted(() => {
<RouterLink to="/settings/lead-forms" @click="closeMenu">فرم‌ساز سرنخ</RouterLink>
<RouterLink to="/settings/custom-fields" @click="closeMenu">فیلدهای سفارشی</RouterLink>
<RouterLink to="/settings/currencies" @click="closeMenu">ارزها</RouterLink>
<RouterLink to="/settings/tahesab" @click="closeMenu">ته‌حساب</RouterLink>
<RouterLink to="/settings/faq" @click="closeMenu">FAQ</RouterLink>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
/** برچسب انواع سند ته‌حساب — کپی runtime برای Vite (بدون named export از shared) */
export const TAHSAB_SANAD_LABELS: Record<string, string> = {
buy_sale_gold: 'خرید/فروش آبشده',
buy_sale_sekeh: 'خرید/فروش سکه',
talab_bedehi: 'طلب/بدهی',
vkh_gold: 'واریز/خروج طلا',
vkh_bank: 'واریز/خروج بانکی',
};
+6
View File
@@ -204,6 +204,12 @@ export const router = createRouter({
component: () => import('@/views/CurrencySettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/tahesab',
name: 'tahesab-settings',
component: () => import('@/views/TahesabSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/faq',
name: 'faq-settings',
+14 -2
View File
@@ -21,6 +21,7 @@ import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import AskMentoPanel from '@/components/AskMentoPanel.vue';
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
@@ -40,7 +41,7 @@ import type {
} from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
type TabKey = 'activities' | 'deals' | 'cards' | 'info';
type TabKey = 'activities' | 'deals' | 'cards' | 'assets' | 'info';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
@@ -65,6 +66,7 @@ const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
@@ -345,7 +347,13 @@ function formatSize(bytes: number) {
function readTab() {
const q = route.query.tab;
if (q === 'activities' || q === 'deals' || q === 'cards' || q === 'info') {
if (
q === 'activities' ||
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
q === 'info'
) {
tab.value = q;
} else {
tab.value = 'activities';
@@ -803,6 +811,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<PersonAssetsPanel :person-id="id" />
</div>
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
<article class="smart">
<header class="smart-head">
+502
View File
@@ -0,0 +1,502 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateTahesabConnectionRequest,
TahesabConnectionDto,
UpdateTahesabConnectionRequest,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<TahesabConnectionDto[]>([]);
const editingId = ref<string | null>(null);
const form = reactive({
label: '',
baseUrl: '',
dbName: 'DB',
token: '',
isDefault: false,
allowInsecureTls: false,
enabled: true,
});
const sync = reactive({
connectionId: '',
phone: '',
name: '',
moshtariCode: '' as string | number,
});
const syncResults = ref<
Array<{ moshtariCode: number | null; name: string; tel: string }>
>([]);
function resetForm() {
editingId.value = null;
form.label = '';
form.baseUrl = '';
form.dbName = 'DB';
form.token = '';
form.isDefault = items.value.length === 0;
form.allowInsecureTls = false;
form.enabled = true;
}
function startEdit(row: TahesabConnectionDto) {
editingId.value = row.id;
form.label = row.label;
form.baseUrl = row.baseUrl;
form.dbName = row.dbName;
form.token = '';
form.isDefault = row.isDefault;
form.allowInsecureTls = row.allowInsecureTls;
form.enabled = row.enabled;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<TahesabConnectionDto[]>(
'/integrations/tahesab/connections',
);
items.value = data;
if (!editingId.value) form.isDefault = data.length === 0;
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری اتصالات ناموفق بود.',
)
: 'بارگذاری اتصالات ناموفق بود.';
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
if (editingId.value) {
const body: UpdateTahesabConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
dbName: form.dbName.trim(),
isDefault: form.isDefault,
allowInsecureTls: form.allowInsecureTls,
enabled: form.enabled,
};
if (form.token.trim()) body.token = form.token.trim();
await api.patch(
`/integrations/tahesab/connections/${editingId.value}`,
body,
);
ok.value = 'اتصال به‌روزرسانی شد.';
} else {
if (!form.token.trim()) {
error.value = 'توکن الزامی است.';
return;
}
const body: CreateTahesabConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
dbName: form.dbName.trim(),
token: form.token.trim(),
isDefault: form.isDefault,
allowInsecureTls: form.allowInsecureTls,
enabled: form.enabled,
};
await api.post('/integrations/tahesab/connections', body);
ok.value = 'اتصال افزوده شد.';
}
resetForm();
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function testConn(id: string) {
error.value = '';
ok.value = '';
try {
const { data } = await api.post<{ ok: boolean; error?: string }>(
`/integrations/tahesab/connections/${id}/test`,
);
if (data.ok) ok.value = 'اتصال سالم است.';
else error.value = data.error ?? 'تست اتصال ناموفق بود.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'تست ناموفق')
: 'تست ناموفق';
}
}
async function removeConn(id: string) {
if (!confirm('این اتصال حذف شود؟ لینک مشتریان مرتبط هم پاک می‌شود.')) return;
error.value = '';
try {
await api.delete(`/integrations/tahesab/connections/${id}`);
ok.value = 'اتصال حذف شد.';
if (editingId.value === id) resetForm();
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق')
: 'حذف ناموفق';
}
}
async function searchTahesab() {
error.value = '';
ok.value = '';
syncResults.value = [];
try {
const body: Record<string, unknown> = {
connectionId: sync.connectionId,
};
if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
body.moshtariCode = Number(sync.moshtariCode);
} else if (sync.phone.trim()) {
body.phone = sync.phone.trim();
} else if (sync.name.trim()) {
body.name = sync.name.trim();
} else {
error.value = 'موبایل، نام یا کد مشتری را وارد کنید.';
return;
}
const { data } = await api.post<{
results: Array<{ moshtariCode: number | null; name: string; tel: string }>;
}>('/integrations/tahesab/sync/search', body);
syncResults.value = data.results ?? [];
if (!syncResults.value.length) ok.value = 'نتیجه‌ای یافت نشد.';
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'جستجو ناموفق')
: 'جستجو ناموفق';
}
}
async function importOne(moshtariCode: number) {
error.value = '';
ok.value = '';
try {
const { data } = await api.post<{ personId: string }>(
'/integrations/tahesab/sync/import',
{ connectionId: sync.connectionId, moshtariCode },
);
ok.value = `وارد شد — شخص ${data.personId}`;
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'وارد کردن ناموفق')
: 'وارد کردن ناموفق';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header>
<p class="eyebrow">تنظیمات</p>
<h1>اتصال ته‌حساب</h1>
<p class="lead">
هر نسخه/دیتابیس ته‌حساب یک اتصال جدا است (آدرس وب‌سرویس + توکن + نام DB).
اگر فقط یک اتصال دارید، همان به‌عنوان پیش‌فرض استفاده می‌شود.
</p>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div v-if="!loading && items.length" class="list">
<article v-for="row in items" :key="row.id" class="card row">
<div class="row-main">
<strong>{{ row.label }}</strong>
<span class="muted">{{ row.baseUrl }}</span>
<span class="meta">
DB: {{ row.dbName }} · توکن: {{ row.tokenMasked }}
<template v-if="row.isDefault"> · پیش‌فرض</template>
<template v-if="!row.enabled"> · غیرفعال</template>
</span>
<span
v-if="row.lastHealthAt"
class="health"
:class="row.lastHealthOk ? 'ok' : 'bad'"
>
آخرین تست:
{{ row.lastHealthOk ? 'موفق' : 'ناموفق' }}
<template v-if="row.lastError"> — {{ row.lastError }}</template>
</span>
</div>
<div class="row-actions">
<button type="button" class="btn ghost" @click="startEdit(row)">ویرایش</button>
<button type="button" class="btn ghost" @click="testConn(row.id)">تست</button>
<button type="button" class="btn danger" @click="removeConn(row.id)">حذف</button>
</div>
</article>
</div>
<form class="card" @submit.prevent="save">
<h2>{{ editingId ? 'ویرایش اتصال' : 'افزودن اتصال' }}</h2>
<div class="field">
<label>برچسب</label>
<input v-model="form.label" required maxlength="120" placeholder="مثلاً شعبه بازار" />
</div>
<div class="field">
<label>آدرس وب‌سرویس (base URL)</label>
<input
v-model="form.baseUrl"
required
dir="ltr"
placeholder="https://api-test.tahesab.ir یا https://IP:8081"
/>
</div>
<div class="field">
<label>نام دیتابیس (DBname)</label>
<input v-model="form.dbName" required dir="ltr" placeholder="DB یا TahesabDB" />
</div>
<div class="field">
<label>
توکن
<span v-if="editingId" class="muted">(خالی بگذارید تا عوض نشود)</span>
</label>
<input
v-model="form.token"
:required="!editingId"
type="password"
dir="ltr"
autocomplete="new-password"
placeholder="Bearer token"
/>
</div>
<label class="check">
<input v-model="form.isDefault" type="checkbox" />
اتصال پیش‌فرض
</label>
<label class="check">
<input v-model="form.enabled" type="checkbox" />
فعال
</label>
<label class="check">
<input v-model="form.allowInsecureTls" type="checkbox" />
پذیرش گواهی SSL خودامضا (فقط شبکه داخلی)
</label>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : editingId ? 'به‌روزرسانی' : 'افزودن' }}
</button>
<button v-if="editingId" type="button" class="btn ghost" @click="resetForm">انصراف</button>
</div>
</form>
<section class="card sync">
<h2>جستجو / وارد کردن از ته‌حساب</h2>
<p class="muted">
مشتری را در یک اتصال جستجو کنید و به‌عنوان شخص جدید وارد CRM کنید یا به شخص موجود لینک دهید.
</p>
<div class="field">
<label>اتصال</label>
<select v-model="sync.connectionId">
<option disabled value="">انتخاب اتصال</option>
<option v-for="row in items" :key="row.id" :value="row.id">
{{ row.label }} ({{ row.dbName }})
</option>
</select>
</div>
<div class="sync-grid">
<input v-model="sync.phone" dir="ltr" placeholder="موبایل" />
<input v-model="sync.name" placeholder="نام" />
<input v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری" />
</div>
<button type="button" class="btn ghost" :disabled="!sync.connectionId" @click="searchTahesab">
جستجو
</button>
<ul v-if="syncResults.length" class="sync-results">
<li v-for="r in syncResults" :key="String(r.moshtariCode)">
<span>
{{ r.moshtariCode }} — {{ r.name || 'بدون نام' }}
<span class="muted">{{ r.tel }}</span>
</span>
<button
type="button"
class="btn"
:disabled="!r.moshtariCode"
@click="importOne(r.moshtariCode!)"
>
وارد کردن
</button>
</li>
</ul>
</section>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 720px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
h2 {
margin: 0;
font-size: 1.05rem;
}
.lead,
.muted {
color: var(--muted);
}
.list {
display: grid;
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.75rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.row {
grid-template-columns: 1fr auto;
align-items: start;
gap: 0.75rem;
}
.row-main {
display: grid;
gap: 0.25rem;
}
.meta {
font-size: 0.85rem;
color: var(--muted);
}
.health.ok {
color: #15803d;
font-size: 0.85rem;
}
.health.bad {
color: #b91c1c;
font-size: 0.85rem;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.field {
display: grid;
gap: 0.35rem;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input[type='text'],
input:not([type]),
input[type='password'],
input[type='url'] {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
background: var(--bg);
color: var(--text);
}
.check {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--text);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn {
border: 0;
border-radius: 10px;
padding: 0.55rem 0.9rem;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.btn.danger {
background: transparent;
color: #b91c1c;
border: 1px solid #fecaca;
}
.error {
color: #b91c1c;
}
.ok {
color: #15803d;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 1fr;
}
}
.sync-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.sync-results {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.sync-results li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--line);
}
select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
background: var(--bg);
color: var(--text);
}
@media (max-width: 640px) {
.sync-grid {
grid-template-columns: 1fr;
}
}
</style>
+3
View File
@@ -39,3 +39,6 @@ STT_ENABLED=true
STT_URL=http://127.0.0.1:3040
STT_INTERNAL_TOKEN=change-me-stt-internal-token
STT_TIMEOUT_MS=60000
# رمزنگاری توکن‌های اتصال حسابداری (ته‌حساب) — ۳۲ بایت hex (۶۴ کاراکتر)
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
+1
View File
@@ -65,6 +65,7 @@ nano apps/api/.env
| `CORS_ORIGIN` | `https://crm.montzar.ir` |
| `SMS_*` | همان ترِز مونتایم |
| `PORTAL_SHOW_OTP` | در production حتماً `false` |
| `INTEGRATIONS_ENCRYPTION_KEY` | ۶۴ کاراکتر hex برای رمز توکن ته‌حساب (`openssl rand -hex 32`) |
نمونهٔ اتصال:
File diff suppressed because one or more lines are too long
+71
View File
@@ -0,0 +1,71 @@
# اتصال ته‌حساب (فاز ۱)
مرجع API: [Tahesab API Reference](https://tahesab.ir/APiDoc/) · OpenAPI: `docs/integrations/tahesab-openapi.json`
## اصول
- هر سازمان می‌تواند **چند اتصال** داشته باشد (`baseUrl` + `token` + `dbName`).
- توکن با **AES-256-GCM** و کلید محیطی `INTEGRATIONS_ENCRYPTION_KEY` رمز می‌شود و هرگز کامل به فرانت برنمی‌گردد.
- مانده و اسناد: **منبع حقیقت ته‌حساب** است؛ CRM فقط cache و لینک نگه می‌دارد.
- ثبت سند فقط از فرم اختصاصی تب **دارایی** با `Factor_Code` یکتا (≥۲۰ کاراکتر) و جدول `AccountingDocumentLog` (idempotent).
## مدل‌ها
| مدل | نقش |
|-----|-----|
| `AccountingConnection` | اتصال چند-DB |
| `PersonAccountingLink` | لینک شخص ↔ `moshtariCode` روی یک اتصال |
| `AccountingDocumentLog` | لاگ push سند |
| `AccountingBalanceCache` | cache مانده |
| `AccountingSyncJob` | لاگ جستجو/وارد کردن |
## APIهای CRM
پیشوند: `/api/integrations/tahesab`
- اتصالات (owner/admin): `GET/POST/PATCH/DELETE /connections` · `POST /connections/:id/test`
- لیست عمومی اتصالات: `GET /connections/public`
- لینک شخص: `GET/POST .../people/:id/links` · `DELETE .../links/:connectionId`
- تطبیق: `POST .../people/:id/match` · ثبت: `POST .../create-in-tahesab`
- دارایی: `GET .../people/:id/assets` · اسناد: `GET .../people/:id/asnad`
- ثبت سند: `POST .../people/:id/sanad`
- sync دوطرفه: `POST /sync/search` · `POST /sync/import`
## تطبیق مشتری
1. نرمال‌سازی موبایل → `DoListMoshtari-ByPhone`
2. اگر خالی → `DoListMoshtari-ByName`
3. اگر بیش از یک نتیجه → انتخاب دستی (لینک خودکار روی ابهام ممنوع)
4. لینک دستی با کد · یا `DoNewMoshtari`
## انواع سند فاز ۱
| کلید CRM | متد ته‌حساب |
|----------|-------------|
| `buy_sale_gold` | `DoNewSanadBuySaleGOLD` |
| `buy_sale_sekeh` | `DoNewSanadBuySaleSEKEH` |
| `talab_bedehi` | `DoNewSanadTalabBedehi` |
| `vkh_gold` | `DoNewSanadVKHGOLD` |
| `vkh_bank` | `DoNewSanadVKHBank` |
مانده: مسیر `GetMandeHesabByCode` با کلید body `getmandehesabbycode` (مثل همکار).
## استقرار
1. `INTEGRATIONS_ENCRYPTION_KEY` را روی سرور بگذارید (۶۴ کاراکتر hex).
2. `npx prisma migrate deploy` در `apps/api`
3. rebuild API/web و restart PM2
## چک‌لیست تست دستی
- [ ] افزودن اتصال + تست health
- [ ] افزودن اتصال دوم با DB دیگر؛ انتخاب پیش‌فرض
- [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید
- [ ] نمایش مانده و جزئیات در تب دارایی
- [ ] لیست اسناد با بازه شمسی
- [ ] ثبت سند؛ retry با همان Factor_Code نباید سند تکراری بسازد
- [ ] وارد کردن مشتری از تنظیمات ته‌حساب
## خارج از فاز ۱
push خودکار از معامله/فاکتور، RFID/اتیکت، PDF/Excel اسناد، ویرایش سند در ته‌حساب.
+87
View File
@@ -1461,6 +1461,93 @@ export interface CreateAutomationRuleRequest {
export type UpdateAutomationRuleRequest = Partial<CreateAutomationRuleRequest>;
// —— ته‌حساب / حسابداری ——
export const TAHSAB_SANAD_TYPES = [
"buy_sale_gold",
"buy_sale_sekeh",
"talab_bedehi",
"vkh_gold",
"vkh_bank",
] as const;
export type TahesabSanadType = (typeof TAHSAB_SANAD_TYPES)[number];
export const TAHSAB_SANAD_LABELS: Record<TahesabSanadType, string> = {
buy_sale_gold: "خرید/فروش آبشده",
buy_sale_sekeh: "خرید/فروش سکه",
talab_bedehi: "طلب/بدهی",
vkh_gold: "واریز/خروج طلا",
vkh_bank: "واریز/خروج بانکی",
};
export interface TahesabConnectionDto {
id: string;
organizationId: string;
provider: string;
label: string;
baseUrl: string;
dbName: string;
tokenMasked: string;
enabled: boolean;
isDefault: boolean;
allowInsecureTls: boolean;
lastHealthAt: string | null;
lastHealthOk: boolean | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateTahesabConnectionRequest {
label: string;
baseUrl: string;
dbName: string;
token: string;
enabled?: boolean;
isDefault?: boolean;
allowInsecureTls?: boolean;
}
export type UpdateTahesabConnectionRequest = Partial<CreateTahesabConnectionRequest>;
export interface PersonAccountingLinkDto {
id: string;
personId: string;
connectionId: string;
moshtariCode: number;
matchMethod: string;
linkedAt: string;
lastBalanceSyncedAt: string | null;
connectionLabel?: string;
dbName?: string;
isDefaultConnection?: boolean;
}
export interface MandehValueDto {
amount: string | number;
unit: string;
status: "بس" | "بد";
}
export interface MandehDetailItemDto {
Name: string;
values: MandehValueDto[];
}
export interface PersonAssetsDto {
connectionId: string;
connectionLabel: string;
dbName: string;
moshtariCode: number;
goldBalance: number | null;
rialBalance: number | null;
currencyBalance: number | null;
totalGoldEquiv: number | null;
mandehDetail: MandehDetailItemDto[];
fetchedAt: string;
}
export const APP_NAME = "Mento CRM";
export const APP_NAME_FA = "منتو";
export const API_PREFIX = "api";