Files
hedena_gold/apps/api/src/modules/accounting-sync/accounting-admin.service.ts
T
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:48:36 +03:30

367 lines
17 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AccountingEntityType, AccountingOutboxStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { cleanUrl } from './accounting-config';
import { AccountingConfigService, JobName } from './accounting-config.service';
import { fetchServerCertificate } from './accounting-http';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService, PULL_FEATURE } from './accounting-worker.service';
import { parseJalali, toJalali } from './jalali';
import { ACCOUNTING_PROVIDERS, ProviderName, isProviderName } from './providers';
const JOBS: JobName[] = ['customers', 'balances', 'products', 'outbox'];
const STATUSES: AccountingOutboxStatus[] = ['PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'];
function page(v: unknown) {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function limit(v: unknown, max = 100) {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? Math.min(n, max) : 25;
}
/** Backs the «اتصال حسابداری» admin screen (plan §5). */
@Injectable()
export class AccountingAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
private readonly sync: AccountingSyncService,
private readonly worker: AccountingWorkerService,
) {}
async overview() {
const [config, state, counts, openIssues, raw] = await Promise.all([
this.config.getPublic(),
this.config.getState(),
this.prisma.accountingOutbox.groupBy({ by: ['status'], _count: { _all: true } }),
this.prisma.accountingIssue.count({ where: { resolvedAt: null } }),
this.config.get(),
]);
return {
config,
inactiveReason: this.config.inactiveReason(raw),
state,
busy: this.worker.busy,
realtime: this.worker.realtime,
outbox: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
openIssues,
};
}
async save(body: Record<string, unknown>) {
const { providerChanged } = await this.config.save(body);
return { ...(await this.overview()), providerChanged };
}
private async settingsOf(name: unknown) {
const config = await this.config.get();
const provider = isProviderName(name) ? name : config.provider;
if (provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است');
const settings = this.config.settingsFor(config, provider);
if (!settings) throw new BadRequestException('آدرس یا توکن این نرم‌افزار تنظیم نشده (یا توکن قابل‌خواندن نیست)');
return { provider: ACCOUNTING_PROVIDERS[provider], settings, config };
}
/** For the sync buttons on the lists: connected or not, nothing more. */
async connectionStatus() {
const config = await this.config.get();
const reason = this.config.inactiveReason(config);
return {
connected: !reason,
provider: reason || config.provider === 'none' ? null : ACCOUNTING_PROVIDERS[config.provider].label,
};
}
/** The list-page sync buttons (plan §12): run now, wait, report. */
async syncNow(jobs: ('customers' | 'balances' | 'products')[]) {
const reason = this.config.inactiveReason(await this.config.get());
if (reason) throw new BadRequestException(`سینک با حسابداری انجام نمی‌شود: ${reason}`);
const results = await this.worker.runPullsNow(jobs);
if (!results) throw new BadRequestException('یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید');
return { ok: Object.values(results).every((r) => r.ok), results };
}
/** Tests the connection as typed in the form (`connection`), falling back to what is saved. */
async test(name: unknown, connection?: Record<string, unknown>) {
if (!isProviderName(name)) throw new BadRequestException('نرم‌افزار حسابداری نامعتبر است');
const provider = ACCOUNTING_PROVIDERS[name];
const settings = this.config.draftSettings(await this.config.get(), name, connection);
if (!settings) throw new BadRequestException('آدرس و توکن را وارد کنید');
const started = Date.now();
try {
const info = await provider.health(settings);
return { ok: true, provider: provider.label, ms: Date.now() - started, info };
} catch (err) {
return { ok: false, provider: provider.label, ms: Date.now() - started, error: this.sync.describeError(err) };
}
}
/**
* The certificate the server at `baseUrl` presents, for the admin to compare
* and pin (a self-signed Tahesab install cannot be verified otherwise).
*/
async certificate(baseUrl: unknown) {
const url = cleanUrl(baseUrl, 'آدرس');
if (!url) throw new BadRequestException('آدرس را وارد کنید');
try {
return await fetchServerCertificate(url, 10_000);
} catch (err) {
throw new BadRequestException(`گواهی دریافت نشد: ${this.sync.describeError(err)}`);
}
}
/** Dropdown options for the mapping tab; each list fails on its own. */
async options(name: unknown) {
const { provider, settings } = await this.settingsOf(name);
const safe = async <T>(fn: () => Promise<T>) => {
try {
return { ok: true as const, items: await fn() };
} catch (err) {
return { ok: false as const, items: [] as never[], error: this.sync.describeError(err) };
}
};
const [customerGroups, karSakhteGroups, karSakhteTypes] = await Promise.all([
safe(() => provider.listCustomerGroups(settings)),
safe(() => provider.listKarSakhteGroups(settings)),
safe(() => provider.listKarSakhteTypes(settings)),
]);
const categories = await this.prisma.category.findMany({ select: { id: true, title: true }, orderBy: { title: 'asc' } });
return { provider: provider.name, customerGroups, karSakhteGroups, karSakhteTypes, categories };
}
async run(job: string) {
if (!JOBS.includes(job as JobName)) throw new BadRequestException('کار نامعتبر است');
const config = await this.config.get();
const reason = this.config.inactiveReason(config);
if (reason) throw new BadRequestException(`همگام‌سازی انجام نمی‌شود: ${reason}`);
if (job !== 'outbox' && !config.features[PULL_FEATURE[job as Exclude<JobName, 'outbox'>]]) {
throw new BadRequestException('این قابلیت در تب «قابلیت‌ها و زمان‌بندی» خاموش است؛ اول روشن و ذخیره کنید');
}
const started = this.worker.runNow(job as JobName);
return { started, message: started ? 'شروع شد' : 'یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید' };
}
// ── outbox log ──────────────────────────────────────────────────────
async outbox(q: Record<string, unknown>) {
const where: Prisma.AccountingOutboxWhereInput = {};
if (typeof q.status === 'string' && STATUSES.includes(q.status as AccountingOutboxStatus)) {
where.status = q.status as AccountingOutboxStatus;
}
if (typeof q.kind === 'string' && q.kind) where.kind = q.kind as Prisma.AccountingOutboxWhereInput['kind'];
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingOutbox.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingOutbox.count({ where }),
]);
const labels = await this.labelsFor(rows.map((r) => ({ kind: r.kind, entityId: r.entityId })));
return { rows: rows.map((r) => ({ ...r, entityLabel: labels.get(r.entityId) ?? null })), total, page: p, limit: l };
}
private async labelsFor(items: { kind: string; entityId: string }[]) {
const orderIds = items.filter((i) => i.kind.startsWith('ORDER')).map((i) => i.entityId);
const customerIds = items.filter((i) => i.kind === 'CUSTOMER_PUSH').map((i) => i.entityId);
const productIds = items.filter((i) => i.kind === 'PRODUCT_PUSH').map((i) => i.entityId);
const [orders, customers, products] = await Promise.all([
orderIds.length ? this.prisma.order.findMany({ where: { id: { in: orderIds } }, select: { id: true, orderNumber: true, customer: { select: { fullName: true } } } }) : [],
customerIds.length ? this.prisma.customer.findMany({ where: { id: { in: customerIds } }, select: { id: true, fullName: true, mobile: true } }) : [],
productIds.length ? this.prisma.product.findMany({ where: { id: { in: productIds } }, select: { id: true, title: true } }) : [],
]);
const m = new Map<string, string>();
for (const o of orders) m.set(o.id, `سفارش ${o.orderNumber} — ${o.customer.fullName}`);
for (const c of customers) m.set(c.id, `${c.fullName} (${c.mobile})`);
for (const p of products) m.set(p.id, p.title);
return m;
}
async retry(id: string) {
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
if (!row) throw new NotFoundException('ردیف پیدا نشد');
if (row.status === 'SENT' || row.status === 'SENDING') throw new BadRequestException('این ردیف قابل تلاش دوباره نیست');
return this.prisma.accountingOutbox.update({
where: { id },
data: { status: 'PENDING', nextAttemptAt: new Date(), attempts: row.status === 'PENDING' ? row.attempts : 0 },
});
}
async resolve(id: string, adminId: string, note?: string) {
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
if (!row) throw new NotFoundException('ردیف پیدا نشد');
if (row.status === 'SENDING') throw new BadRequestException('در حال ارسال است');
return this.prisma.accountingOutbox.update({
where: { id },
data: { status: 'RESOLVED', resolvedById: adminId, lastError: note ? `حل‌شده: ${note}` : row.lastError },
});
}
// ── issues ──────────────────────────────────────────────────────────
async issues(q: Record<string, unknown>) {
const where: Prisma.AccountingIssueWhereInput = q.resolved === '1' ? { resolvedAt: { not: null } } : { resolvedAt: null };
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingIssue.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingIssue.count({ where }),
]);
return { rows, total, page: p, limit: l };
}
resolveIssue(id: string) {
return this.prisma.accountingIssue.update({ where: { id }, data: { resolvedAt: new Date() } });
}
// ── links ───────────────────────────────────────────────────────────
async links(q: Record<string, unknown>) {
const config = await this.config.get();
const where: Prisma.AccountingLinkWhereInput = {};
if (q.entityType === 'CUSTOMER' || q.entityType === 'PRODUCT') where.entityType = q.entityType;
where.provider = isProviderName(q.provider) ? q.provider : config.provider === 'none' ? undefined : config.provider;
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
if (typeof q.q === 'string' && q.q.trim()) {
where.OR = [{ externalCode: { contains: q.q.trim() } }, { externalName: { contains: q.q.trim() } }];
}
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingLink.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingLink.count({ where }),
]);
return { rows, total, page: p, limit: l };
}
async manualLink(body: Record<string, unknown>) {
const config = await this.config.get();
if (config.provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است');
const provider = config.provider as ProviderName;
const entityType = body.entityType === 'PRODUCT' ? AccountingEntityType.PRODUCT : AccountingEntityType.CUSTOMER;
const entityId = String(body.entityId ?? '').trim();
const externalCode = String(body.externalCode ?? '').trim();
if (!entityId || !externalCode) throw new BadRequestException('شناسه و کد حسابداری الزامی است');
const exists =
entityType === 'CUSTOMER'
? await this.prisma.customer.findUnique({ where: { id: entityId }, select: { id: true } })
: await this.prisma.product.findUnique({ where: { id: entityId }, select: { id: true } });
if (!exists) throw new NotFoundException('ردیف اپ پیدا نشد');
const taken = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_externalCode: { provider, entityType, externalCode } },
});
if (taken && taken.entityId !== entityId) throw new BadRequestException('این کد به ردیف دیگری لینک است');
return this.prisma.accountingLink.upsert({
where: { provider_entityType_entityId: { provider, entityType, entityId } },
update: { externalCode, origin: 'MANUAL', syncedAt: new Date() },
create: { provider, entityType, entityId, externalCode, origin: 'MANUAL', externalName: (body.externalName as string) || null },
});
}
async unlink(id: string) {
await this.prisma.accountingLink.delete({ where: { id } }).catch(() => {
throw new NotFoundException('لینک پیدا نشد');
});
return { success: true };
}
// ── per-row panels ──────────────────────────────────────────────────
async customer(id: string) {
const config = await this.config.get();
const c = await this.prisma.customer.findUnique({
where: { id },
select: {
id: true,
goldDebtGrams: true,
fiatDebtRial: true,
accountingGoldBalance: true,
accountingRialBalance: true,
accountingBalanceAt: true,
},
});
if (!c) throw new NotFoundException('مشتری یافت نشد');
const link =
config.provider === 'none'
? null
: await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: config.provider, entityType: 'CUSTOMER', entityId: id } },
});
const fresh = link ? await this.sync.customerBalance(id) : null;
const jobs = await this.prisma.accountingOutbox.findMany({
where: { entityId: id, kind: 'CUSTOMER_PUSH' },
orderBy: { createdAt: 'desc' },
take: 5,
});
return {
provider: config.provider,
link,
internal: { gold: c.goldDebtGrams, rial: c.fiatDebtRial },
accounting: fresh
? { gold: fresh.gold, rial: fresh.rial, at: fresh.at }
: c.accountingBalanceAt
? { gold: c.accountingGoldBalance, rial: c.accountingRialBalance, at: c.accountingBalanceAt }
: null,
jobs,
};
}
async productsStatus(idsCsv: string) {
const ids = String(idsCsv ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 500);
const config = await this.config.get();
if (ids.length === 0) return {};
const [products, links] = await Promise.all([
this.prisma.product.findMany({
where: { id: { in: ids } },
select: {
id: true,
accountingStockWeight: true,
accountingStockCount: true,
accountingStockAt: true,
_count: { select: { images: true } },
},
}),
config.provider === 'none'
? []
: this.prisma.accountingLink.findMany({ where: { provider: config.provider, entityType: 'PRODUCT', entityId: { in: ids } } }),
]);
const linkBy = new Map(links.map((l) => [l.entityId, l]));
return Object.fromEntries(
products.map((p) => {
const l = linkBy.get(p.id);
return [
p.id,
{
linked: !!l,
locked: !!l && config.features.products,
code: l?.externalCode ?? null,
origin: l?.origin ?? null,
stockWeight: p.accountingStockWeight,
stockCount: p.accountingStockCount,
stockAt: p.accountingStockAt,
hasImage: p._count.images > 0,
},
];
}),
);
}
async movement(q: Record<string, unknown>) {
const a = await this.config.active();
if (!a) throw new BadRequestException('اتصال حسابداری فعال نیست');
const to = parseJalali(q.to as string) ?? toJalali(new Date());
const from = parseJalali(q.from as string) ?? toJalali(new Date(Date.now() - 30 * 24 * 3600 * 1000));
const rows = await a.provider.getKarSakhteMovement(a.settings, from, to);
return { provider: a.provider.label, scope: a.provider.capabilities.movementScope, rows };
}
}