367 lines
17 KiB
TypeScript
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 };
|
||
|
|
}
|
||
|
|
}
|