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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -0,0 +1,366 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user