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:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 'واریز/خروج بانکی',
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 اسناد، ویرایش سند در تهحساب.
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user