Fix Tahesab health check to match Postman root-endpoint flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 16:45:04 +03:30
co-authored by Cursor
parent c322a24424
commit 17628722d3
3 changed files with 109 additions and 33 deletions
@@ -251,12 +251,7 @@ export class TahesabConnectionsService {
async test(organizationId: string, id: string) { async test(organizationId: string, id: string) {
const { row, api } = await this.getApi(organizationId, id); const { row, api } = await this.getApi(organizationId, id);
try { try {
let result: unknown; const result = await api.checkHealth();
try {
result = await api.checkHealthSsl();
} catch {
result = await api.checkHealthNoSsl();
}
const updated = await this.prisma.accountingConnection.update({ const updated = await this.prisma.accountingConnection.update({
where: { id: row.id }, where: { id: row.id },
data: { data: {
@@ -268,15 +263,20 @@ export class TahesabConnectionsService {
return { ok: true, connection: this.toDto(updated), health: result }; return { ok: true, connection: this.toDto(updated), health: result };
} catch (err) { } catch (err) {
const message = (err as Error).message ?? 'خطای ناشناخته'; const message = (err as Error).message ?? 'خطای ناشناخته';
const hint =
/مهلت اتصال|timeout/i.test(message) && !row.allowInsecureTls && /^https:/i.test(row.baseUrl)
? ' اگر گواهی ته‌حساب خودامضاست، «پذیرش گواهی SSL خودامضا» را فعال کنید.'
: '';
const full = `${message}${hint}`.slice(0, 500);
const updated = await this.prisma.accountingConnection.update({ const updated = await this.prisma.accountingConnection.update({
where: { id: row.id }, where: { id: row.id },
data: { data: {
lastHealthAt: new Date(), lastHealthAt: new Date(),
lastHealthOk: false, lastHealthOk: false,
lastError: message.slice(0, 500), lastError: full,
}, },
}); });
return { ok: false, connection: this.toDto(updated), error: message }; return { ok: false, connection: this.toDto(updated), error: full };
} }
} }
} }
@@ -44,17 +44,18 @@ export class TahesabHttpClient {
async call<T = unknown>(opts: TahesabCallOptions): Promise<T> { async call<T = unknown>(opts: TahesabCallOptions): Promise<T> {
const base = opts.baseUrl.replace(/\/+$/, ''); const base = opts.baseUrl.replace(/\/+$/, '');
const path = opts.path.replace(/^\/+/, ''); const path = (opts.path ?? '').replace(/^\/+/, '');
const url = new URL(`${base}/${path}`); // Postman ته‌حساب برای health به ریشه می‌زند (بدون path)
const url = path ? new URL(`${base}/${path}`) : new URL(base);
const method = opts.method ?? (opts.bodyKey ? 'POST' : 'GET'); const method = opts.method ?? (opts.bodyKey ? 'POST' : 'GET');
const timeoutMs = opts.timeoutMs ?? 30_000; const timeoutMs = opts.timeoutMs ?? 30_000;
const label = path || '(root)';
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: `Bearer ${opts.token}`, Authorization: `Bearer ${opts.token}`,
DBname: opts.dbName, DBname: opts.dbName,
DBName: opts.dbName, DBName: opts.dbName,
Accept: 'application/json', Accept: 'application/json',
Host: url.host,
}; };
let body: string | undefined; let body: string | undefined;
@@ -72,12 +73,20 @@ export class TahesabHttpClient {
: undefined; : undefined;
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
fn();
};
const req = lib.request( const req = lib.request(
{ {
protocol: url.protocol, protocol: url.protocol,
hostname: url.hostname, hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80), port: url.port || (isHttps ? 443 : 80),
path: `${url.pathname}${url.search}`, path: `${url.pathname}${url.search}` || '/',
method, method,
headers, headers,
timeout: timeoutMs, timeout: timeoutMs,
@@ -106,19 +115,21 @@ export class TahesabHttpClient {
if (status < 200 || status >= 300) { if (status < 200 || status >= 300) {
if (isEmptyOk) { if (isEmptyOk) {
this.logger.debug( this.logger.debug(
`Tahesab ${path} ${status} empty-on-error: ${detail.slice(0, 120)}`, `Tahesab ${label} ${status} empty-on-error: ${detail.slice(0, 120)}`,
); );
resolve([] as T); finish(() => resolve([] as T));
return; return;
} }
this.logger.warn( this.logger.warn(
`Tahesab ${method} ${path} → ${status}: ${text.slice(0, 400)}`, `Tahesab ${method} ${label} → ${status}: ${text.slice(0, 400)}`,
); );
reject( finish(() =>
new BadGatewayException( reject(
detail new BadGatewayException(
? `ته‌حساب (${status}): ${detail}` detail
: `پاسخ نامعتبر ته‌حساب (${status}) برای ${path}`, ? `ته‌حساب (${status}): ${detail}`
: `پاسخ نامعتبر ته‌حساب (${status}) برای ${label}`,
),
), ),
); );
return; return;
@@ -127,42 +138,61 @@ export class TahesabHttpClient {
if (json && typeof json === 'object' && 'ERROR' in (json as object)) { if (json && typeof json === 'object' && 'ERROR' in (json as object)) {
if (isEmptyOk) { if (isEmptyOk) {
this.logger.debug( this.logger.debug(
`Tahesab ${path} empty-on-error: ${detail.slice(0, 120)}`, `Tahesab ${label} empty-on-error: ${detail.slice(0, 120)}`,
); );
resolve([] as T); finish(() => resolve([] as T));
return; return;
} }
reject( finish(() =>
new BadGatewayException( reject(
detail ? `ته‌حساب: ${detail}` : `خطای ته‌حساب برای ${path}`, new BadGatewayException(
detail ? `ته‌حساب: ${detail}` : `خطای ته‌حساب برای ${label}`,
),
), ),
); );
return; return;
} }
resolve(json as T); finish(() => resolve(json as T));
}); });
}, },
); );
// مهلت از لحظهٔ شروع (شامل اتصال TCP/TLS) — timeout سوکت Node فقط بعد از connect است
const hardTimer = setTimeout(() => {
req.destroy();
finish(() =>
reject(
new RequestTimeoutException(
`مهلت اتصال به ته‌حساب برای ${label} به پایان رسید.`,
),
),
);
}, timeoutMs);
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject( finish(() =>
new RequestTimeoutException( reject(
`مهلت اتصال به ته‌حساب برای ${path} به پایان رسید.`, new RequestTimeoutException(
`مهلت اتصال به ته‌حساب برای ${label} به پایان رسید.`,
),
), ),
); );
}); });
req.on('error', (err) => { req.on('error', (err) => {
this.logger.error(`Tahesab call failed: ${path}`, err); if (settled) return;
this.logger.error(`Tahesab call failed: ${label}`, err);
const raw = err.message || String(err); const raw = err.message || String(err);
const tlsHint = const tlsHint =
/self-signed|certificate|UNABLE_TO_VERIFY|CERT_/i.test(raw) /self-signed|certificate|UNABLE_TO_VERIFY|CERT_/i.test(raw)
? ' — در تنظیمات اتصال، گزینه «پذیرش گواهی SSL خودامضا» را فعال کنید.' ? ' — در تنظیمات اتصال، گزینه «پذیرش گواهی SSL خودامضا» را فعال کنید.'
: ''; : '';
reject( finish(() =>
new BadGatewayException( reject(
`اتصال به ته‌حساب ناموفق بود: ${raw}${tlsHint}`, new BadGatewayException(
`اتصال به ته‌حساب ناموفق بود: ${raw}${tlsHint}`,
),
), ),
); );
}); });
@@ -177,6 +177,52 @@ export class TahesabApi {
); );
} }
/**
* تست سلامت مطابق Postman ته‌حساب:
* ابتدا ریشهٔ baseUrl، بعد /CheckHealth، در نهایت مسیر SSL یا No-SSL بر اساس پروتکل.
*/
async checkHealth() {
const isHttps = /^https:/i.test(this.creds.baseUrl.trim());
const timeoutMs = 12_000;
type Health = { Status?: string; Api_Status?: string };
const attempts: Array<{
path: string;
bodyKey?: string;
method: 'GET' | 'POST';
}> = [
{ path: '', method: 'GET' },
{ path: '', bodyKey: 'CheckHealth', method: 'POST' },
{ path: 'CheckHealth', method: 'GET' },
{
path: isHttps ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL',
method: 'GET',
},
];
let lastErr: unknown;
for (const attempt of attempts) {
try {
return await this.call<Health>(
attempt.path,
attempt.bodyKey,
attempt.bodyKey ? [] : undefined,
attempt.method,
timeoutMs,
);
} catch (err) {
lastErr = err;
const msg = err instanceof Error ? err.message : String(err);
// خطای گواهی با تلاش‌های بعدی روی همان https حل نمی‌شود
if (/certificate|UNABLE_TO_VERIFY|self-signed|CERT_/i.test(msg)) {
throw err;
}
}
}
throw lastErr instanceof Error
? lastErr
: new Error('تست اتصال ته‌حساب ناموفق بود.');
}
/** ثبت مشتری — پاسخ { OK: moshtariCode } */ /** ثبت مشتری — پاسخ { OK: moshtariCode } */
doNewMoshtari(args: unknown[]) { doNewMoshtari(args: unknown[]) {
return this.call<{ OK?: number }>('DoNewMoshtari', 'DoNewMoshtari', args); return this.call<{ OK?: number }>('DoNewMoshtari', 'DoNewMoshtari', args);