Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { AccountingAdminService } from './accounting-admin.service';
|
||||
|
||||
/**
|
||||
* «اتصال حسابداری» (D27) — every route behind `config.accounting`. Nothing
|
||||
* here ever returns a token: the configuration comes back with tokens reduced
|
||||
* to `tokenSet` / `tokenReadable` flags.
|
||||
*/
|
||||
@Controller('admin/accounting')
|
||||
@RequirePermission('config.accounting')
|
||||
export class AccountingAdminController {
|
||||
constructor(private readonly admin: AccountingAdminService) {}
|
||||
|
||||
@Get()
|
||||
overview() {
|
||||
return this.admin.overview();
|
||||
}
|
||||
|
||||
@Put('config')
|
||||
save(@Body() body: Record<string, unknown>) {
|
||||
return this.admin.save(body);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(200)
|
||||
test(@Body() body: { provider?: string; connection?: Record<string, unknown> }) {
|
||||
return this.admin.test(body?.provider, body?.connection);
|
||||
}
|
||||
|
||||
@Post('certificate')
|
||||
@HttpCode(200)
|
||||
certificate(@Body() body: { baseUrl?: string }) {
|
||||
return this.admin.certificate(body?.baseUrl);
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
options(@Query('provider') provider?: string) {
|
||||
return this.admin.options(provider);
|
||||
}
|
||||
|
||||
@Post('run/:job')
|
||||
@HttpCode(200)
|
||||
run(@Param('job') job: string) {
|
||||
return this.admin.run(job);
|
||||
}
|
||||
|
||||
@Get('outbox')
|
||||
outbox(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.outbox(q);
|
||||
}
|
||||
|
||||
@Post('outbox/:id/retry')
|
||||
@HttpCode(200)
|
||||
retry(@Param('id') id: string) {
|
||||
return this.admin.retry(id);
|
||||
}
|
||||
|
||||
@Post('outbox/:id/resolve')
|
||||
@HttpCode(200)
|
||||
resolve(@Param('id') id: string, @CurrentAdmin() me: AuthenticatedAdmin, @Body() body: { note?: string }) {
|
||||
return this.admin.resolve(id, me.id, body?.note);
|
||||
}
|
||||
|
||||
@Get('issues')
|
||||
issues(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.issues(q);
|
||||
}
|
||||
|
||||
@Post('issues/:id/resolve')
|
||||
@HttpCode(200)
|
||||
resolveIssue(@Param('id') id: string) {
|
||||
return this.admin.resolveIssue(id);
|
||||
}
|
||||
|
||||
@Get('links')
|
||||
links(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.links(q);
|
||||
}
|
||||
|
||||
@Post('links')
|
||||
@HttpCode(200)
|
||||
link(@Body() body: Record<string, unknown>) {
|
||||
return this.admin.manualLink(body);
|
||||
}
|
||||
|
||||
@Delete('links/:id')
|
||||
unlink(@Param('id') id: string) {
|
||||
return this.admin.unlink(id);
|
||||
}
|
||||
|
||||
@Get('customers/:id')
|
||||
customer(@Param('id') id: string) {
|
||||
return this.admin.customer(id);
|
||||
}
|
||||
|
||||
@Get('products/status')
|
||||
productsStatus(@Query('ids') ids: string) {
|
||||
return this.admin.productsStatus(ids);
|
||||
}
|
||||
|
||||
@Get('movement')
|
||||
movement(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.movement(q);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
ACCOUNTING_CONFIG_KEY,
|
||||
ACCOUNTING_STATE_KEY,
|
||||
AccountingConfig,
|
||||
StoredConnection,
|
||||
cleanBool,
|
||||
cleanCa,
|
||||
cleanGroup,
|
||||
cleanGroups,
|
||||
cleanInt,
|
||||
cleanUrl,
|
||||
documentStatusRank,
|
||||
withDefaults,
|
||||
} from './accounting-config';
|
||||
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
|
||||
import { openSecret, sealSecret, secretKeySource } from './secret-box';
|
||||
import { parsePemCertificates } from './accounting-http';
|
||||
import { CatalogCacheService } from '../catalog/catalog-cache.service';
|
||||
|
||||
/** The first certificate of a pasted bundle, for display; null when none parses. */
|
||||
function describeCertificate(pem: string | null) {
|
||||
const [c] = parsePemCertificates(pem);
|
||||
if (!c) return null;
|
||||
return {
|
||||
subject: c.subject,
|
||||
subjectAltName: c.subjectAltName ?? null,
|
||||
validTo: c.validTo,
|
||||
expired: new Date(c.validTo).getTime() < Date.now(),
|
||||
fingerprint256: c.fingerprint256,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActiveProvider {
|
||||
provider: AccountingProvider;
|
||||
settings: ProviderSettings;
|
||||
config: AccountingConfig;
|
||||
}
|
||||
|
||||
export type JobName = 'customers' | 'balances' | 'products' | 'outbox';
|
||||
|
||||
export interface JobState {
|
||||
lastRunAt?: string;
|
||||
lastOkAt?: string;
|
||||
lastError?: string | null;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AccountingState {
|
||||
jobs: Partial<Record<JobName, JobState>>;
|
||||
}
|
||||
|
||||
/** How long a read config is reused before going back to the database. */
|
||||
const CACHE_MS = 5_000;
|
||||
|
||||
@Injectable()
|
||||
export class AccountingConfigService implements OnModuleInit {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private cache: { at: number; config: AccountingConfig } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogCache: CatalogCacheService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') {
|
||||
this.logger.warn(
|
||||
'⚠️ ACCOUNTING_SECRET_KEY تنظیم نشده است. توکن نرمافزار حسابداری با کلیدی مشتق از JWT_SECRET رمز میشود؛ ' +
|
||||
'با عوضشدن JWT_SECRET توکن ذخیرهشده قابلخواندن نمیماند و باید دوباره وارد شود. (D27)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async get(): Promise<AccountingConfig> {
|
||||
if (this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.config;
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_CONFIG_KEY } });
|
||||
const config = withDefaults(row?.value);
|
||||
this.cache = { at: Date.now(), config };
|
||||
return config;
|
||||
}
|
||||
|
||||
/** The configuration as the admin screen sees it: tokens reduced to flags. */
|
||||
async getPublic() {
|
||||
const c = await this.get();
|
||||
const publicConn = (conn: StoredConnection) => {
|
||||
const { tokenSealed, ...rest } = conn;
|
||||
return {
|
||||
...rest,
|
||||
tokenSet: !!tokenSealed,
|
||||
tokenReadable: !!tokenSealed && openSecret(tokenSealed) !== null,
|
||||
// What the pinned certificate is, so the screen can show it instead of PEM.
|
||||
caInfo: describeCertificate(rest.caCert),
|
||||
};
|
||||
};
|
||||
return {
|
||||
...c,
|
||||
tahesab: publicConn(c.tahesab),
|
||||
zarrin: publicConn(c.zarrin),
|
||||
secretKeySource: secretKeySource(),
|
||||
providers: Object.values(ACCOUNTING_PROVIDERS).map((p) => ({
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
capabilities: p.capabilities,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an admin's edit. Anything not sent is kept. A token is replaced
|
||||
* only by a non-empty string and removed only by an explicit null — so the
|
||||
* form can be saved without ever having seen the token.
|
||||
*/
|
||||
async save(input: Record<string, any>): Promise<{ providerChanged: boolean }> {
|
||||
const current = await this.get();
|
||||
const next: AccountingConfig = JSON.parse(JSON.stringify(current));
|
||||
|
||||
if (input.provider !== undefined) {
|
||||
if (input.provider !== 'none' && !isProviderName(input.provider)) {
|
||||
throw new BadRequestException('نرمافزار حسابداری نامعتبر است');
|
||||
}
|
||||
next.provider = input.provider;
|
||||
}
|
||||
|
||||
const conn = (key: ProviderName) => {
|
||||
const src = input[key];
|
||||
if (!src || typeof src !== 'object') return;
|
||||
const label = ACCOUNTING_PROVIDERS[key].label;
|
||||
const dst: StoredConnection = next[key];
|
||||
if (src.baseUrl !== undefined) dst.baseUrl = cleanUrl(src.baseUrl, `آدرس ${label}`);
|
||||
if (src.caCert !== undefined) dst.caCert = cleanCa(src.caCert);
|
||||
if (src.timeoutMs !== undefined) dst.timeoutMs = cleanInt(src.timeoutMs, 'مهلت پاسخ (میلیثانیه)', 3000, 120000, dst.timeoutMs);
|
||||
if (src.token === null) dst.tokenSealed = null;
|
||||
else if (typeof src.token === 'string' && src.token.trim()) {
|
||||
try {
|
||||
dst.tokenSealed = sealSecret(src.token.trim());
|
||||
} catch (err) {
|
||||
throw new BadRequestException((err as Error).message);
|
||||
}
|
||||
}
|
||||
};
|
||||
conn('tahesab');
|
||||
conn('zarrin');
|
||||
|
||||
if (input.tahesab?.dbName !== undefined) {
|
||||
const db = String(input.tahesab.dbName ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_]{1,64}$/.test(db)) throw new BadRequestException('نام دیتابیس تهحساب نامعتبر است');
|
||||
next.tahesab.dbName = db;
|
||||
}
|
||||
if (input.zarrin?.balanceOwesWhen !== undefined) {
|
||||
if (input.zarrin.balanceOwesWhen !== 'negative' && input.zarrin.balanceOwesWhen !== 'positive') {
|
||||
throw new BadRequestException('علامت ماندهی زرین نامعتبر است');
|
||||
}
|
||||
next.zarrin.balanceOwesWhen = input.zarrin.balanceOwesWhen;
|
||||
}
|
||||
|
||||
if (input.features && typeof input.features === 'object') {
|
||||
for (const k of Object.keys(next.features) as (keyof AccountingConfig['features'])[]) {
|
||||
next.features[k] = cleanBool(input.features[k], next.features[k]);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.intervals && typeof input.intervals === 'object') {
|
||||
const i = input.intervals;
|
||||
next.intervals.customersMinutes = cleanInt(i.customersMinutes, 'بازهی سینک مشتری', 5, 1440, next.intervals.customersMinutes);
|
||||
next.intervals.balancesMinutes = cleanInt(i.balancesMinutes, 'بازهی سینک مانده', 1, 1440, next.intervals.balancesMinutes);
|
||||
next.intervals.productsMinutes = cleanInt(i.productsMinutes, 'بازهی سینک کارساخته', 5, 1440, next.intervals.productsMinutes);
|
||||
}
|
||||
|
||||
if (input.mapping && typeof input.mapping === 'object') {
|
||||
const m = input.mapping;
|
||||
if (m.importCustomerGroups !== undefined) next.mapping.importCustomerGroups = cleanGroups(m.importCustomerGroups);
|
||||
if (m.newCustomerGroup !== undefined) next.mapping.newCustomerGroup = cleanGroup(m.newCustomerGroup);
|
||||
if (m.newKarSakhteGroup !== undefined) next.mapping.newKarSakhteGroup = cleanGroup(m.newKarSakhteGroup);
|
||||
if (m.newKarSakhteType !== undefined) next.mapping.newKarSakhteType = cleanGroup(m.newKarSakhteType);
|
||||
if (m.categoryGroups !== undefined) {
|
||||
next.mapping.categoryGroups = (Array.isArray(m.categoryGroups) ? m.categoryGroups : [])
|
||||
.map((x: any) => ({ categoryId: String(x?.categoryId ?? '').trim(), group: cleanGroup(x?.group) }))
|
||||
.filter((x: any) => x.categoryId && x.group);
|
||||
}
|
||||
if (m.karat !== undefined) next.mapping.karat = cleanInt(m.karat, 'عیار پایه', 1, 1000, next.mapping.karat);
|
||||
}
|
||||
|
||||
if (input.documents && typeof input.documents === 'object' && input.documents.triggerStatus !== undefined) {
|
||||
if (!documentStatusRank(String(input.documents.triggerStatus))) {
|
||||
throw new BadRequestException('وضعیت ثبت سند سفارش نامعتبر است');
|
||||
}
|
||||
next.documents.triggerStatus = input.documents.triggerStatus;
|
||||
}
|
||||
|
||||
if (input.report && typeof input.report === 'object') {
|
||||
next.report.windowDays = cleanInt(input.report.windowDays, 'بازهی گزارش (روز)', 7, 3650, next.report.windowDays);
|
||||
}
|
||||
|
||||
// Selecting a software that has no address/token would switch the sync
|
||||
// off without a word (the worker finds nothing usable).
|
||||
if (next.provider !== 'none' && (!next[next.provider].baseUrl || !next[next.provider].tokenSealed)) {
|
||||
const label = ACCOUNTING_PROVIDERS[next.provider].label;
|
||||
throw new BadRequestException(`«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`);
|
||||
}
|
||||
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: ACCOUNTING_CONFIG_KEY },
|
||||
update: { value: next as unknown as Prisma.InputJsonValue },
|
||||
create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
this.cache = null;
|
||||
// The provider and product-sync switch decide which products read as out of stock.
|
||||
this.catalogCache.invalidate();
|
||||
return { providerChanged: current.provider !== next.provider };
|
||||
}
|
||||
|
||||
/** Decrypted settings for one provider; null when it is not usable. */
|
||||
settingsFor(config: AccountingConfig, name: ProviderName): ProviderSettings | null {
|
||||
const conn = config[name];
|
||||
if (!conn.baseUrl) return null;
|
||||
const token = openSecret(conn.tokenSealed);
|
||||
if (!token) return null;
|
||||
return {
|
||||
baseUrl: conn.baseUrl,
|
||||
token,
|
||||
caCert: conn.caCert,
|
||||
timeoutMs: conn.timeoutMs,
|
||||
dbName: name === 'tahesab' ? config.tahesab.dbName : undefined,
|
||||
balanceOwesWhen: name === 'zarrin' ? config.zarrin.balanceOwesWhen : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings as the admin form currently shows them, saved or not, so «تست
|
||||
* اتصال» checks what is on screen. A blank token means "the saved one".
|
||||
* Validated like a save; nothing is stored.
|
||||
*/
|
||||
draftSettings(config: AccountingConfig, name: ProviderName, input: Record<string, any> | undefined): ProviderSettings | null {
|
||||
const src = input && typeof input === 'object' ? input : {};
|
||||
const label = ACCOUNTING_PROVIDERS[name].label;
|
||||
const saved = config[name];
|
||||
const baseUrl = src.baseUrl !== undefined ? cleanUrl(src.baseUrl, `آدرس ${label}`) : saved.baseUrl;
|
||||
const token = typeof src.token === 'string' && src.token.trim() ? src.token.trim() : openSecret(saved.tokenSealed);
|
||||
if (!baseUrl || !token) return null;
|
||||
let dbName = config.tahesab.dbName;
|
||||
if (name === 'tahesab' && src.dbName !== undefined) {
|
||||
dbName = String(src.dbName ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_]{1,64}$/.test(dbName)) throw new BadRequestException('نام دیتابیس تهحساب نامعتبر است');
|
||||
}
|
||||
let balanceOwesWhen = config.zarrin.balanceOwesWhen;
|
||||
if (name === 'zarrin' && (src.balanceOwesWhen === 'negative' || src.balanceOwesWhen === 'positive')) {
|
||||
balanceOwesWhen = src.balanceOwesWhen;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
caCert: src.caCert !== undefined ? cleanCa(src.caCert) : saved.caCert,
|
||||
timeoutMs: cleanInt(src.timeoutMs, 'مهلت پاسخ (میلیثانیه)', 3000, 120000, saved.timeoutMs),
|
||||
dbName: name === 'tahesab' ? dbName : undefined,
|
||||
balanceOwesWhen: name === 'zarrin' ? balanceOwesWhen : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Why nothing syncs, in words for the admin screen; null when a provider is usable. */
|
||||
inactiveReason(config: AccountingConfig): string | null {
|
||||
if (config.provider === 'none') return 'سوییچ روی «بدون اتصال» است';
|
||||
const label = ACCOUNTING_PROVIDERS[config.provider].label;
|
||||
const conn = config[config.provider];
|
||||
if (!conn.baseUrl || !conn.tokenSealed) return `«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`;
|
||||
if (!this.settingsFor(config, config.provider)) return `توکن ذخیرهشدهی «${label}» قابلخواندن نیست؛ دوباره وارد کنید`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The selected, fully configured provider — or null when the switch is off. */
|
||||
async active(): Promise<ActiveProvider | null> {
|
||||
const config = await this.get();
|
||||
if (config.provider === 'none') return null;
|
||||
const settings = this.settingsFor(config, config.provider);
|
||||
if (!settings) return null;
|
||||
return { provider: ACCOUNTING_PROVIDERS[config.provider], settings, config };
|
||||
}
|
||||
|
||||
/** Active provider with this feature switched on, or null. */
|
||||
async activeFor(feature: keyof AccountingConfig['features']): Promise<ActiveProvider | null> {
|
||||
const a = await this.active();
|
||||
return a && a.config.features[feature] ? a : null;
|
||||
}
|
||||
|
||||
// ── job bookkeeping (shown on the admin screen) ───────────────────────
|
||||
|
||||
async getState(): Promise<AccountingState> {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_STATE_KEY } });
|
||||
const v = (row?.value ?? {}) as Partial<AccountingState>;
|
||||
return { jobs: v.jobs ?? {} };
|
||||
}
|
||||
|
||||
async recordJob(job: JobName, patch: JobState) {
|
||||
const state = await this.getState();
|
||||
state.jobs[job] = { ...(state.jobs[job] ?? {}), ...patch };
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: ACCOUNTING_STATE_KEY },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: { key: ACCOUNTING_STATE_KEY, value: state as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ExternalGroup, ProviderName } from './providers/accounting-provider';
|
||||
|
||||
/**
|
||||
* The admin-editable configuration of the accounting integration (D27),
|
||||
* stored as one Setting row. Every `accounting.*` Setting key is refused by
|
||||
* the generic `const` routes (admin-misc), so the only way in or out is the
|
||||
* dedicated, `config.accounting`-guarded screen — and even that never returns
|
||||
* a token.
|
||||
*/
|
||||
|
||||
export const ACCOUNTING_SETTING_PREFIX = 'accounting.';
|
||||
export const ACCOUNTING_CONFIG_KEY = 'accounting.config';
|
||||
export const ACCOUNTING_STATE_KEY = 'accounting.state';
|
||||
|
||||
/** Keys the generic `const` settings routes must never read or write. */
|
||||
export function isAccountingSettingKey(key: string) {
|
||||
return key.trim().toLowerCase().startsWith(ACCOUNTING_SETTING_PREFIX);
|
||||
}
|
||||
|
||||
export type ProviderChoice = 'none' | ProviderName;
|
||||
|
||||
export interface StoredConnection {
|
||||
baseUrl: string;
|
||||
/** AES-GCM sealed (secret-box.ts). Never leaves the server. */
|
||||
tokenSealed: string | null;
|
||||
/** PEM CA of a self-signed installation; verification is never turned off. */
|
||||
caCert: string | null;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface AccountingFeatures {
|
||||
/** F1 — customer import/link and push. */
|
||||
customers: boolean;
|
||||
/** F2 — balance pull; the customer sees the accounting balance. */
|
||||
balances: boolean;
|
||||
/** F6 — «گزارش» reads the accounting statement. */
|
||||
report: boolean;
|
||||
/** F4 — کارساخته ↔ product sync and the out-of-stock lock. */
|
||||
products: boolean;
|
||||
/** F3 — order documents. */
|
||||
documents: boolean;
|
||||
}
|
||||
|
||||
export interface AccountingConfig {
|
||||
provider: ProviderChoice;
|
||||
tahesab: StoredConnection & { dbName: string };
|
||||
zarrin: StoredConnection & { balanceOwesWhen: 'negative' | 'positive' };
|
||||
features: AccountingFeatures;
|
||||
intervals: {
|
||||
customersMinutes: number;
|
||||
balancesMinutes: number;
|
||||
productsMinutes: number;
|
||||
};
|
||||
mapping: {
|
||||
/** Account groups imported into the app; empty = every group. */
|
||||
importCustomerGroups: ExternalGroup[];
|
||||
/** Group a customer created from the app is put in. */
|
||||
newCustomerGroup: ExternalGroup | null;
|
||||
/** Zarrin: group (گروه ساخته) for a product created in the admin… */
|
||||
newKarSakhteGroup: ExternalGroup | null;
|
||||
/** …overridden per app category. */
|
||||
categoryGroups: { categoryId: string; group: ExternalGroup }[];
|
||||
/** Zarrin: item type (گرمی/تعدادی…) for a product created in the admin. */
|
||||
newKarSakhteType: ExternalGroup | null;
|
||||
/** Purity the app's grams are in. */
|
||||
karat: number;
|
||||
};
|
||||
/** How far back «گزارش» reads the accounting statement. */
|
||||
report: { windowDays: number };
|
||||
/** F3 — the order status at (or past) which the temporary document (پیشفاکتور) is posted. */
|
||||
documents: { triggerStatus: DocumentTriggerStatus };
|
||||
}
|
||||
|
||||
/** Order statuses in the order an order passes them; SUSPENDED/CANCELLED never trigger. */
|
||||
export const DOCUMENT_TRIGGER_STATUSES = [
|
||||
'PLACED',
|
||||
'ADMIN_APPROVED',
|
||||
'SOURCING',
|
||||
'PREPARING',
|
||||
'DELIVERED_TO_AGENT',
|
||||
'DELIVERED_TO_CUSTOMER',
|
||||
] as const;
|
||||
export type DocumentTriggerStatus = (typeof DOCUMENT_TRIGGER_STATUSES)[number];
|
||||
|
||||
/** 1-based place of a status in DOCUMENT_TRIGGER_STATUSES; 0 = never triggers. */
|
||||
export function documentStatusRank(status: string): number {
|
||||
return DOCUMENT_TRIGGER_STATUSES.indexOf(status as DocumentTriggerStatus) + 1;
|
||||
}
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function defaultConfig(): AccountingConfig {
|
||||
return {
|
||||
provider: 'none',
|
||||
tahesab: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, dbName: 'TahesabDB' },
|
||||
zarrin: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, balanceOwesWhen: 'negative' },
|
||||
features: { customers: false, balances: false, report: false, products: false, documents: false },
|
||||
intervals: { customersMinutes: 60, balancesMinutes: 10, productsMinutes: 30 },
|
||||
mapping: {
|
||||
importCustomerGroups: [],
|
||||
newCustomerGroup: null,
|
||||
newKarSakhteGroup: null,
|
||||
categoryGroups: [],
|
||||
newKarSakhteType: null,
|
||||
karat: 750,
|
||||
},
|
||||
report: { windowDays: 365 },
|
||||
// The point the debt is recognised — the behaviour before the rule existed.
|
||||
documents: { triggerStatus: 'DELIVERED_TO_AGENT' },
|
||||
};
|
||||
}
|
||||
|
||||
/** Stored JSON → a complete config, tolerating rows written by older code. */
|
||||
export function withDefaults(raw: unknown): AccountingConfig {
|
||||
const d = defaultConfig();
|
||||
const r = (raw && typeof raw === 'object' ? raw : {}) as Partial<AccountingConfig>;
|
||||
return {
|
||||
provider: r.provider === 'tahesab' || r.provider === 'zarrin' ? r.provider : 'none',
|
||||
tahesab: { ...d.tahesab, ...(r.tahesab ?? {}) },
|
||||
zarrin: { ...d.zarrin, ...(r.zarrin ?? {}) },
|
||||
features: { ...d.features, ...(r.features ?? {}) },
|
||||
intervals: { ...d.intervals, ...(r.intervals ?? {}) },
|
||||
mapping: { ...d.mapping, ...(r.mapping ?? {}) },
|
||||
report: { ...d.report, ...(r.report ?? {}) },
|
||||
documents: { ...d.documents, ...(r.documents ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// ── input validation ────────────────────────────────────────────────────
|
||||
|
||||
export function cleanUrl(v: unknown, label: string): string {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!s) return '';
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(s);
|
||||
} catch {
|
||||
throw new BadRequestException(`${label}: آدرس معتبر نیست`);
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
throw new BadRequestException(`${label}: آدرس باید با http:// یا https:// شروع شود`);
|
||||
}
|
||||
if (u.username || u.password) throw new BadRequestException(`${label}: آدرس نباید نام کاربری/رمز داشته باشد`);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function cleanCa(v: unknown): string | null {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!s) return null;
|
||||
if (!s.includes('-----BEGIN CERTIFICATE-----')) {
|
||||
throw new BadRequestException('گواهی CA باید با قالب PEM (BEGIN CERTIFICATE) باشد');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function cleanInt(v: unknown, label: string, min: number, max: number, fallback: number): number {
|
||||
if (v === undefined || v === null || v === '') return fallback;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min || n > max) {
|
||||
throw new BadRequestException(`${label}: عدد صحیح بین ${min} و ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function cleanGroup(v: unknown): ExternalGroup | null {
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const g = v as Record<string, unknown>;
|
||||
const id = g.id === undefined || g.id === null ? '' : String(g.id).trim();
|
||||
const name = typeof g.name === 'string' ? g.name.trim() : '';
|
||||
if (!id && !name) return null;
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
export function cleanGroups(v: unknown): ExternalGroup[] {
|
||||
return Array.isArray(v) ? v.map(cleanGroup).filter((g): g is ExternalGroup => !!g) : [];
|
||||
}
|
||||
|
||||
export function cleanBool(v: unknown, fallback: boolean): boolean {
|
||||
return typeof v === 'boolean' ? v : fallback;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { AccountingHooksService } from './accounting-hooks.service';
|
||||
import { AccountingConfig, defaultConfig } from './accounting-config';
|
||||
|
||||
type Row = { id: string; provider: string; kind: string; entityId: string; status: string };
|
||||
|
||||
/** Just enough of a Prisma transaction client for the outbox rules. */
|
||||
function fakeTx(rows: Row[]) {
|
||||
const match = (r: Row, where: any) =>
|
||||
(!where.provider || r.provider === where.provider) &&
|
||||
(!where.kind || r.kind === where.kind) &&
|
||||
(!where.entityId || r.entityId === where.entityId) &&
|
||||
(!where.status ||
|
||||
(where.status.in ? where.status.in.includes(r.status) : true) &&
|
||||
(where.status.notIn ? !where.status.notIn.includes(r.status) : true) &&
|
||||
(where.status.not ? r.status !== where.status.not : true));
|
||||
return {
|
||||
accountingOutbox: {
|
||||
findFirst: async ({ where }: any) => rows.find((r) => match(r, where)) ?? null,
|
||||
findMany: async ({ where }: any) => rows.filter((r) => match(r, where)),
|
||||
create: async ({ data }: any) => {
|
||||
rows.push({ id: String(rows.length + 1), status: 'PENDING', ...data });
|
||||
},
|
||||
update: async ({ where, data }: any) => {
|
||||
Object.assign(rows.find((r) => r.id === where.id)!, data);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hooks(trigger: AccountingConfig['documents']['triggerStatus'], provider: 'tahesab' | 'zarrin' = 'tahesab') {
|
||||
const c = defaultConfig();
|
||||
c.provider = provider;
|
||||
c.features.documents = true;
|
||||
c.documents.triggerStatus = trigger;
|
||||
const config = { get: async () => c } as any;
|
||||
const worker = { kickSoon: jest.fn() } as any;
|
||||
return new AccountingHooksService({} as any, config, {} as any, worker);
|
||||
}
|
||||
|
||||
const docs = (rows: Row[], kind = 'ORDER_DOCUMENT') => rows.filter((r) => r.kind === kind);
|
||||
|
||||
describe('document rule (پیشفاکتور at a chosen status)', () => {
|
||||
it('rule «ثبت سفارش»: the document is queued as the order is placed', async () => {
|
||||
const rows: Row[] = [];
|
||||
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('default rule: nothing before «تحویل مسئول فروش», then once', async () => {
|
||||
const rows: Row[] = [];
|
||||
const h = hooks('DELIVERED_TO_AGENT');
|
||||
for (const s of ['PLACED', 'ADMIN_APPROVED', 'PREPARING']) await h.orderStatusReached(fakeTx(rows) as any, 'o1', s);
|
||||
expect(docs(rows)).toHaveLength(0);
|
||||
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_AGENT');
|
||||
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an order that jumps past the rule status still gets its document', async () => {
|
||||
const rows: Row[] = [];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a suspended order never triggers', async () => {
|
||||
const rows: Row[] = [];
|
||||
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'SUSPENDED');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rolled back below the rule: a waiting document is dropped, a sent one gets a delete job', async () => {
|
||||
const waiting: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'PENDING' }];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(waiting) as any, 'o1', 'ADMIN_APPROVED');
|
||||
expect(waiting[0].status).toBe('RESOLVED');
|
||||
|
||||
const sent: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' }];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(sent) as any, 'o1', 'ADMIN_APPROVED');
|
||||
expect(docs(sent, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Tahesab: reaching the rule again after a withdrawal queues a new document behind the delete', async () => {
|
||||
const rows: Row[] = [
|
||||
{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
|
||||
{ id: '2', provider: 'tahesab', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
|
||||
];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
|
||||
expect(docs(rows)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Zarrin: never queued twice (no duplicate guard in its API)', async () => {
|
||||
const rows: Row[] = [
|
||||
{ id: '1', provider: 'zarrin', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
|
||||
{ id: '2', provider: 'zarrin', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
|
||||
];
|
||||
await hooks('PREPARING', 'zarrin').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing while the document feature is off', async () => {
|
||||
const rows: Row[] = [];
|
||||
const h = hooks('PLACED');
|
||||
((await (h as any).config.get()) as AccountingConfig).features.documents = false;
|
||||
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountingEntityType, AccountingOutboxKind, AccountingOutboxStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingFeatures, documentStatusRank } from './accounting-config';
|
||||
import { AccountingSyncService } from './accounting-sync.service';
|
||||
import { AccountingWorkerService } from './accounting-worker.service';
|
||||
import { ACCOUNTING_PROVIDERS } from './providers';
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaService;
|
||||
|
||||
type StockFields = {
|
||||
accountingStockAt: Date | null;
|
||||
accountingStockWeight: Prisma.Decimal | null;
|
||||
accountingStockCount: Prisma.Decimal | null;
|
||||
};
|
||||
|
||||
const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING'];
|
||||
|
||||
/**
|
||||
* The narrow surface the rest of the app calls into (D27): orders, customer
|
||||
* admin, product admin, catalog. Every write goes through the caller's own
|
||||
* transaction client, so the outbox row commits or rolls back with the change
|
||||
* that caused it. Nothing here talks to the accounting software.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountingHooksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AccountingConfigService,
|
||||
private readonly sync: AccountingSyncService,
|
||||
private readonly worker: AccountingWorkerService,
|
||||
) {}
|
||||
|
||||
private async enqueue(tx: Tx, feature: keyof AccountingFeatures, kind: AccountingOutboxKind, entityId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features[feature]) return false;
|
||||
const open = await tx.accountingOutbox.findFirst({
|
||||
where: { provider: c.provider, kind, entityId, status: { in: OPEN } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (open) return false;
|
||||
await tx.accountingOutbox.create({ data: { provider: c.provider, kind, entityId } });
|
||||
this.worker.kickSoon();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 — the order is now in `status` (created, advanced, or rolled back).
|
||||
* The owner's rule (`documents.triggerStatus`, «اتصال حسابداری») says from
|
||||
* which status on the temporary document (پیشفاکتور) belongs in the books:
|
||||
* at or past it the document is queued once; back below it, a document
|
||||
* already sent is withdrawn like a cancellation.
|
||||
*/
|
||||
async orderStatusReached(tx: Tx, orderId: string, status: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.documents) return;
|
||||
const rank = documentStatusRank(status);
|
||||
if (!rank) return;
|
||||
if (rank >= documentStatusRank(c.documents.triggerStatus)) await this.queueOrderDocument(tx, c.provider, orderId);
|
||||
else await this.orderCancelled(tx, orderId);
|
||||
}
|
||||
|
||||
private async queueOrderDocument(tx: Tx, provider: string, orderId: string) {
|
||||
const existing = await tx.accountingOutbox.findFirst({
|
||||
where: { provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { notIn: ['RESOLVED'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
// Withdrawn and wanted again: Tahesab deletes first (older job), then
|
||||
// the new one posts — and a duplicate is refused by its Factor_Code key.
|
||||
// Zarrin has no such key, so its document is never queued twice.
|
||||
const deleting = await tx.accountingOutbox.findFirst({
|
||||
where: { provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { in: ['PENDING', 'SENDING'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!(provider === 'tahesab' && deleting)) return;
|
||||
}
|
||||
await this.enqueue(tx, 'documents', 'ORDER_DOCUMENT', orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 — the order was cancelled. A document still waiting is simply dropped;
|
||||
* one that may already be in the books gets a delete job, which itself only
|
||||
* deletes a document that is still temporary (worker).
|
||||
*/
|
||||
async orderCancelled(tx: Tx, orderId: string) {
|
||||
const docs = await tx.accountingOutbox.findMany({
|
||||
where: { kind: 'ORDER_DOCUMENT', entityId: orderId, status: { not: 'RESOLVED' } },
|
||||
});
|
||||
for (const doc of docs) {
|
||||
if (doc.status === 'PENDING') {
|
||||
await tx.accountingOutbox.update({
|
||||
where: { id: doc.id },
|
||||
data: { status: 'RESOLVED', lastError: 'سفارش پیش از ارسال سند لغو شد' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const open = await tx.accountingOutbox.findFirst({
|
||||
where: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { not: 'RESOLVED' } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!open) {
|
||||
await tx.accountingOutbox.create({
|
||||
data: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** F1 — an admin marked the customer VERIFIED. */
|
||||
async customerVerified(tx: Tx, customerId: string) {
|
||||
await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
|
||||
}
|
||||
|
||||
/** F1 — a linked customer's contact details changed. */
|
||||
async customerEdited(tx: Tx, customerId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.customers) return;
|
||||
const link = await tx.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: c.provider, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (link) await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
|
||||
}
|
||||
|
||||
/** F4 — a product was created in the admin panel. */
|
||||
async productCreated(tx: Tx, productId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !ACCOUNTING_PROVIDERS[c.provider].capabilities.createKarSakhte) return;
|
||||
await this.enqueue(tx, 'products', 'PRODUCT_PUSH', productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 — title and code of a linked product belong to the accounting software
|
||||
* (D27); the admin form locks them. Null when the product is not linked.
|
||||
*/
|
||||
async productLink(productId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.products) return null;
|
||||
return this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: c.provider, entityType: AccountingEntityType.PRODUCT, entityId: productId } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Order time: the product with its stock read live from the accounting
|
||||
* software, or as last stored when that cannot answer in a moment.
|
||||
*/
|
||||
liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(product: T) {
|
||||
return this.sync.liveStock(product);
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 — is this product out of stock in the accounting software? Only
|
||||
* meaningful while the product sync is on; stock fields of products that are
|
||||
* not linked under the active provider are cleared by every product pull.
|
||||
*/
|
||||
async stockBlocked(product: StockFields) {
|
||||
return (await this.stockBlocker())(product);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule as stockBlocked, with the configuration read once — for
|
||||
* checking a whole list without an await per product.
|
||||
*/
|
||||
async stockBlocker(): Promise<(product: StockFields) => boolean> {
|
||||
const c = await this.config.get();
|
||||
const active = c.provider !== 'none' && c.features.products;
|
||||
return (product) => {
|
||||
if (!active || !product.accountingStockAt) return false;
|
||||
const stock = product.accountingStockWeight ?? product.accountingStockCount;
|
||||
return stock !== null && new Prisma.Decimal(stock).lte(0);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as https from 'https';
|
||||
import { AddressInfo } from 'net';
|
||||
import { AccountingHttpError, fetchServerCertificate, httpRequest, pinnedFingerprints } from './accounting-http';
|
||||
|
||||
// Throwaway self-signed test certificates (EC P-256, valid to 2126), issued
|
||||
// for *.invalid names so connecting to 127.0.0.1 never matches them — the
|
||||
// same situation as a Tahesab install whose certificate says `localhost`.
|
||||
const CERT_A = `-----BEGIN CERTIFICATE-----
|
||||
MIIBxTCCAWugAwIBAgIUNaqVnWRRUyN8hOGPQtARjZymRcUwCgYIKoZIzj0EAwIw
|
||||
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWEuaW52YWxpZDAgFw0yNjEwMDUx
|
||||
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
|
||||
ZXN0LWEuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFC+h8KB1h41
|
||||
56L9+l8nHbMZ5hrrp9nwqmz5RoAqrkVI8Ii5RFYUjssaH2Y0P9+B9L9SvijfB5Xi
|
||||
Wwk6eVzGKtSjeTB3MB0GA1UdDgQWBBSokIrPzA6ur33vyVeBY+2/NWHx1TAfBgNV
|
||||
HSMEGDAWgBSokIrPzA6ur33vyVeBY+2/NWHx1TAPBgNVHRMBAf8EBTADAQH/MCQG
|
||||
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1hLmludmFsaWQwCgYIKoZIzj0EAwID
|
||||
SAAwRQIgCLZVDlrE2aO8poWAdZMOd+obOeB23TU1Jfh3nnbEDF0CIQCB5+8PNRQe
|
||||
aWzW6sgCcDWdiga81Nhm7FxYj4AorbPO1w==
|
||||
-----END CERTIFICATE-----`;
|
||||
const KEY_A = `-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOQ//h9ZRCK1KaiXW
|
||||
Ij3FN+77RDPYSWgY6DwZqC5x0A6hRANCAARQvofCgdYeNeei/fpfJx2zGeYa66fZ
|
||||
8Kps+UaAKq5FSPCIuURWFI7LGh9mND/fgfS/Ur4o3weV4lsJOnlcxirU
|
||||
-----END PRIVATE KEY-----`;
|
||||
const CERT_B = `-----BEGIN CERTIFICATE-----
|
||||
MIIBxjCCAWugAwIBAgIUMO+HE/U7Z4EXTnw9ucoKjRz/u7MwCgYIKoZIzj0EAwIw
|
||||
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWIuaW52YWxpZDAgFw0yNjEwMDUx
|
||||
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
|
||||
ZXN0LWIuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABI2Naohejia1
|
||||
fQnFx33/5jasQJudFTmAdPEHxlIyBlUUi2IoaMWbSyMBBfgy+dX6gM7QetK/99ex
|
||||
rt/d5OQNyvqjeTB3MB0GA1UdDgQWBBSgpkkpJptOajy+1aSgPZ8gre559TAfBgNV
|
||||
HSMEGDAWgBSgpkkpJptOajy+1aSgPZ8gre559TAPBgNVHRMBAf8EBTADAQH/MCQG
|
||||
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1iLmludmFsaWQwCgYIKoZIzj0EAwID
|
||||
SQAwRgIhALJMbkGHT256tdNcVJfnB0eqxivGD7hooljD6epmvTgbAiEA1r/cX59l
|
||||
0bu56aMEi0TeoQAAQRiEpdBIwe0F/YjWXL4=
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
describe('accounting HTTP client — TLS with a pinned certificate', () => {
|
||||
let server: https.Server;
|
||||
let url: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = https.createServer({ cert: CERT_A, key: KEY_A }, (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"Status":"OK"}');
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
url = `https://127.0.0.1:${(server.address() as AddressInfo).port}/`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
const call = (ca: string | null) => httpRequest({ method: 'POST', url, body: '{}', timeoutMs: 5000, ca });
|
||||
|
||||
it('rejects a self-signed server when nothing is pinned, without claiming it may have been received', async () => {
|
||||
const err = await call(null).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(AccountingHttpError);
|
||||
expect(err.ambiguous).toBe(false);
|
||||
expect(err.message).toContain('بررسی گواهی سرور');
|
||||
});
|
||||
|
||||
it('accepts exactly the pinned certificate even though it names another host', async () => {
|
||||
await expect(call(CERT_A)).resolves.toEqual({ status: 200, body: '{"Status":"OK"}' });
|
||||
});
|
||||
|
||||
it('still rejects when a different certificate is pinned', async () => {
|
||||
const err = await call(CERT_B).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(AccountingHttpError);
|
||||
expect(err.message).toContain('یکی نیست');
|
||||
});
|
||||
|
||||
it('reads the presented certificate for the admin to compare and pin', async () => {
|
||||
const c = await fetchServerCertificate(url, 5000);
|
||||
expect(c.fingerprint256).toBe([...pinnedFingerprints(CERT_A)][0]);
|
||||
expect(c.selfSigned).toBe(true);
|
||||
expect(c.trustedBySystem).toBe(false);
|
||||
expect(c.subjectAltName).toContain('accounting-test-a.invalid');
|
||||
expect(pinnedFingerprints(c.pem).has(c.fingerprint256)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to fetch a certificate from a plain-http address', async () => {
|
||||
await expect(fetchServerCertificate('http://127.0.0.1:1/', 1000)).rejects.toThrow('https');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { X509Certificate } from 'crypto';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
|
||||
/**
|
||||
* The one HTTP client both accounting drivers use.
|
||||
*
|
||||
* Built on node:http(s) rather than global fetch because a self-hosted
|
||||
* Tahesab/Zarrin often serves a self-signed certificate, and fetch cannot be
|
||||
* given a CA without an extra dependency. The reference "solved" that with
|
||||
* `Http::withoutVerifying()` (SEC-17); here verification is ALWAYS on and the
|
||||
* admin can paste the installation's certificate instead.
|
||||
*
|
||||
* A pasted certificate is trusted two ways: as a CA (the normal chain and
|
||||
* hostname checks), and as a pin — when the server presents exactly that
|
||||
* certificate it is accepted whatever name it was issued for. Tahesab's own
|
||||
* certificate is self-signed for `localhost`, so a remote install reached by
|
||||
* IP can only be verified by the pin.
|
||||
*
|
||||
* Errors say whether the request may have reached the server. That decides
|
||||
* what the outbox may do next: a refused connection is safe to retry, a
|
||||
* timeout after the request went out is not (Zarrin has no idempotency key).
|
||||
*/
|
||||
|
||||
export interface HttpRequest {
|
||||
method: 'GET' | 'POST';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs: number;
|
||||
/** PEM CA certificate(s) to trust in addition to the system store. */
|
||||
ca?: string | null;
|
||||
}
|
||||
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class AccountingHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** True when the request may have been received and acted on. */
|
||||
readonly ambiguous: boolean,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountingHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const TLS_CERT_ERRORS = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/;
|
||||
|
||||
/** The PEM blocks in a pasted bundle, parsed; unparsable blocks are skipped. */
|
||||
export function parsePemCertificates(pem: string | null | undefined): X509Certificate[] {
|
||||
const blocks = (pem ?? '').match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
|
||||
const out: X509Certificate[] = [];
|
||||
for (const block of blocks) {
|
||||
try {
|
||||
out.push(new X509Certificate(block));
|
||||
} catch {
|
||||
// not a certificate — the CA list below will reject it too
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprints, as Node prints them (`AB:CD:…`), of the pasted certificates. */
|
||||
export function pinnedFingerprints(pem: string | null | undefined): Set<string> {
|
||||
return new Set(parsePemCertificates(pem).map((c) => c.fingerprint256));
|
||||
}
|
||||
|
||||
function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string {
|
||||
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
||||
return pinned
|
||||
? `گواهی سرور برای این آدرس صادر نشده و با گواهی ثبتشده در تنظیمات هم یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
|
||||
: `گواهی TLS سرور برای این آدرس صادر نشده (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
|
||||
}
|
||||
return pinned
|
||||
? `گواهی فعلی سرور با گواهی ثبتشده در تنظیمات یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
|
||||
: `گواهی TLS سرور قابلاعتماد نیست (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
|
||||
}
|
||||
|
||||
export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||
return send(req, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt. `fresh` = on a new connection: a server that closes idle
|
||||
* kept-alive connections (Zarrin v228 does) makes the next request on a pooled
|
||||
* one fail with ECONNRESET / "socket hang up" before it was received. That one
|
||||
* case is repeated once on a fresh connection; nothing else is ever retried.
|
||||
*/
|
||||
function send(req: HttpRequest, fresh: boolean): Promise<HttpResponse> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url);
|
||||
} catch {
|
||||
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
|
||||
}
|
||||
const isHttps = url.protocol === 'https:';
|
||||
if (!isHttps && url.protocol !== 'http:') {
|
||||
return Promise.reject(new AccountingHttpError('آدرس باید با http یا https شروع شود', false, 'BAD_URL'));
|
||||
}
|
||||
|
||||
const headers: Record<string, string | number> = { ...(req.headers ?? {}) };
|
||||
if (req.body !== undefined) headers['Content-Length'] = Buffer.byteLength(req.body);
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
method: req.method,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers,
|
||||
...(fresh ? { agent: false } : {}),
|
||||
};
|
||||
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
|
||||
if (isHttps) {
|
||||
options.rejectUnauthorized = true;
|
||||
if (req.ca && req.ca.trim()) {
|
||||
// Extra CA on top of the system store, never instead of verification.
|
||||
options.ca = [...tls.rootCertificates, req.ca];
|
||||
// Runs only after the chain verified. The exact pasted certificate is
|
||||
// accepted under any name; anything else gets the normal name check.
|
||||
// (Pooled sockets are keyed by `ca`, so a reused one had the same pins.)
|
||||
options.checkServerIdentity = (host, cert) =>
|
||||
pins.has(cert.fingerprint256) ? undefined : tls.checkServerIdentity(host, cert);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<HttpResponse>((resolve, reject) => {
|
||||
let connected = false;
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const client = isHttps ? https : http;
|
||||
const request = client.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
request.destroy();
|
||||
finish(() => reject(new AccountingHttpError('پاسخ سرور حسابداری بیش از حد بزرگ است', true, 'TOO_LARGE')));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () =>
|
||||
finish(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })),
|
||||
);
|
||||
res.on('error', (err) => finish(() => reject(new AccountingHttpError(err.message, true, 'RESPONSE'))));
|
||||
});
|
||||
|
||||
request.on('socket', (socket) => {
|
||||
// A kept-alive socket is already connected; listening on it again would
|
||||
// pile up a listener per request.
|
||||
if (!(socket as { connecting?: boolean }).connecting) {
|
||||
connected = true;
|
||||
return;
|
||||
}
|
||||
socket.once(isHttps ? 'secureConnect' : 'connect', () => {
|
||||
connected = true;
|
||||
});
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
request.destroy();
|
||||
finish(() =>
|
||||
reject(
|
||||
new AccountingHttpError(
|
||||
connected ? 'پاسخ سرور حسابداری در زمان مقرر نرسید' : 'اتصال به سرور حسابداری برقرار نشد (timeout)',
|
||||
connected,
|
||||
'TIMEOUT',
|
||||
),
|
||||
),
|
||||
);
|
||||
}, req.timeoutMs);
|
||||
|
||||
request.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (!fresh && request.reusedSocket && err.code === 'ECONNRESET') {
|
||||
finish(() => resolve(send(req, true)));
|
||||
return;
|
||||
}
|
||||
// Node's own text for these suggests flags that do not apply here.
|
||||
const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message;
|
||||
finish(() => reject(new AccountingHttpError(text, connected, err.code)));
|
||||
});
|
||||
|
||||
if (req.body !== undefined) request.write(req.body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export interface ServerCertificate {
|
||||
pem: string;
|
||||
subject: string;
|
||||
issuer: string;
|
||||
subjectAltName: string | null;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
expired: boolean;
|
||||
fingerprint256: string;
|
||||
selfSigned: boolean;
|
||||
/** Valid chain to a system CA and issued for this host: no pin needed. */
|
||||
trustedBySystem: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the certificate an https server presents, WITHOUT trusting it, so the
|
||||
* admin can compare its fingerprint with the one on the accounting server and
|
||||
* pin it. Nothing but the TLS handshake is sent.
|
||||
*/
|
||||
export function fetchServerCertificate(baseUrl: string, timeoutMs: number): Promise<ServerCertificate> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
return Promise.reject(new AccountingHttpError('این آدرس https نیست؛ گواهی ندارد', false, 'NOT_HTTPS'));
|
||||
}
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
return new Promise<ServerCertificate>((resolve, reject) => {
|
||||
const socket = tls.connect({
|
||||
host,
|
||||
port: Number(url.port || 443),
|
||||
servername: net.isIP(host) ? undefined : host,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new AccountingHttpError('اتصال به سرور حسابداری برقرار نشد (timeout)', false, 'TIMEOUT'));
|
||||
}, timeoutMs);
|
||||
socket.once('secureConnect', () => {
|
||||
clearTimeout(timer);
|
||||
const peer = socket.getPeerCertificate();
|
||||
const chainOk = socket.authorized;
|
||||
socket.end();
|
||||
if (!peer?.raw) {
|
||||
reject(new AccountingHttpError('سرور گواهی ارسال نکرد', false, 'NO_CERT'));
|
||||
return;
|
||||
}
|
||||
const cert = new X509Certificate(peer.raw);
|
||||
const nameOk = net.isIP(host) ? !!cert.checkIP(host) : !!cert.checkHost(host);
|
||||
const b64 = peer.raw.toString('base64').match(/.{1,64}/g)!.join('\n');
|
||||
resolve({
|
||||
pem: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`,
|
||||
subject: cert.subject,
|
||||
issuer: cert.issuer,
|
||||
subjectAltName: cert.subjectAltName ?? null,
|
||||
validFrom: cert.validFrom,
|
||||
validTo: cert.validTo,
|
||||
expired: new Date(cert.validTo).getTime() < Date.now(),
|
||||
fingerprint256: cert.fingerprint256,
|
||||
// checkIssued() also wants keyCertSign, which Tahesab's certificate lacks.
|
||||
selfSigned: cert.subject === cert.issuer && cert.verify(cert.publicKey),
|
||||
trustedBySystem: chainOk && nameOk,
|
||||
});
|
||||
});
|
||||
socket.once('error', (err: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timer);
|
||||
reject(new AccountingHttpError(err.message, false, err.code));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON body that may carry junk around it. Tahesab replies have been
|
||||
* seen with a BOM, raw control characters and unescaped backslashes inside
|
||||
* strings (henza_server TahesabController::test had to scrub them the same
|
||||
* way before json_decode would accept the body).
|
||||
*/
|
||||
export function parseLooseJson(body: string): unknown {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
// fall through to the scrubbed attempt
|
||||
}
|
||||
let raw = body.replace(/^/, '');
|
||||
raw = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
raw = raw.replace(/\\(?!["\\/bfnrtu])/g, '-');
|
||||
const start = raw.search(/[{[]/);
|
||||
if (start > 0) raw = raw.slice(start);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
import { AccountingAdminService } from './accounting-admin.service';
|
||||
import { AccountingSyncService } from './accounting-sync.service';
|
||||
|
||||
/**
|
||||
* D27, plan §12 — sync where people work, on top of the «اتصال حسابداری»
|
||||
* screen: a button on the wholesale-customer list and on the product list,
|
||||
* and the customer's own balance read live each time the app's profile opens.
|
||||
* Each is behind the permission of the screen it sits on, and works whenever a
|
||||
* provider is connected, whatever the feature switches say (owner's ruling).
|
||||
*/
|
||||
@Controller()
|
||||
export class AccountingQuickController {
|
||||
constructor(
|
||||
private readonly admin: AccountingAdminService,
|
||||
private readonly sync: AccountingSyncService,
|
||||
) {}
|
||||
|
||||
/** Whether to show the sync buttons. Any signed-in admin; reveals no settings. */
|
||||
@Get('admin/accounting-status')
|
||||
status() {
|
||||
return this.admin.connectionStatus();
|
||||
}
|
||||
|
||||
@Post('admin/customers-omde/accounting-sync')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
syncCustomers() {
|
||||
return this.admin.syncNow(['customers', 'balances']);
|
||||
}
|
||||
|
||||
@Post('admin/products/accounting-sync')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
syncProducts() {
|
||||
return this.admin.syncNow(['products']);
|
||||
}
|
||||
|
||||
/** The app's profile: this customer's balance, read from the software now. */
|
||||
@CustomerAuth()
|
||||
@Get('customer/me/accounting-balance')
|
||||
myBalance(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.sync.customerBalanceLive(me.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AccountingAdminController } from './accounting-admin.controller';
|
||||
import { AccountingAdminService } from './accounting-admin.service';
|
||||
import { AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingHooksService } from './accounting-hooks.service';
|
||||
import { AccountingQuickController } from './accounting-quick.controller';
|
||||
import { AccountingSyncService } from './accounting-sync.service';
|
||||
import { AccountingWorkerService } from './accounting-worker.service';
|
||||
|
||||
/**
|
||||
* Accounting software integration (D27). Global so orders, customer admin,
|
||||
* product admin, the catalog and the report can call the hooks without each
|
||||
* re-importing it — the same reason SmsModule is global.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AccountingAdminController, AccountingQuickController],
|
||||
providers: [
|
||||
AccountingConfigService,
|
||||
AccountingHooksService,
|
||||
AccountingSyncService,
|
||||
AccountingWorkerService,
|
||||
AccountingAdminService,
|
||||
],
|
||||
exports: [AccountingConfigService, AccountingHooksService, AccountingSyncService],
|
||||
})
|
||||
export class AccountingSyncModule {}
|
||||
@@ -0,0 +1,950 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AccountingOutbox, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingConfig } from './accounting-config';
|
||||
import { JalaliDate, parseJalali, toJalali } from './jalali';
|
||||
import {
|
||||
AccountingProviderError,
|
||||
CustomerPayload,
|
||||
DocumentLine,
|
||||
ExternalCustomer,
|
||||
ExternalGroup,
|
||||
ExternalKarSakhte,
|
||||
ProviderName,
|
||||
StatementRow,
|
||||
normalizeMobile,
|
||||
splitName,
|
||||
} from './providers';
|
||||
|
||||
/** A job that must stop and wait for a human, not be retried. */
|
||||
export class NeedsReviewError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NeedsReviewError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CUSTOMER_PAGES = 1000;
|
||||
// Real-time (owner, 2026-10-05): the change poll keeps stored figures a few
|
||||
// seconds old; these windows only absorb a burst of reads of the same figure.
|
||||
const STATEMENT_CACHE_MS = 10_000;
|
||||
const BALANCE_FRESH_MS = 5_000;
|
||||
const BALANCE_ON_DEMAND_TIMEOUT_MS = 3_000;
|
||||
const STOCK_ON_DEMAND_TIMEOUT_MS = 2_500;
|
||||
|
||||
/** Lowercase alphanumeric, ≥ 20 chars: Tahesab lowercases Factor_Code and dedupes on it. */
|
||||
export function factorCodeFor(orderId: string) {
|
||||
return `gbord${orderId}`.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function groupMatches(selected: ExternalGroup[], c: { groupId: string | null; groupName: string | null }) {
|
||||
if (selected.length === 0) return true;
|
||||
return selected.some(
|
||||
(g) => (g.id !== '' && c.groupId !== null && g.id === c.groupId) || (!!g.name && !!c.groupName && g.name === c.groupName),
|
||||
);
|
||||
}
|
||||
|
||||
/** A real Iranian national code (10 digits, checksum), excluding the all-same placeholders. */
|
||||
export function validNationalCode(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
const d = v.replace(/\D/g, '');
|
||||
if (!/^\d{10}$/.test(d) || /^(\d)\1{9}$/.test(d)) return null;
|
||||
const check = Number(d[9]);
|
||||
const sum = d
|
||||
.slice(0, 9)
|
||||
.split('')
|
||||
.reduce((s, ch, i) => s + Number(ch) * (10 - i), 0);
|
||||
const r = sum % 11;
|
||||
return (r < 2 ? check === r : check === 11 - r) ? d : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the accounting integration does (D27, plan
|
||||
* docs/ACCOUNTING-INTEGRATION-PLAN.md §4): pulls on a schedule, outbox jobs,
|
||||
* and the reads the customer app makes. Each public pull takes the active
|
||||
* provider so the worker resolves configuration once per run.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountingSyncService {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private readonly statementCache = new Map<string, { at: number; rows: StatementRow[] }>();
|
||||
private itemCache: { provider: string; at: number; items: ExternalKarSakhte[] } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AccountingConfigService,
|
||||
) {}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F1 — customers
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Reads every account (in the selected groups) and links or imports it.
|
||||
* Two passes: all accounts first, so two accounts sharing one mobile are
|
||||
* caught before either is linked.
|
||||
*/
|
||||
async pullCustomers(a: ActiveProvider) {
|
||||
// No groups must not mean "all": cash boxes, staff and investors are
|
||||
// accounts too, and would become app customers.
|
||||
if (a.config.mapping.importCustomerGroups.length === 0) {
|
||||
throw new Error('هیچ گروه مشتری برای ورود انتخاب نشده است (تب «نگاشت»)');
|
||||
}
|
||||
const provider = a.provider.name;
|
||||
const all: ExternalCustomer[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < MAX_CUSTOMER_PAGES; page += 1) {
|
||||
const res = await a.provider.listCustomers(a.settings, cursor);
|
||||
all.push(...res.items);
|
||||
if (!res.next) break;
|
||||
cursor = res.next;
|
||||
}
|
||||
|
||||
const inScope = all.filter((c) => groupMatches(a.config.mapping.importCustomerGroups, c));
|
||||
const byMobile = new Map<string, ExternalCustomer[]>();
|
||||
for (const c of inScope) {
|
||||
if (!c.mobile) continue;
|
||||
byMobile.set(c.mobile, [...(byMobile.get(c.mobile) ?? []), c]);
|
||||
}
|
||||
|
||||
const existingLinks = await this.prisma.accountingLink.findMany({
|
||||
where: { provider, entityType: 'CUSTOMER' },
|
||||
select: { id: true, entityId: true, externalCode: true },
|
||||
});
|
||||
const linkByCode = new Map(existingLinks.map((l) => [l.externalCode, l]));
|
||||
const linkedCustomerIds = new Set(existingLinks.map((l) => l.entityId));
|
||||
|
||||
const summary = { scanned: all.length, inScope: inScope.length, refreshed: 0, linked: 0, imported: 0, issues: 0 };
|
||||
|
||||
for (const ext of inScope) {
|
||||
const snapshot = ext as unknown as Prisma.InputJsonValue;
|
||||
const link = linkByCode.get(ext.code);
|
||||
if (link) {
|
||||
await this.prisma.accountingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { externalName: ext.name, snapshot, syncedAt: new Date() },
|
||||
});
|
||||
summary.refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ext.mobile) {
|
||||
await this.issue(provider, 'CUSTOMER_NO_MOBILE', ext.code, `حساب «${ext.name}» (کد ${ext.code}) موبایل معتبر ندارد`, ext);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
const siblings = byMobile.get(ext.mobile) ?? [];
|
||||
if (siblings.length > 1) {
|
||||
await this.issue(
|
||||
provider,
|
||||
'CUSTOMER_DUPLICATE_MOBILE',
|
||||
ext.code,
|
||||
`موبایل ${ext.mobile} در ${siblings.length} حساب حسابداری تکرار شده (کدها: ${siblings.map((s) => s.code).join('، ')})`,
|
||||
siblings,
|
||||
);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const appCustomer = await this.prisma.customer.findUnique({ where: { mobile: ext.mobile }, select: { id: true } });
|
||||
if (appCustomer) {
|
||||
if (linkedCustomerIds.has(appCustomer.id)) {
|
||||
await this.issue(
|
||||
provider,
|
||||
'CUSTOMER_LINK_CONFLICT',
|
||||
ext.code,
|
||||
`مشتری با موبایل ${ext.mobile} قبلاً به حساب دیگری لینک شده؛ حساب ${ext.code} لینک نشد`,
|
||||
ext,
|
||||
appCustomer.id,
|
||||
);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'CUSTOMER', entityId: appCustomer.id, externalCode: ext.code, externalName: ext.name, origin: 'LINKED', snapshot },
|
||||
});
|
||||
linkedCustomerIds.add(appCustomer.id);
|
||||
summary.linked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.importCustomer(provider, ext);
|
||||
linkedCustomerIds.add(created);
|
||||
summary.imported += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* An account that exists only in the accounting software becomes an app
|
||||
* customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The
|
||||
* national code is the account's own when it is a real one and still free,
|
||||
* else none (the column is optional since 2026-10-07 and holds national
|
||||
* codes only — never the mobile or a made-up key).
|
||||
* Credit limit 0 and UNVERIFIED: everything beyond identity is the app's.
|
||||
* OTP-only: an imported account has no app password.
|
||||
*/
|
||||
private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise<string> {
|
||||
const mobile = ext.mobile as string;
|
||||
let nationalCode = validNationalCode(ext.nationalCode);
|
||||
if (nationalCode) {
|
||||
const taken = await this.prisma.customer.findUnique({ where: { nationalCode }, select: { id: true } });
|
||||
if (taken) nationalCode = null;
|
||||
}
|
||||
const { firstName, lastName } = splitName(ext.name || mobile);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const customer = await tx.customer.create({
|
||||
data: {
|
||||
// The DB trigger rebuilds fullName from the parts (schema.prisma).
|
||||
fullName: [firstName, lastName].filter(Boolean).join(' ') || mobile,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
nationalCode,
|
||||
passwordHash: OTP_ONLY_PASSWORD_HASH,
|
||||
phone: ext.phone,
|
||||
address: ext.address,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.accountingLink.create({
|
||||
data: {
|
||||
provider,
|
||||
entityType: 'CUSTOMER',
|
||||
entityId: customer.id,
|
||||
externalCode: ext.code,
|
||||
externalName: ext.name,
|
||||
origin: 'IMPORTED',
|
||||
snapshot: ext as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return customer.id;
|
||||
});
|
||||
}
|
||||
|
||||
private customerPayload(
|
||||
// nationalCode is nullable since 2026-10-07 (no legacy customer has one —
|
||||
// docs/DATA-MIGRATION-MAP.md §10.4). validNationalCode() already takes
|
||||
// `string | null` and maps empty/invalid to null, so the providers below
|
||||
// keep receiving exactly what they received before.
|
||||
c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string | null },
|
||||
group: ExternalGroup | null,
|
||||
): CustomerPayload {
|
||||
const parts = c.firstName || c.lastName ? { firstName: c.firstName ?? '', lastName: c.lastName ?? '' } : splitName(c.fullName);
|
||||
return {
|
||||
firstName: parts.firstName,
|
||||
lastName: parts.lastName,
|
||||
fullName: c.fullName || [parts.firstName, parts.lastName].filter(Boolean).join(' '),
|
||||
mobile: normalizeMobile(c.mobile) ?? c.mobile,
|
||||
phone: c.phone,
|
||||
address: c.address,
|
||||
nationalCode: validNationalCode(c.nationalCode),
|
||||
group: { id: group?.id || null, name: group?.name || null },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the customer has an account in the accounting software and
|
||||
* returns its code: existing link → update it (when `update`); else search
|
||||
* by mobile → link; else create → link. Search-before-create is what makes a
|
||||
* retried push safe on both providers.
|
||||
*/
|
||||
async ensureCustomerAccount(a: ActiveProvider, customerId: string, opts: { update: boolean }) {
|
||||
const provider = a.provider.name;
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NeedsReviewError('مشتری در اپ پیدا نشد');
|
||||
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
});
|
||||
|
||||
if (link) {
|
||||
if (opts.update) {
|
||||
// Keep the account in its current group: Zarrin moves it otherwise.
|
||||
const snap = (link.snapshot ?? {}) as Partial<ExternalCustomer>;
|
||||
const group = snap.groupName || snap.groupId ? { id: snap.groupId ?? '', name: snap.groupName ?? '' } : a.config.mapping.newCustomerGroup;
|
||||
const payload = this.customerPayload(customer, group);
|
||||
const res = await a.provider.updateCustomer(a.settings, link.externalCode, payload);
|
||||
await this.prisma.accountingLink.update({ where: { id: link.id }, data: { syncedAt: new Date() } });
|
||||
return { code: link.externalCode, request: { action: 'update', code: link.externalCode, payload }, response: res.response };
|
||||
}
|
||||
return { code: link.externalCode, request: { action: 'none' }, response: null };
|
||||
}
|
||||
|
||||
const mobile = normalizeMobile(customer.mobile);
|
||||
if (!mobile) throw new NeedsReviewError(`موبایل مشتری (${customer.mobile}) معتبر نیست`);
|
||||
|
||||
const matches = await a.provider.findCustomersByMobile(a.settings, mobile);
|
||||
const free = [];
|
||||
for (const m of matches) {
|
||||
const taken = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_externalCode: { provider, entityType: 'CUSTOMER', externalCode: m.code } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!taken) free.push(m);
|
||||
}
|
||||
if (free.length > 1) {
|
||||
throw new NeedsReviewError(
|
||||
`موبایل ${mobile} در چند حساب حسابداری هست (کدها: ${free.map((m) => m.code).join('، ')}). از «لینک دستی» یکی را انتخاب کنید.`,
|
||||
);
|
||||
}
|
||||
if (free.length === 1) {
|
||||
const m = free[0];
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'CUSTOMER', entityId: customerId, externalCode: m.code, externalName: m.name, origin: 'LINKED', snapshot: m as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
return { code: m.code, request: { action: 'link', mobile }, response: m };
|
||||
}
|
||||
|
||||
if (!a.config.mapping.newCustomerGroup) {
|
||||
throw new NeedsReviewError('گروه مشتری جدید در تب «نگاشت» تعیین نشده؛ بعد از تعیین، «تلاش دوباره» را بزنید');
|
||||
}
|
||||
const payload = this.customerPayload(customer, a.config.mapping.newCustomerGroup);
|
||||
const res = await a.provider.createCustomer(a.settings, payload);
|
||||
await this.prisma.accountingLink.create({
|
||||
data: {
|
||||
provider,
|
||||
entityType: 'CUSTOMER',
|
||||
entityId: customerId,
|
||||
externalCode: res.code,
|
||||
externalName: payload.fullName,
|
||||
origin: 'PUSHED',
|
||||
snapshot: { ...payload, groupId: payload.group.id, groupName: payload.group.name } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return { code: res.code, request: { action: 'create', payload }, response: res.response };
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F2 — balances
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
async pullBalances(a: ActiveProvider) {
|
||||
const links = await this.prisma.accountingLink.findMany({
|
||||
where: { provider: a.provider.name, entityType: 'CUSTOMER' },
|
||||
select: { entityId: true, externalCode: true },
|
||||
});
|
||||
if (links.length === 0) return { linked: 0, updated: 0 };
|
||||
const balances = await a.provider.getBalances(
|
||||
a.settings,
|
||||
links.map((l) => l.externalCode),
|
||||
);
|
||||
const byCode = new Map(balances.map((b) => [b.code, b]));
|
||||
const now = new Date();
|
||||
let updated = 0;
|
||||
for (const l of links) {
|
||||
const b = byCode.get(l.externalCode);
|
||||
if (!b) continue;
|
||||
await this.prisma.customer.update({
|
||||
where: { id: l.entityId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
return { linked: links.length, updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* The balance the customer sees (D27): the accounting software's, refreshed
|
||||
* on demand when older than a minute, with a short timeout so the app never
|
||||
* waits on a slow installation — on any failure the last stored figure is
|
||||
* served. Null when the integration does not apply to this customer.
|
||||
*/
|
||||
async customerBalance(customerId: string) {
|
||||
const a = await this.config.activeFor('balances');
|
||||
if (!a) return null;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
});
|
||||
if (!link) return null;
|
||||
|
||||
let c = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS;
|
||||
if (stale) {
|
||||
try {
|
||||
const [b] = await a.provider.getBalances(
|
||||
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
||||
[link.externalCode],
|
||||
);
|
||||
if (b) {
|
||||
c = await this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() },
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!c?.accountingBalanceAt) return null;
|
||||
return {
|
||||
gold: c.accountingGoldBalance ?? new Prisma.Decimal(0),
|
||||
rial: c.accountingRialBalance ?? new Prisma.Decimal(0),
|
||||
at: c.accountingBalanceAt,
|
||||
provider: a.provider.label,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Real-time: change poll + live reads
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* One poll of the software's change feed. Only the accounts and کارساخته
|
||||
* that changed are re-read, so this is cheap enough to run every few
|
||||
* seconds. Returns the new cursor and what the slow jobs should still do.
|
||||
*/
|
||||
async pollChanges(a: ActiveProvider, cursor: string | null) {
|
||||
const batch = await a.provider.getChanges(a.settings, cursor);
|
||||
const out = { cursor: batch.cursor, balances: 0, stocks: 0, fullBalances: false, fullProducts: false, newAccounts: false };
|
||||
if (cursor === null) return out;
|
||||
const provider = a.provider.name;
|
||||
|
||||
if (a.config.features.balances) {
|
||||
if (batch.overflow) out.fullBalances = true;
|
||||
else if (batch.accountCodes.length > 0) {
|
||||
const links = await this.prisma.accountingLink.findMany({
|
||||
where: { provider, entityType: 'CUSTOMER', externalCode: { in: batch.accountCodes } },
|
||||
select: { entityId: true, externalCode: true },
|
||||
});
|
||||
out.newAccounts = links.length < batch.accountCodes.length;
|
||||
if (links.length > 0) out.balances = await this.storeBalances(a, links);
|
||||
}
|
||||
}
|
||||
|
||||
if (a.config.features.products) {
|
||||
if (batch.overflow) out.fullProducts = true;
|
||||
else if (batch.itemNames.length > 0) {
|
||||
const links = await this.prisma.accountingLink.findMany({
|
||||
where: { provider, entityType: 'PRODUCT', externalCode: { in: batch.itemNames } },
|
||||
select: { entityId: true, externalCode: true },
|
||||
});
|
||||
// A name with no product yet is a new کارساخته: the product pull imports it.
|
||||
if (links.length < batch.itemNames.length) out.fullProducts = true;
|
||||
for (const l of links) {
|
||||
if (await this.storeStock(a, l.entityId, l.externalCode, l.externalCode)) out.stocks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads the balances of these linked customers and stores them. */
|
||||
private async storeBalances(a: ActiveProvider, links: { entityId: string; externalCode: string }[]) {
|
||||
const balances = await a.provider.getBalances(a.settings, links.map((l) => l.externalCode));
|
||||
const byCode = new Map(balances.map((b) => [b.code, b]));
|
||||
const now = new Date();
|
||||
let n = 0;
|
||||
for (const l of links) {
|
||||
const b = byCode.get(l.externalCode);
|
||||
if (!b) continue;
|
||||
await this.prisma.customer.update({
|
||||
where: { id: l.entityId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
|
||||
});
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private async storeStock(a: ActiveProvider, productId: string, code: string, name: string, timeoutMs?: number) {
|
||||
const settings = timeoutMs ? { ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, timeoutMs) } : a.settings;
|
||||
const stock = await a.provider.getKarSakhteStock(settings, { code, name });
|
||||
const known = stock.weight !== null || stock.count !== null;
|
||||
await this.prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
accountingStockWeight: stock.weight,
|
||||
accountingStockCount: stock.count,
|
||||
accountingStockAt: known ? new Date() : null,
|
||||
},
|
||||
});
|
||||
return known;
|
||||
}
|
||||
|
||||
/**
|
||||
* At order time: the product's stock straight from the accounting software.
|
||||
* If it cannot answer in time the order goes ahead on the last stored figure
|
||||
* (owner's ruling) and the next change poll corrects it. Returns the product
|
||||
* fields stockBlocked() reads, fresh when it could be.
|
||||
*/
|
||||
async liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(
|
||||
product: T,
|
||||
): Promise<T> {
|
||||
const a = await this.config.activeFor('products');
|
||||
if (!a) return product;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'PRODUCT', entityId: product.id } },
|
||||
select: { externalCode: true, externalName: true },
|
||||
});
|
||||
if (!link) return product;
|
||||
try {
|
||||
await this.storeStock(a, product.id, link.externalCode, link.externalName ?? link.externalCode, STOCK_ON_DEMAND_TIMEOUT_MS);
|
||||
const fresh = await this.prisma.product.findUnique({
|
||||
where: { id: product.id },
|
||||
select: { accountingStockAt: true, accountingStockWeight: true, accountingStockCount: true },
|
||||
});
|
||||
return fresh ? { ...product, ...fresh } : product;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Live stock of product ${product.id} unavailable, using the stored figure: ${(err as Error).message}`);
|
||||
return product;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's profile (plan §12): this one customer's balance, read from the
|
||||
* accounting software on every call — no freshness window, no feature
|
||||
* switch, only a connected provider. On no answer the stored figure is
|
||||
* served and labelled as such.
|
||||
*/
|
||||
async customerBalanceLive(customerId: string) {
|
||||
const none = { source: 'none' as const, gold: null, rial: null, at: null, provider: null };
|
||||
const a = await this.config.active();
|
||||
if (!a) return none;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
select: { externalCode: true },
|
||||
});
|
||||
if (!link) return none;
|
||||
try {
|
||||
const [b] = await a.provider.getBalances(
|
||||
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
||||
[link.externalCode],
|
||||
);
|
||||
if (b) {
|
||||
const at = new Date();
|
||||
await this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: at },
|
||||
});
|
||||
return { source: 'accounting' as const, gold: b.goldGrams.toString(), rial: b.rial.toString(), at, provider: a.provider.label };
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Live balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
|
||||
}
|
||||
const c = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
if (!c?.accountingBalanceAt) return none;
|
||||
return {
|
||||
source: 'stored' as const,
|
||||
gold: (c.accountingGoldBalance ?? 0).toString(),
|
||||
rial: (c.accountingRialBalance ?? 0).toString(),
|
||||
at: c.accountingBalanceAt,
|
||||
provider: a.provider.label,
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F6 — statement («گزارش»)
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The customer's statement from the accounting software, oldest first, or
|
||||
* null when the integration does not apply (the report then falls back to
|
||||
* the internal ledger). Cached for a minute per customer so paging through
|
||||
* the report costs one call.
|
||||
*/
|
||||
async customerStatement(customerId: string, range?: { from?: string; to?: string }) {
|
||||
const a = await this.config.activeFor('report');
|
||||
if (!a) return null;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
});
|
||||
if (!link) return null;
|
||||
|
||||
const to: JalaliDate = parseJalali(range?.to) ?? toJalali(new Date());
|
||||
const from: JalaliDate =
|
||||
parseJalali(range?.from) ?? toJalali(new Date(Date.now() - a.config.report.windowDays * 24 * 3600 * 1000));
|
||||
const key = `${a.provider.name}:${link.externalCode}:${from.jy}-${from.jm}-${from.jd}:${to.jy}-${to.jm}-${to.jd}`;
|
||||
const hit = this.statementCache.get(key);
|
||||
if (hit && Date.now() - hit.at < STATEMENT_CACHE_MS) return { rows: hit.rows, code: link.externalCode, provider: a.provider.label };
|
||||
|
||||
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from, to });
|
||||
// SEC-16: never hand back a line that belongs to another account, whatever
|
||||
// the software returned.
|
||||
const own = rows.filter((r) => !r.accountCode || r.accountCode === link.externalCode);
|
||||
this.statementCache.set(key, { at: Date.now(), rows: own });
|
||||
if (this.statementCache.size > 500) this.statementCache.delete(this.statementCache.keys().next().value as string);
|
||||
return { rows: own, code: link.externalCode, provider: a.provider.label };
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F4 — کارساخته ↔ products
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Accounting items → app products. A new item becomes a product with no
|
||||
* image and no variant, so it stays out of the app (F5) until an admin
|
||||
* photographs it and adds its weights. Title, code and stock follow the
|
||||
* accounting software; everything else is the app's (D27).
|
||||
*/
|
||||
async pullProducts(a: ActiveProvider) {
|
||||
const provider = a.provider.name;
|
||||
const items = await a.provider.listKarSakhte(a.settings);
|
||||
const links = await this.prisma.accountingLink.findMany({ where: { provider, entityType: 'PRODUCT' } });
|
||||
const linkByCode = new Map(links.map((l) => [l.externalCode, l]));
|
||||
const linkedProductIds = new Set(links.map((l) => l.entityId));
|
||||
const summary = { items: items.length, updated: 0, linked: 0, imported: 0, stockErrors: 0, issues: 0 };
|
||||
const now = new Date();
|
||||
const touched = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
let stock = { weight: item.stockWeight, count: item.stockCount };
|
||||
if (stock.weight === null && stock.count === null) {
|
||||
try {
|
||||
stock = await a.provider.getKarSakhteStock(a.settings, item);
|
||||
} catch (err) {
|
||||
summary.stockErrors += 1;
|
||||
this.logger.warn(`Stock of «${item.name}» unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
const stockData = {
|
||||
accountingStockWeight: stock.weight,
|
||||
accountingStockCount: stock.count,
|
||||
accountingStockAt: stock.weight !== null || stock.count !== null ? now : null,
|
||||
};
|
||||
const snapshot = item as unknown as Prisma.InputJsonValue;
|
||||
|
||||
const link = linkByCode.get(item.code);
|
||||
if (link) {
|
||||
await this.applyItemToProduct(provider, link.entityId, item, stockData);
|
||||
await this.prisma.accountingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { externalName: item.name, snapshot, syncedAt: now },
|
||||
});
|
||||
touched.add(link.entityId);
|
||||
summary.updated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A product an admin created by the same name (in Tahesab an item only
|
||||
// appears after its first document, which is how it gets linked).
|
||||
const sameName = await this.prisma.product.findMany({ where: { title: item.name }, select: { id: true } });
|
||||
const candidates = sameName.filter((p) => !linkedProductIds.has(p.id));
|
||||
if (candidates.length > 1) {
|
||||
await this.issue(provider, 'PRODUCT_DUPLICATE_TITLE', item.code, `چند محصول با عنوان «${item.name}» هست؛ کارساخته لینک نشد`, item);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
const productId = candidates[0].id;
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot },
|
||||
});
|
||||
await this.applyItemToProduct(provider, productId, item, stockData);
|
||||
linkedProductIds.add(productId);
|
||||
touched.add(productId);
|
||||
summary.linked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const productId = await this.prisma.$transaction(async (tx) => {
|
||||
const product = await tx.product.create({
|
||||
data: { title: item.name, ...stockData },
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: product.id, externalCode: item.code, externalName: item.name, origin: 'IMPORTED', snapshot },
|
||||
});
|
||||
return product.id;
|
||||
});
|
||||
await this.setProductCode(provider, productId, item);
|
||||
linkedProductIds.add(productId);
|
||||
touched.add(productId);
|
||||
summary.imported += 1;
|
||||
}
|
||||
|
||||
// Stock figures only mean something for products linked under the active
|
||||
// provider; anything else (unlinked, or from a provider switched away
|
||||
// from) must not keep blocking orders.
|
||||
await this.prisma.product.updateMany({
|
||||
where: { accountingStockAt: { not: null }, id: { notIn: [...touched] } },
|
||||
data: { accountingStockWeight: null, accountingStockCount: null, accountingStockAt: null },
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async applyItemToProduct(
|
||||
provider: ProviderName,
|
||||
productId: string,
|
||||
item: ExternalKarSakhte,
|
||||
stockData: Prisma.ProductUpdateInput,
|
||||
) {
|
||||
await this.prisma.product.update({ where: { id: productId }, data: { title: item.name, ...stockData } });
|
||||
await this.setProductCode(provider, productId, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zarrin items have a numeric id, which becomes the product's code. Tahesab
|
||||
* items have none (the name is the key), so the code stays the app's.
|
||||
*/
|
||||
private async setProductCode(provider: ProviderName, productId: string, item: ExternalKarSakhte) {
|
||||
if (provider !== 'zarrin') return;
|
||||
const owner = await this.prisma.product.findUnique({ where: { productCode: item.code }, select: { id: true } });
|
||||
if (owner && owner.id !== productId) {
|
||||
await this.issue(provider, 'PRODUCT_CODE_TAKEN', item.code, `کد ${item.code} روی محصول دیگری است؛ کد محصول «${item.name}» عوض نشد`, item, productId);
|
||||
return;
|
||||
}
|
||||
if (!owner) await this.prisma.product.update({ where: { id: productId }, data: { productCode: item.code } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a product has its کارساخته: existing link → done; an item by
|
||||
* the same name → link; else create it (Zarrin). Tahesab cannot create items
|
||||
* through its API — the item comes into being with the first document under
|
||||
* the product's title, and the next pull links it by name.
|
||||
*/
|
||||
async ensureProductItem(a: ActiveProvider, productId: string): Promise<{ code: string; name: string; created: boolean } | null> {
|
||||
const provider = a.provider.name;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider, entityType: 'PRODUCT', entityId: productId } },
|
||||
});
|
||||
const product = await this.prisma.product.findUnique({ where: { id: productId }, select: { id: true, title: true, categoryId: true } });
|
||||
if (!product) throw new NeedsReviewError('محصول در اپ پیدا نشد');
|
||||
if (link) return { code: link.externalCode, name: link.externalName ?? product.title, created: false };
|
||||
|
||||
if (!a.provider.capabilities.createKarSakhte) return null;
|
||||
|
||||
const existing = (await this.itemsCached(a)).filter((i) => i.name === product.title);
|
||||
for (const item of existing) {
|
||||
const taken = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_externalCode: { provider, entityType: 'PRODUCT', externalCode: item.code } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!taken) {
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot: item as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
await this.setProductCode(provider, productId, item);
|
||||
return { code: item.code, name: item.name, created: false };
|
||||
}
|
||||
}
|
||||
|
||||
const m = a.config.mapping;
|
||||
const group = (product.categoryId && m.categoryGroups.find((x) => x.categoryId === product.categoryId)?.group) || m.newKarSakhteGroup;
|
||||
if (!group?.id) throw new NeedsReviewError('گروه ساخته برای این دستهبندی/پیشفرض در تنظیمات حسابداری تعیین نشده');
|
||||
if (!m.newKarSakhteType?.id) throw new NeedsReviewError('نوع کارساختهی پیشفرض در تنظیمات حسابداری تعیین نشده');
|
||||
|
||||
const res = await a.provider.createKarSakhte(a.settings, { name: product.title, groupId: group.id, typeId: m.newKarSakhteType.id });
|
||||
const item: ExternalKarSakhte = {
|
||||
code: res.code,
|
||||
name: product.title,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
typeId: m.newKarSakhteType.id,
|
||||
typeName: m.newKarSakhteType.name,
|
||||
saleWagePercent: null,
|
||||
stockWeight: null,
|
||||
stockCount: null,
|
||||
};
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: res.code, externalName: product.title, origin: 'PUSHED', snapshot: item as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
await this.setProductCode(provider, productId, item);
|
||||
return { code: res.code, name: product.title, created: true };
|
||||
}
|
||||
|
||||
/** Zarrin's listKarSakhte takes seconds; an order with several lines needs it once. */
|
||||
private async itemsCached(a: ActiveProvider) {
|
||||
const c = this.itemCache;
|
||||
if (c && c.provider === a.provider.name && Date.now() - c.at < STATEMENT_CACHE_MS) return c.items;
|
||||
const items = await a.provider.listKarSakhte(a.settings);
|
||||
this.itemCache = { provider: a.provider.name, at: Date.now(), items };
|
||||
return items;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F3 — order documents
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
async buildOrderDocument(a: ActiveProvider, orderId: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { items: { include: { productVariant: { include: { product: true } } } } },
|
||||
});
|
||||
if (!order) throw new NeedsReviewError('سفارش در اپ پیدا نشد');
|
||||
if (order.items.length === 0) throw new NeedsReviewError('سفارش قلمی ندارد');
|
||||
|
||||
const customer = await this.ensureCustomerAccount(a, order.customerId, { update: false });
|
||||
|
||||
const lines: DocumentLine[] = [];
|
||||
let total = new Prisma.Decimal(0);
|
||||
for (const item of order.items) {
|
||||
const product = item.productVariant.product;
|
||||
let ref: { code: string; name: string };
|
||||
const ensured = await this.ensureProductItem(a, product.id);
|
||||
if (ensured) ref = ensured;
|
||||
else {
|
||||
// Tahesab: the item is identified by name and created by this document.
|
||||
ref = { code: product.title, name: product.title };
|
||||
}
|
||||
const qty = item.quantity;
|
||||
const wage = new Prisma.Decimal(item.wagePercentSnapshot).sub(item.discountPercentSnapshot);
|
||||
const weight = new Prisma.Decimal(item.rashWeightGramsSnapshot).mul(qty);
|
||||
total = total.add(new Prisma.Decimal(item.computedWeightGrams).mul(qty));
|
||||
lines.push({
|
||||
itemCode: ref.code,
|
||||
itemName: ref.name,
|
||||
weightGrams: weight,
|
||||
count: qty,
|
||||
wagePercent: wage,
|
||||
flatRial: new Prisma.Decimal(item.flatRialCharge).mul(qty),
|
||||
karat: a.config.mapping.karat,
|
||||
description: `${product.title} — ${item.productVariant.title} — سفارش ${order.orderNumber} — اجرت ${wage.toString()}٪`,
|
||||
});
|
||||
}
|
||||
|
||||
// Acceptance rule of F3: the gold the document moves must equal the
|
||||
// order's own total, or the two books disagree from the first day.
|
||||
const docGold = lines.reduce(
|
||||
(s, l) => s.add(l.weightGrams.mul(new Prisma.Decimal(1).add(l.wagePercent.div(100)))),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
const mismatch = docGold.sub(order.totalGoldWeightGrams).abs();
|
||||
if (mismatch.gt('0.01')) {
|
||||
throw new NeedsReviewError(
|
||||
`جمع وزن سند (${docGold.toFixed(3)}) با وزن سفارش (${new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3)}) نمیخواند`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
input: {
|
||||
factorCode: factorCodeFor(order.id),
|
||||
customerCode: customer.code,
|
||||
date: order.deliveredAt ?? new Date(),
|
||||
description: `سفارش ${order.orderNumber} از اپ`,
|
||||
temporary: true, // D27: the accountant finalises
|
||||
lines,
|
||||
},
|
||||
check: { orderTotal: new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3), documentGold: docGold.toFixed(3), linesTotal: total.toFixed(3) },
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// outbox jobs
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Runs one outbox job. Throws to signal failure; the worker decides retry vs review. */
|
||||
async runJob(a: ActiveProvider, job: AccountingOutbox): Promise<{ externalRef?: string | null; request?: unknown; response?: unknown }> {
|
||||
switch (job.kind) {
|
||||
case 'CUSTOMER_PUSH': {
|
||||
const res = await this.ensureCustomerAccount(a, job.entityId, { update: true });
|
||||
return { externalRef: res.code, request: res.request, response: res.response };
|
||||
}
|
||||
|
||||
case 'PRODUCT_PUSH': {
|
||||
const res = await this.ensureProductItem(a, job.entityId);
|
||||
return {
|
||||
externalRef: res?.code ?? null,
|
||||
request: { productId: job.entityId },
|
||||
response: res ?? { note: `${a.provider.label} ساخت کارساخته ندارد؛ با اولین سند ساخته میشود` },
|
||||
};
|
||||
}
|
||||
|
||||
case 'ORDER_DOCUMENT': {
|
||||
const doc = await this.buildOrderDocument(a, job.entityId);
|
||||
const request = { ...doc.input, lines: doc.input.lines.map((l) => ({ ...l, weightGrams: l.weightGrams.toFixed(3), wagePercent: l.wagePercent.toString(), flatRial: l.flatRial.toFixed(0) })), check: doc.check };
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { lastRequest: request as unknown as Prisma.InputJsonValue } });
|
||||
const result = await a.provider.postOrderDocument(
|
||||
a.settings,
|
||||
doc.input,
|
||||
(job.progress ?? {}) as Record<string, unknown>,
|
||||
async (p) => {
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { progress: p as unknown as Prisma.InputJsonValue } });
|
||||
},
|
||||
);
|
||||
return { externalRef: result.ref, request, response: result.response };
|
||||
}
|
||||
|
||||
case 'ORDER_DOCUMENT_DELETE': {
|
||||
const sent = await this.prisma.accountingOutbox.findFirst({
|
||||
where: { provider: job.provider, kind: 'ORDER_DOCUMENT', entityId: job.entityId, status: { in: ['SENT', 'SENDING', 'NEEDS_REVIEW', 'FAILED'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!sent) return { response: { note: 'سندی برای حذف ثبت نشده بود' } };
|
||||
// The order row may be gone (deleted from the admin panel); the sent
|
||||
// document's own request says which account it went to.
|
||||
const order = await this.prisma.order.findUnique({ where: { id: job.entityId }, select: { customerId: true, deliveredAt: true } });
|
||||
let customerCode = (sent.lastRequest as { customerCode?: string } | null)?.customerCode ?? null;
|
||||
if (!customerCode && order) {
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: job.provider, entityType: 'CUSTOMER', entityId: order.customerId } },
|
||||
});
|
||||
customerCode = link?.externalCode ?? null;
|
||||
}
|
||||
if (!customerCode) throw new NeedsReviewError('حساب مشتری در حسابداری معلوم نیست؛ سند را دستی حذف کنید');
|
||||
const link = { externalCode: customerCode };
|
||||
const factorCode = factorCodeFor(job.entityId);
|
||||
|
||||
// Only a document still temporary may be deleted automatically (plan
|
||||
// F3). Tahesab reports that per row; Zarrin cannot, so a person checks.
|
||||
if (job.provider === 'tahesab') {
|
||||
// The document is dated when it was built (or at delivery, if earlier).
|
||||
const posted = order?.deliveredAt && order.deliveredAt < sent.createdAt ? order.deliveredAt : sent.createdAt;
|
||||
const date = toJalali(posted);
|
||||
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from: date, to: toJalali(new Date()) });
|
||||
const docRows = rows.filter((r) => r.documentRef === factorCode);
|
||||
if (docRows.some((r) => !r.temporary)) {
|
||||
throw new NeedsReviewError('سند در حسابداری نهایی شده؛ حذف خودکار انجام نشد. حسابدار باید دستی اصلاح کند.');
|
||||
}
|
||||
} else {
|
||||
throw new NeedsReviewError(
|
||||
`${a.provider.label} وضعیت موقت/نهایی سند را گزارش نمیدهد؛ سند ${sent.externalRef ?? ''} را حسابدار بررسی و حذف کند.`,
|
||||
);
|
||||
}
|
||||
const res = await a.provider.deleteDocument(a.settings, { factorCode, ref: sent.externalRef, customerCode: link.externalCode });
|
||||
await this.prisma.accountingOutbox.update({
|
||||
where: { id: sent.id },
|
||||
data: { status: 'RESOLVED', lastError: 'سند بهخاطر لغو سفارش حذف شد' },
|
||||
});
|
||||
return { externalRef: sent.externalRef, request: { factorCode }, response: res.response };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a job whose call may or may not have landed safe to send again?
|
||||
* Customer/product pushes search before they create; Tahesab rejects a
|
||||
* repeated document row; a Zarrin document has no such guard.
|
||||
*/
|
||||
isRetrySafe(provider: ProviderName, kind: AccountingOutbox['kind']) {
|
||||
if (kind === 'ORDER_DOCUMENT') return provider === 'tahesab';
|
||||
return true;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
async issue(provider: ProviderName, kind: string, externalCode: string, message: string, data?: unknown, entityId?: string) {
|
||||
const json =
|
||||
data === undefined || data === null ? Prisma.JsonNull : (JSON.parse(JSON.stringify(data)) as Prisma.InputJsonValue);
|
||||
await this.prisma.accountingIssue.upsert({
|
||||
where: { provider_kind_externalCode: { provider, kind, externalCode } },
|
||||
update: { message, data: json, entityId: entityId ?? null, resolvedAt: null },
|
||||
create: { provider, kind, externalCode, message, data: json, entityId: entityId ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
describeError(err: unknown) {
|
||||
if (err instanceof AccountingProviderError) return err.message;
|
||||
if (err instanceof NeedsReviewError) return err.message;
|
||||
return (err as Error)?.message ?? String(err);
|
||||
}
|
||||
|
||||
/** For callers that only need to know the configuration. */
|
||||
activeConfig(): Promise<AccountingConfig> {
|
||||
return this.config.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { formatJalali, jalaliToIsoDate, parseJalali, toIsoDateTehran, toJalali } from './jalali';
|
||||
import { openSecret, sealSecret } from './secret-box';
|
||||
import { dec, normalizeMobile, splitName } from './providers/accounting-provider';
|
||||
import { factorCodeFor, validNationalCode } from './accounting-sync.service';
|
||||
import { parseLooseJson } from './accounting-http';
|
||||
import { isAccountingSettingKey, withDefaults } from './accounting-config';
|
||||
|
||||
describe('jalali', () => {
|
||||
it('converts a Tehran calendar day', () => {
|
||||
// The Tahesab test server reported 1405/07/13 on 2026-10-05.
|
||||
expect(formatJalali(toJalali(new Date('2026-10-05T08:00:00Z')), '/')).toBe('1405/07/13');
|
||||
// Nowruz 1404: 2025-03-21 00:30 in Tehran is still 20 March in UTC.
|
||||
expect(formatJalali(toJalali(new Date('2025-03-20T21:00:00Z')))).toBe('1404-01-01');
|
||||
});
|
||||
|
||||
it('goes back to Gregorian', () => {
|
||||
expect(jalaliToIsoDate({ jy: 1405, jm: 7, jd: 13 })).toBe('2026-10-05');
|
||||
expect(jalaliToIsoDate({ jy: 1403, jm: 12, jd: 30 })).toBe('2025-03-20');
|
||||
});
|
||||
|
||||
it('parses the forms the apps and Tahesab use, and rejects impossible days', () => {
|
||||
expect(parseJalali('1405/7/13')).toEqual({ jy: 1405, jm: 7, jd: 13 });
|
||||
expect(parseJalali('۱۴۰۵-۰۷-۱۳')).toEqual({ jy: 1405, jm: 7, jd: 13 });
|
||||
expect(parseJalali('1403/12/30')).not.toBeNull(); // leap year
|
||||
expect(parseJalali('1404/12/30')).toBeNull(); // common year
|
||||
expect(parseJalali('1405/07/31')).toBeNull();
|
||||
expect(parseJalali('2026-10-05x')).toBeNull();
|
||||
});
|
||||
|
||||
it('formats a Tehran ISO day', () => {
|
||||
expect(toIsoDateTehran(new Date('2026-10-04T21:00:00Z'))).toBe('2026-10-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret-box', () => {
|
||||
const saved = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
it('round-trips and never stores the plain token', () => {
|
||||
process.env.ACCOUNTING_SECRET_KEY = 'k1';
|
||||
const sealed = sealSecret('M3Y3D2Y7');
|
||||
expect(sealed.startsWith('enc:v1:')).toBe(true);
|
||||
expect(sealed).not.toContain('M3Y3D2Y7');
|
||||
expect(openSecret(sealed)).toBe('M3Y3D2Y7');
|
||||
});
|
||||
|
||||
it('cannot be opened with another key', () => {
|
||||
process.env.ACCOUNTING_SECRET_KEY = 'k1';
|
||||
const sealed = sealSecret('secret');
|
||||
process.env.ACCOUNTING_SECRET_KEY = 'k2';
|
||||
expect(openSecret(sealed)).toBeNull();
|
||||
expect(openSecret('plain-text')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to JWT_SECRET when the dedicated key is blank', () => {
|
||||
process.env.ACCOUNTING_SECRET_KEY = ' ';
|
||||
process.env.JWT_SECRET = 'jwt';
|
||||
expect(openSecret(sealSecret('x'))).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('normalises every mobile form the two programs store', () => {
|
||||
expect(normalizeMobile('9121234567')).toBe('09121234567');
|
||||
expect(normalizeMobile('09121234567')).toBe('09121234567');
|
||||
expect(normalizeMobile('+98 912 123 4567')).toBe('09121234567');
|
||||
expect(normalizeMobile('00989121234567')).toBe('09121234567');
|
||||
expect(normalizeMobile('۰۹۱۲۱۲۳۴۵۶۷')).toBe('09121234567');
|
||||
expect(normalizeMobile('02155662233')).toBeNull();
|
||||
expect(normalizeMobile('')).toBeNull();
|
||||
expect(normalizeMobile(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the numbers Tahesab sends as strings, and its literal "null"', () => {
|
||||
expect(dec('-177.431')?.toString()).toBe('-177.431');
|
||||
expect(dec('1,234')?.toString()).toBe('1234');
|
||||
expect(dec('null')).toBeNull();
|
||||
expect(dec(null)).toBeNull();
|
||||
expect(dec('abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts only real national codes', () => {
|
||||
expect(validNationalCode('0499370899')).toBe('0499370899');
|
||||
expect(validNationalCode('0000000000')).toBeNull(); // seen on the Tahesab test server
|
||||
expect(validNationalCode('1234567890')).toBeNull();
|
||||
expect(validNationalCode(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a Tahesab-safe Factor_Code from an order id', () => {
|
||||
const code = factorCodeFor('cm1abcdefghijklmnopqrstuv');
|
||||
expect(code).toBe(code.toLowerCase());
|
||||
expect(code).toMatch(/^[a-z0-9]+$/);
|
||||
expect(code.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('splits a name the way the DB trigger does', () => {
|
||||
expect(splitName('علی رضا محمدی')).toEqual({ firstName: 'علی', lastName: 'رضا محمدی' });
|
||||
expect(splitName('masoud')).toEqual({ firstName: 'masoud', lastName: '' });
|
||||
});
|
||||
|
||||
it('scrubs the junk Tahesab replies have carried', () => {
|
||||
expect(parseLooseJson('{"a":"x\\y"}')).toEqual({ a: 'x-y' });
|
||||
expect(parseLooseJson('garbage [1,2]')).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('fences off every accounting Setting key from the const routes', () => {
|
||||
expect(isAccountingSettingKey('accounting.config')).toBe(true);
|
||||
expect(isAccountingSettingKey('Accounting.lease')).toBe(true);
|
||||
expect(isAccountingSettingKey('wallet.config')).toBe(false);
|
||||
});
|
||||
|
||||
it('fills a partial stored config with defaults', () => {
|
||||
const c = withDefaults({ provider: 'tahesab', features: { balances: true } });
|
||||
expect(c.provider).toBe('tahesab');
|
||||
expect(c.features).toEqual({ customers: false, balances: true, report: false, products: false, documents: false });
|
||||
expect(c.tahesab.dbName).toBe('TahesabDB');
|
||||
expect(c.mapping.karat).toBe(750);
|
||||
expect(withDefaults({ provider: 'other' }).provider).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps Decimal maths exact for the wage-as-karat rule', () => {
|
||||
const w = new Prisma.Decimal(18.5);
|
||||
expect(new Prisma.Decimal(750).mul(new Prisma.Decimal(1).add(w.div(100))).toString()).toBe('888.75');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import { AccountingOutbox, Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { envOr } from '../../common/env';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ActiveProvider, AccountingConfigService, JobName } from './accounting-config.service';
|
||||
import { AccountingSyncService, NeedsReviewError } from './accounting-sync.service';
|
||||
import { AccountingNotSupportedError, AccountingProviderError } from './providers';
|
||||
|
||||
/** Scheduled pulls (customers, full balances, full products): the safety net. */
|
||||
const TICK_MS = 30_000;
|
||||
/** Outbox and change poll, the real-time path: checked this often. */
|
||||
const FAST_TICK_MS = 2_000;
|
||||
/** How often the change feed is polled. */
|
||||
const CHANGES_POLL_MS = 5_000;
|
||||
/** Long enough for a full customer import; renewed before every step. */
|
||||
const LEASE_MS = 10 * 60_000;
|
||||
const LEASE_KEY = 'accounting.lease';
|
||||
const OUTBOX_BATCH = 25;
|
||||
const MAX_ATTEMPTS = 8;
|
||||
|
||||
type PullJob = Exclude<JobName, 'outbox'>;
|
||||
|
||||
export const PULL_FEATURE: Record<PullJob, 'customers' | 'balances' | 'products'> = {
|
||||
customers: 'customers',
|
||||
balances: 'balances',
|
||||
products: 'products',
|
||||
};
|
||||
|
||||
function toJson(v: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
|
||||
if (v === undefined || v === null) return Prisma.JsonNull;
|
||||
return JSON.parse(JSON.stringify(v)) as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the accounting integration in the background (D27, plan §3.4). Two
|
||||
* loops: a fast one (every couple of seconds) for the outbox and the change
|
||||
* feed, the real-time path; and the 30-second one for the scheduled pulls that
|
||||
* reconcile what the feed cannot see (new customers, edits and deletions,
|
||||
* overflow).
|
||||
*
|
||||
* In-process, no queue dependency. A lease row in the Setting table
|
||||
* (`accounting.lease`) keeps a second API instance from running the same jobs
|
||||
* at the same time; the in-memory flag keeps one instance's ticks from
|
||||
* overlapping. Set ACCOUNTING_WORKER=off to keep an instance out entirely.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountingWorkerService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private readonly owner = randomUUID();
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
private fastRunning = false;
|
||||
private fastTimer: NodeJS.Timeout | null = null;
|
||||
private kickTimer: NodeJS.Timeout | null = null;
|
||||
private changes: { provider: string; cursor: string | null } = { provider: '', cursor: null };
|
||||
private lastChangePollAt = 0;
|
||||
private lastChangeWarnAt = 0;
|
||||
private forced = new Set<PullJob>();
|
||||
private lastForcedCustomersAt = 0;
|
||||
/** Shown on the admin screen. */
|
||||
readonly realtime: {
|
||||
lastPollAt?: string;
|
||||
lastOkAt?: string;
|
||||
lastError?: string | null;
|
||||
lastChangeAt?: string;
|
||||
lastSummary?: Record<string, unknown>;
|
||||
} = {};
|
||||
private recovered = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AccountingConfigService,
|
||||
private readonly sync: AccountingSyncService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') return;
|
||||
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
||||
this.timer.unref?.();
|
||||
this.fastTimer = setInterval(() => void this.fastTick(), FAST_TICK_MS);
|
||||
this.fastTimer.unref?.();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.fastTimer) clearInterval(this.fastTimer);
|
||||
if (this.kickTimer) clearTimeout(this.kickTimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Something was just queued. Hooks run inside the caller's transaction, so
|
||||
* wake the outbox a moment later, once it has committed, instead of waiting
|
||||
* for the next tick.
|
||||
*/
|
||||
kickSoon() {
|
||||
if (this.kickTimer || !this.fastTimer) return;
|
||||
this.kickTimer = setTimeout(() => {
|
||||
this.kickTimer = null;
|
||||
void this.fastTick(true);
|
||||
}, 700);
|
||||
this.kickTimer.unref?.();
|
||||
}
|
||||
|
||||
/** True when this instance holds (or just took) the lease. */
|
||||
private async acquireLease(): Promise<boolean> {
|
||||
const value = JSON.stringify({ owner: this.owner, until: new Date(Date.now() + LEASE_MS).toISOString() });
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "Setting" ("key", "value", "updatedAt") VALUES (${LEASE_KEY}, ${value}::jsonb, now())
|
||||
ON CONFLICT ("key") DO NOTHING`;
|
||||
const n = await this.prisma.$executeRaw`
|
||||
UPDATE "Setting" SET "value" = ${value}::jsonb, "updatedAt" = now()
|
||||
WHERE "key" = ${LEASE_KEY}
|
||||
AND ("value"->>'owner' = ${this.owner} OR ("value"->>'until')::timestamptz < now())`;
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
async tick(only?: JobName): Promise<boolean> {
|
||||
if (this.running) return false;
|
||||
this.running = true;
|
||||
try {
|
||||
if (!(await this.acquireLease())) return false;
|
||||
const a = await this.config.active();
|
||||
if (!a) return false;
|
||||
|
||||
if (!this.recovered.has(a.provider.name)) {
|
||||
await this.recoverInterrupted(a);
|
||||
this.recovered.add(a.provider.name);
|
||||
}
|
||||
|
||||
if (only === 'outbox') await this.processOutbox(a);
|
||||
for (const job of ['balances', 'customers', 'products'] as PullJob[]) {
|
||||
if (only && only !== job) continue;
|
||||
if (!a.config.features[PULL_FEATURE[job]]) continue;
|
||||
const wasForced = this.forced.delete(job);
|
||||
if (!only && !wasForced && !(await this.isDue(job, a))) continue;
|
||||
await this.acquireLease();
|
||||
await this.runPull(job, a);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.error(`Accounting tick failed: ${(err as Error).message}`);
|
||||
return false;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real-time path, every couple of seconds: send queued documents and
|
||||
* edits at once, and poll the software's change feed so a balance or a stock
|
||||
* figure changed there is changed here moments later. Never waits behind a
|
||||
* long scheduled pull (it has its own guard, apart from tick()).
|
||||
*/
|
||||
async fastTick(force = false): Promise<void> {
|
||||
if (this.fastRunning) return;
|
||||
this.fastRunning = true;
|
||||
try {
|
||||
if (!(await this.acquireLease())) return;
|
||||
const a = await this.config.active();
|
||||
if (!a) return;
|
||||
if (!this.recovered.has(a.provider.name)) {
|
||||
await this.recoverInterrupted(a);
|
||||
this.recovered.add(a.provider.name);
|
||||
}
|
||||
await this.processOutbox(a);
|
||||
|
||||
if (!force && Date.now() - this.lastChangePollAt < CHANGES_POLL_MS) return;
|
||||
if (!a.provider.capabilities.changeFeed || !(a.config.features.balances || a.config.features.products)) return;
|
||||
this.lastChangePollAt = Date.now();
|
||||
await this.pollChanges(a);
|
||||
} catch (err) {
|
||||
this.logger.error(`Accounting fast tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.fastRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollChanges(a: ActiveProvider) {
|
||||
if (this.changes.provider !== a.provider.name) this.changes = { provider: a.provider.name, cursor: null };
|
||||
this.realtime.lastPollAt = new Date().toISOString();
|
||||
try {
|
||||
const baseline = this.changes.cursor === null;
|
||||
const r = await this.sync.pollChanges(a, this.changes.cursor);
|
||||
this.changes.cursor = r.cursor;
|
||||
this.realtime.lastOkAt = new Date().toISOString();
|
||||
this.realtime.lastError = null;
|
||||
if (r.balances > 0 || r.stocks > 0) {
|
||||
this.realtime.lastChangeAt = this.realtime.lastOkAt;
|
||||
this.realtime.lastSummary = { balances: r.balances, stocks: r.stocks };
|
||||
}
|
||||
if (baseline) return;
|
||||
// What the feed cannot describe goes to the scheduled jobs right away.
|
||||
if (r.fullBalances) this.forced.add('balances');
|
||||
if (r.fullProducts) this.forced.add('products');
|
||||
if (r.newAccounts && a.config.features.customers && Date.now() - this.lastForcedCustomersAt > 60_000) {
|
||||
this.lastForcedCustomersAt = Date.now();
|
||||
this.forced.add('customers');
|
||||
}
|
||||
if (this.forced.size > 0) void this.tick();
|
||||
} catch (err) {
|
||||
const msg = this.sync.describeError(err);
|
||||
this.realtime.lastError = msg;
|
||||
if (Date.now() - this.lastChangeWarnAt > 60_000) {
|
||||
this.lastChangeWarnAt = Date.now();
|
||||
this.logger.warn(`Accounting change poll failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sync button (plan §12): runs these pulls now and waits for them. Needs
|
||||
* only a connected provider — the feature switches do not apply. Each job
|
||||
* reports on its own, so one failing does not hide the others.
|
||||
*/
|
||||
async runPullsNow(jobs: PullJob[]): Promise<Record<string, { ok: boolean; summary?: unknown; error?: string }> | null> {
|
||||
if (this.running) return null;
|
||||
this.running = true;
|
||||
try {
|
||||
const a = await this.config.active();
|
||||
if (!a) return null;
|
||||
const out: Record<string, { ok: boolean; summary?: unknown; error?: string }> = {};
|
||||
for (const job of jobs) {
|
||||
await this.acquireLease();
|
||||
const startedAt = new Date().toISOString();
|
||||
await this.config.recordJob(job, { lastRunAt: startedAt });
|
||||
try {
|
||||
const summary =
|
||||
job === 'customers'
|
||||
? await this.sync.pullCustomers(a)
|
||||
: job === 'balances'
|
||||
? await this.sync.pullBalances(a)
|
||||
: await this.sync.pullProducts(a);
|
||||
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
|
||||
out[job] = { ok: true, summary };
|
||||
} catch (err) {
|
||||
const error = this.sync.describeError(err);
|
||||
await this.config.recordJob(job, { lastError: error });
|
||||
out[job] = { ok: false, error };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** «سینک الان» from the admin screen: runs in the background, returns at once. */
|
||||
runNow(job: JobName): boolean {
|
||||
if (job === 'outbox') {
|
||||
if (this.fastRunning) return false;
|
||||
void this.fastTick(true);
|
||||
return true;
|
||||
}
|
||||
if (this.running) return false;
|
||||
void this.tick(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
get busy() {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async isDue(job: PullJob, a: ActiveProvider) {
|
||||
const minutes = {
|
||||
customers: a.config.intervals.customersMinutes,
|
||||
balances: a.config.intervals.balancesMinutes,
|
||||
products: a.config.intervals.productsMinutes,
|
||||
}[job];
|
||||
const state = await this.config.getState();
|
||||
const last = state.jobs[job]?.lastRunAt;
|
||||
return !last || Date.now() - new Date(last).getTime() >= minutes * 60_000;
|
||||
}
|
||||
|
||||
private async runPull(job: PullJob, a: ActiveProvider) {
|
||||
const startedAt = new Date().toISOString();
|
||||
await this.config.recordJob(job, { lastRunAt: startedAt });
|
||||
try {
|
||||
const summary =
|
||||
job === 'customers'
|
||||
? await this.sync.pullCustomers(a)
|
||||
: job === 'balances'
|
||||
? await this.sync.pullBalances(a)
|
||||
: await this.sync.pullProducts(a);
|
||||
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
|
||||
} catch (err) {
|
||||
const msg = this.sync.describeError(err);
|
||||
this.logger.warn(`Accounting ${job} pull failed: ${msg}`);
|
||||
await this.config.recordJob(job, { lastError: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A job left in SENDING means the process stopped mid-call: its outcome is
|
||||
* unknown. Safe kinds go back to the queue; a Zarrin document goes to a
|
||||
* person, since sending it again could post it twice.
|
||||
*/
|
||||
private async recoverInterrupted(a: ActiveProvider) {
|
||||
const stuck = await this.prisma.accountingOutbox.findMany({ where: { provider: a.provider.name, status: 'SENDING' } });
|
||||
for (const job of stuck) {
|
||||
const safe = this.sync.isRetrySafe(a.provider.name, job.kind);
|
||||
await this.prisma.accountingOutbox.update({
|
||||
where: { id: job.id },
|
||||
data: safe
|
||||
? { status: 'PENDING', nextAttemptAt: new Date() }
|
||||
: {
|
||||
status: 'NEEDS_REVIEW',
|
||||
lastError: 'سرور هنگام ارسال متوقف شد؛ معلوم نیست سند ثبت شده یا نه. پیش از «تلاش دوباره» در حسابداری بررسی کنید.',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async processOutbox(a: ActiveProvider) {
|
||||
const jobs = await this.prisma.accountingOutbox.findMany({
|
||||
where: { provider: a.provider.name, status: 'PENDING', nextAttemptAt: { lte: new Date() } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: OUTBOX_BATCH,
|
||||
});
|
||||
const startedAt = new Date().toISOString();
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const job of jobs) {
|
||||
await this.acquireLease();
|
||||
const ok = await this.runOne(a, job);
|
||||
if (ok) sent += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
if (jobs.length > 0) {
|
||||
await this.config.recordJob('outbox', { lastRunAt: startedAt, lastOkAt: new Date().toISOString(), summary: { picked: jobs.length, sent, failed } });
|
||||
}
|
||||
}
|
||||
|
||||
private async runOne(a: ActiveProvider, job: AccountingOutbox): Promise<boolean> {
|
||||
const attempts = job.attempts + 1;
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { status: 'SENDING', attempts } });
|
||||
const fresh = { ...job, attempts };
|
||||
try {
|
||||
const res = await this.sync.runJob(a, fresh);
|
||||
await this.prisma.accountingOutbox.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'SENT',
|
||||
sentAt: new Date(),
|
||||
externalRef: res.externalRef ?? null,
|
||||
lastRequest: toJson(res.request),
|
||||
lastResponse: toJson(res.response),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = this.sync.describeError(err);
|
||||
const response = err instanceof AccountingProviderError ? err.response : undefined;
|
||||
let data: Prisma.AccountingOutboxUpdateInput;
|
||||
if (err instanceof NeedsReviewError || err instanceof AccountingNotSupportedError) {
|
||||
data = { status: 'NEEDS_REVIEW', lastError: msg };
|
||||
} else if (err instanceof AccountingProviderError && err.ambiguous && !this.sync.isRetrySafe(a.provider.name, job.kind)) {
|
||||
data = {
|
||||
status: 'NEEDS_REVIEW',
|
||||
lastError: `${msg} — ممکن است سند ثبت شده باشد؛ پیش از «تلاش دوباره» در حسابداری بررسی کنید.`,
|
||||
};
|
||||
} else if (attempts >= MAX_ATTEMPTS) {
|
||||
data = { status: 'FAILED', lastError: msg };
|
||||
} else {
|
||||
const delayMin = Math.min(2 ** (attempts - 1), 60);
|
||||
data = { status: 'PENDING', lastError: msg, nextAttemptAt: new Date(Date.now() + delayMin * 60_000) };
|
||||
}
|
||||
if (response !== undefined) data.lastResponse = toJson(response);
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data });
|
||||
this.logger.warn(`Accounting job ${job.kind} ${job.entityId} → ${String(data.status)}: ${msg}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Jalali (Shamsi) ⇄ Gregorian conversion.
|
||||
*
|
||||
* Tahesab wants a document date as separate Shamsi year/month/day integers and
|
||||
* its list filters as `1405-04-24`; the customer app sends report ranges as
|
||||
* `1405/04/24`; Zarrin wants Gregorian `2024-10-12` on most calls. One small
|
||||
* converter covers all three without a dependency.
|
||||
*
|
||||
* The arithmetic is the well-known jalaali-js algorithm (Borkowski's leap-year
|
||||
* break table), valid for Jalali years -61 … 3177.
|
||||
*/
|
||||
|
||||
export interface JalaliDate {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
}
|
||||
|
||||
const BREAKS = [
|
||||
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456,
|
||||
3178,
|
||||
];
|
||||
|
||||
function div(a: number, b: number) {
|
||||
return ~~(a / b);
|
||||
}
|
||||
|
||||
function mod(a: number, b: number) {
|
||||
return a - ~~(a / b) * b;
|
||||
}
|
||||
|
||||
function jalCal(jy: number) {
|
||||
const gy = jy + 621;
|
||||
let leapJ = -14;
|
||||
let jp = BREAKS[0];
|
||||
let jump = 0;
|
||||
if (jy < jp || jy >= BREAKS[BREAKS.length - 1]) throw new RangeError(`Invalid Jalali year ${jy}`);
|
||||
|
||||
for (let i = 1; i < BREAKS.length; i += 1) {
|
||||
const jm = BREAKS[i];
|
||||
jump = jm - jp;
|
||||
if (jy < jm) break;
|
||||
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
|
||||
jp = jm;
|
||||
}
|
||||
let n = jy - jp;
|
||||
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
|
||||
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
|
||||
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
|
||||
const march = 20 + leapJ - leapG;
|
||||
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
|
||||
let leap = mod(mod(n + 1, 33) - 1, 4);
|
||||
if (leap === -1) leap = 4;
|
||||
return { leap, gy, march };
|
||||
}
|
||||
|
||||
function g2d(gy: number, gm: number, gd: number) {
|
||||
let d =
|
||||
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * mod(gm + 9, 12) + 2, 5) + gd - 34840408;
|
||||
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
|
||||
return d;
|
||||
}
|
||||
|
||||
function d2g(jdn: number) {
|
||||
let j = 4 * jdn + 139361631;
|
||||
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
|
||||
const i = div(mod(j, 1461), 4) * 5 + 308;
|
||||
const gd = div(mod(i, 153), 5) + 1;
|
||||
const gm = mod(div(i, 153), 12) + 1;
|
||||
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
|
||||
return { gy, gm, gd };
|
||||
}
|
||||
|
||||
function j2d(jy: number, jm: number, jd: number) {
|
||||
const r = jalCal(jy);
|
||||
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
|
||||
}
|
||||
|
||||
function d2j(jdn: number): JalaliDate {
|
||||
const gy = d2g(jdn).gy;
|
||||
let jy = gy - 621;
|
||||
const r = jalCal(jy);
|
||||
const jdn1f = g2d(gy, 3, r.march);
|
||||
let k = jdn - jdn1f;
|
||||
if (k >= 0) {
|
||||
if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 };
|
||||
k -= 186;
|
||||
} else {
|
||||
jy -= 1;
|
||||
k += 179;
|
||||
if (r.leap === 1) k += 1;
|
||||
}
|
||||
return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 };
|
||||
}
|
||||
|
||||
/** Iran has had no DST since 2022, so a fixed +03:30 gives the local calendar day. */
|
||||
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
|
||||
|
||||
/** The Jalali calendar day of an instant, as seen in Tehran. */
|
||||
export function toJalali(date: Date): JalaliDate {
|
||||
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
|
||||
return d2j(g2d(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate()));
|
||||
}
|
||||
|
||||
/** Gregorian calendar day for a Jalali day: `{ gy, gm, gd }`. */
|
||||
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
|
||||
return d2g(j2d(jy, jm, jd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `1405/04/24`, `1405-4-24` or Persian digits. Returns null for anything
|
||||
* that is not a real Jalali date.
|
||||
*/
|
||||
export function parseJalali(input: string | null | undefined): JalaliDate | null {
|
||||
if (!input) return null;
|
||||
const ascii = String(input)
|
||||
.trim()
|
||||
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||
const m = ascii.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/);
|
||||
if (!m) return null;
|
||||
const jy = Number(m[1]);
|
||||
const jm = Number(m[2]);
|
||||
const jd = Number(m[3]);
|
||||
if (jm < 1 || jm > 12 || jd < 1 || jd > 31 || (jm > 6 && jd > 30)) return null;
|
||||
try {
|
||||
// Round-trip rejects 30 Esfand in a common year.
|
||||
const back = d2j(j2d(jy, jm, jd));
|
||||
return back.jy === jy && back.jm === jm && back.jd === jd ? { jy, jm, jd } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** `1405-04-24` (or with another separator). */
|
||||
export function formatJalali(d: JalaliDate, sep = '-') {
|
||||
return `${d.jy}${sep}${pad(d.jm)}${sep}${pad(d.jd)}`;
|
||||
}
|
||||
|
||||
/** `2026-07-15` for a Jalali day. */
|
||||
export function jalaliToIsoDate(d: JalaliDate) {
|
||||
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
|
||||
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`;
|
||||
}
|
||||
|
||||
/** `2026-07-15` for an instant, Tehran calendar day. */
|
||||
export function toIsoDateTehran(date: Date) {
|
||||
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
|
||||
return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { JalaliDate } from '../jalali';
|
||||
|
||||
/**
|
||||
* One accounting software (D27). AccountingSyncService picks a driver by the
|
||||
* admin's switch and owns everything around the call — links, outbox, retries,
|
||||
* logging — so a driver only translates between the app's shapes and its
|
||||
* software's API.
|
||||
*
|
||||
* Every figure a driver returns is ALREADY in the app's convention:
|
||||
* * gold in grams of 750, positive balance = the customer owes;
|
||||
* * rial in rial, positive balance = the customer owes.
|
||||
* Each software's own sign and unit quirks stay inside its driver.
|
||||
*/
|
||||
|
||||
export type ProviderName = 'tahesab' | 'zarrin';
|
||||
|
||||
/** Decrypted connection settings, handed to every call. */
|
||||
export interface ProviderSettings {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
caCert: string | null;
|
||||
timeoutMs: number;
|
||||
/** Tahesab only: the `DBname` header (`TahesabDB` for SQL Server, `DB` for Access). */
|
||||
dbName?: string;
|
||||
/** Zarrin only, until verified on a real installation (plan §9). */
|
||||
balanceOwesWhen?: 'negative' | 'positive';
|
||||
}
|
||||
|
||||
export interface ExternalGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExternalCustomer {
|
||||
code: string;
|
||||
name: string;
|
||||
/** Normalised to 09xxxxxxxxx, or null when the account has no usable mobile. */
|
||||
mobile: string | null;
|
||||
phone: string | null;
|
||||
nationalCode: string | null;
|
||||
address: string | null;
|
||||
groupId: string | null;
|
||||
groupName: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerPayload {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
nationalCode: string | null;
|
||||
group: { id: string | null; name: string | null };
|
||||
}
|
||||
|
||||
export interface ExternalBalance {
|
||||
code: string;
|
||||
goldGrams: Prisma.Decimal;
|
||||
rial: Prisma.Decimal;
|
||||
}
|
||||
|
||||
export interface StatementQuery {
|
||||
code: string;
|
||||
from: JalaliDate;
|
||||
to: JalaliDate;
|
||||
}
|
||||
|
||||
/** One line of a customer's statement, normalised across providers. */
|
||||
export interface StatementRow {
|
||||
/** Stable within the provider (Tahesab ID + Radif, Zarrin idRadif). */
|
||||
key: string;
|
||||
/** The document the line belongs to — what a detail lookup is keyed on. */
|
||||
documentRef: string;
|
||||
/** The account the line belongs to, for the SEC-16 ownership check. */
|
||||
accountCode: string;
|
||||
documentNo: string;
|
||||
/** Jalali `1405/07/12`. */
|
||||
date: string;
|
||||
time: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
itemName: string | null;
|
||||
weight: Prisma.Decimal | null;
|
||||
karat: number | null;
|
||||
count: Prisma.Decimal | null;
|
||||
unitPrice: Prisma.Decimal | null;
|
||||
wagePercent: Prisma.Decimal | null;
|
||||
/** 750-equivalent gold received from the customer (reduces what they owe). */
|
||||
goldIn: Prisma.Decimal;
|
||||
goldOut: Prisma.Decimal;
|
||||
rialIn: Prisma.Decimal;
|
||||
rialOut: Prisma.Decimal;
|
||||
/** Running balance after this line, when the software reports one. */
|
||||
goldBalance: Prisma.Decimal | null;
|
||||
rialBalance: Prisma.Decimal | null;
|
||||
metal: string | null;
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalKarSakhte {
|
||||
/** Zarrin idKar; for Tahesab, which has no item ids, the item name itself. */
|
||||
code: string;
|
||||
name: string;
|
||||
groupId: string | null;
|
||||
groupName: string | null;
|
||||
typeId: string | null;
|
||||
typeName: string | null;
|
||||
saleWagePercent: Prisma.Decimal | null;
|
||||
stockWeight: Prisma.Decimal | null;
|
||||
stockCount: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface KarSakhteStock {
|
||||
weight: Prisma.Decimal | null;
|
||||
count: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface MovementRow {
|
||||
itemCode: string | null;
|
||||
itemName: string;
|
||||
date: string;
|
||||
description: string | null;
|
||||
weight: Prisma.Decimal | null;
|
||||
amount: Prisma.Decimal | null;
|
||||
count: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface DocumentLine {
|
||||
/** The linked کارساخته: Zarrin idKar / Tahesab Name_Kar. */
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
/** Physical (raw) weight of the line, all pieces. */
|
||||
weightGrams: Prisma.Decimal;
|
||||
count: number;
|
||||
/** Effective making charge: variant wage minus discount, in percent. */
|
||||
wagePercent: Prisma.Decimal;
|
||||
/** Flat rial charge of the line (stones), all pieces. */
|
||||
flatRial: Prisma.Decimal;
|
||||
karat: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface OrderDocumentInput {
|
||||
/** Deterministic per order; Tahesab dedupes on it (≥ 20 chars, lowercase). */
|
||||
factorCode: string;
|
||||
customerCode: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
temporary: boolean;
|
||||
lines: DocumentLine[];
|
||||
}
|
||||
|
||||
/** Resumable state of a multi-call document (Zarrin posts one call per line). */
|
||||
export interface DocumentProgress {
|
||||
factorId?: string;
|
||||
doneLines?: number;
|
||||
}
|
||||
|
||||
export interface DocumentPostResult {
|
||||
ref: string;
|
||||
progress: DocumentProgress;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
/** What changed in the accounting software since the last poll (real-time sync). */
|
||||
export interface ChangeBatch {
|
||||
/** Opaque; hand it back on the next poll. */
|
||||
cursor: string;
|
||||
/** Accounts that got a new or edited document. */
|
||||
accountCodes: string[];
|
||||
/** کارساخته names that got a new or edited document. */
|
||||
itemNames: string[];
|
||||
/** More changed than one poll can see: the caller must do a full refresh. */
|
||||
overflow: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
/** Can a کارساخته be created through the API? (Zarrin yes, Tahesab no.) */
|
||||
createKarSakhte: boolean;
|
||||
/** Can a posted document be looked up by our own key before a retry? */
|
||||
documentInquiry: boolean;
|
||||
/** Does the movement report cover every account, or only recent documents? */
|
||||
movementScope: 'all' | 'recent-documents';
|
||||
/** Can recent changes be polled cheaply (see getChanges)? */
|
||||
changeFeed: boolean;
|
||||
}
|
||||
|
||||
export interface AccountingProvider {
|
||||
readonly name: ProviderName;
|
||||
readonly label: string;
|
||||
readonly capabilities: ProviderCapabilities;
|
||||
|
||||
health(s: ProviderSettings): Promise<Record<string, unknown>>;
|
||||
|
||||
listCustomerGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
/** One page of accounts; `next` is null after the last page. */
|
||||
listCustomers(s: ProviderSettings, cursor: string | null): Promise<{ items: ExternalCustomer[]; next: string | null }>;
|
||||
findCustomersByMobile(s: ProviderSettings, mobile: string): Promise<ExternalCustomer[]>;
|
||||
createCustomer(s: ProviderSettings, p: CustomerPayload): Promise<{ code: string; response: unknown }>;
|
||||
updateCustomer(s: ProviderSettings, code: string, p: CustomerPayload): Promise<{ response: unknown }>;
|
||||
|
||||
getBalances(s: ProviderSettings, codes: string[]): Promise<ExternalBalance[]>;
|
||||
/**
|
||||
* Documents added or edited since `cursor` (null = first poll: only returns
|
||||
* the starting cursor). Cheap enough to call every few seconds.
|
||||
*/
|
||||
getChanges(s: ProviderSettings, cursor: string | null): Promise<ChangeBatch>;
|
||||
getStatement(s: ProviderSettings, q: StatementQuery): Promise<StatementRow[]>;
|
||||
|
||||
listKarSakhteGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
listKarSakhteTypes(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
listKarSakhte(s: ProviderSettings): Promise<ExternalKarSakhte[]>;
|
||||
getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise<KarSakhteStock>;
|
||||
getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise<MovementRow[]>;
|
||||
createKarSakhte(
|
||||
s: ProviderSettings,
|
||||
item: { name: string; groupId: string | null; typeId: string | null },
|
||||
): Promise<{ code: string; response: unknown }>;
|
||||
|
||||
postOrderDocument(
|
||||
s: ProviderSettings,
|
||||
doc: OrderDocumentInput,
|
||||
progress: DocumentProgress,
|
||||
onProgress: (p: DocumentProgress) => Promise<void>,
|
||||
): Promise<DocumentPostResult>;
|
||||
/** True when a document with this key exists. */
|
||||
documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise<boolean>;
|
||||
deleteDocument(
|
||||
s: ProviderSettings,
|
||||
doc: { factorCode: string; ref: string | null; customerCode: string },
|
||||
): Promise<{ response: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A call the software answered with an error, or could not be made.
|
||||
* `ambiguous` = the software may have acted on it anyway (timeout after send).
|
||||
*/
|
||||
export class AccountingProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly ambiguous = false,
|
||||
readonly response?: unknown,
|
||||
readonly notFound = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountingProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Not offered by this software's API. */
|
||||
export class AccountingNotSupportedError extends AccountingProviderError {
|
||||
constructor(what: string) {
|
||||
super(`${what} در API این نرمافزار وجود ندارد`);
|
||||
this.name = 'AccountingNotSupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
export function asciiDigits(v: string) {
|
||||
return v
|
||||
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)));
|
||||
}
|
||||
|
||||
/**
|
||||
* `9121234567`, `09121234567`, `+989121234567`, `00989121234567`, Persian
|
||||
* digits → `09121234567`. Anything that is not an Iranian mobile → null.
|
||||
*/
|
||||
export function normalizeMobile(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
let d = asciiDigits(String(v)).replace(/\D/g, '');
|
||||
if (d.startsWith('0098')) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length === 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** A Prisma.Decimal from a value the software sent, or null for "null"/blank/NaN. */
|
||||
export function dec(v: unknown): Prisma.Decimal | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = asciiDigits(String(v)).replace(/,/g, '').trim();
|
||||
if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'nan') return null;
|
||||
try {
|
||||
const d = new Prisma.Decimal(s);
|
||||
return d.isFinite() ? d : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const ZERO = new Prisma.Decimal(0);
|
||||
|
||||
export function str(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = String(v).trim();
|
||||
return s === '' || s.toLowerCase() === 'null' ? null : s;
|
||||
}
|
||||
|
||||
/** Split a full name into first/last the way the app's DB trigger does. */
|
||||
export function splitName(full: string): { firstName: string; lastName: string } {
|
||||
const parts = full.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' };
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AccountingProvider, ProviderName } from './accounting-provider';
|
||||
import { tahesabProvider } from './tahesab.provider';
|
||||
import { zarrinProvider } from './zarrin.provider';
|
||||
|
||||
/**
|
||||
* Every accounting software, keyed by the admin switch's value. A new one is a
|
||||
* driver file plus one line here — the same shape as the SMS drivers (D26).
|
||||
*/
|
||||
export const ACCOUNTING_PROVIDERS: Readonly<Record<ProviderName, AccountingProvider>> = {
|
||||
tahesab: tahesabProvider,
|
||||
zarrin: zarrinProvider,
|
||||
};
|
||||
|
||||
export function isProviderName(v: unknown): v is ProviderName {
|
||||
return typeof v === 'string' && Object.prototype.hasOwnProperty.call(ACCOUNTING_PROVIDERS, v);
|
||||
}
|
||||
|
||||
export * from './accounting-provider';
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { httpRequest } from '../accounting-http';
|
||||
import { AccountingProviderError, OrderDocumentInput, ProviderSettings } from './accounting-provider';
|
||||
import { karatWithWage, tahesabProvider as t } from './tahesab.provider';
|
||||
|
||||
jest.mock('../accounting-http', () => ({
|
||||
...jest.requireActual('../accounting-http'),
|
||||
httpRequest: jest.fn(),
|
||||
}));
|
||||
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
|
||||
|
||||
const s: ProviderSettings = { baseUrl: 'https://tahesab.local:8081/', token: 'TOK', caCert: null, timeoutMs: 5000, dbName: 'TahesabDB' };
|
||||
|
||||
const reply = (status: number, body: unknown) =>
|
||||
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
||||
|
||||
/** The body of the n-th request, parsed. */
|
||||
const sent = (n = 0) => JSON.parse(http.mock.calls[n][0].body as string);
|
||||
|
||||
beforeEach(() => http.mockReset());
|
||||
|
||||
describe('wire format', () => {
|
||||
it('posts {"Method": [args]} to the root with the bearer token and DBname', async () => {
|
||||
http.mockReturnValueOnce(reply(200, [{ Name: 'بنکدار', GID: 2 }]));
|
||||
const groups = await t.listCustomerGroups(s);
|
||||
const req = http.mock.calls[0][0];
|
||||
expect(req.method).toBe('POST');
|
||||
expect(req.url).toBe('https://tahesab.local:8081/');
|
||||
expect(req.headers).toMatchObject({ Authorization: 'Bearer TOK', DBname: 'TahesabDB' });
|
||||
expect(sent()).toEqual({ DoListGorooh: [] });
|
||||
// GID 0 (accounts in no group) is offered too.
|
||||
expect(groups).toEqual([
|
||||
{ id: '2', name: 'بنکدار' },
|
||||
{ id: '0', name: 'بدون گروه (GID 0)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('turns {"ERROR": …} into a provider error, flagging "not found"', async () => {
|
||||
http.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.', Command: 'POST', SSL: '1' }));
|
||||
await expect(t.updateCustomer(s, '5', { firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '09120000000', phone: null, address: null, nationalCode: null, group: { id: null, name: null } })).rejects.toMatchObject({
|
||||
notFound: true,
|
||||
response: { ERROR: 'هیچ رکوردی یافت نشد.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a timeout after the request went out as ambiguous', async () => {
|
||||
const { AccountingHttpError } = jest.requireActual('../accounting-http');
|
||||
http.mockRejectedValueOnce(new AccountingHttpError('timeout', true, 'TIMEOUT'));
|
||||
await expect(t.listCustomerGroups(s)).rejects.toMatchObject({ ambiguous: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('customers', () => {
|
||||
it('pages through code windows and stops after empty ones', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply(200, {
|
||||
'1': { Code: 1, Name: 'masoud', GID: '2', GoroupName: 'بنکدار', Mobile: '9121234567', CodeMelli: '', Address: 'آدرس' },
|
||||
}),
|
||||
);
|
||||
const page1 = await t.listCustomers(s, null);
|
||||
expect(sent()).toEqual({ Dolistmoshtari: [1, 500] });
|
||||
expect(page1.items[0]).toMatchObject({ code: '1', mobile: '09121234567', groupId: '2', nationalCode: null });
|
||||
expect(page1.next).toBe('501:0');
|
||||
|
||||
http.mockReturnValue(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
|
||||
let cursor: string | null = page1.next;
|
||||
let calls = 0;
|
||||
while (cursor) {
|
||||
cursor = (await t.listCustomers(s, cursor)).next;
|
||||
calls += 1;
|
||||
}
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
|
||||
it('looks a mobile up in both stored forms and keeps exact matches only', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(reply(200, { '1': { Code: 1, Name: 'a', Mobile: '9121234567' }, '790': { Code: 790, Name: 'b', Mobile: '9121234567' } }))
|
||||
.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
|
||||
const found = await t.findCustomersByMobile(s, '09121234567');
|
||||
expect(sent(0)).toEqual({ DoListMoshtari: [9121234567] });
|
||||
expect(sent(1)).toEqual({ DoListMoshtari: ['09121234567'] });
|
||||
expect(found.map((c) => c.code)).toEqual(['1', '790']);
|
||||
});
|
||||
|
||||
it('creates with an auto code and returns it', async () => {
|
||||
http.mockReturnValueOnce(reply(200, { OK: 35 }));
|
||||
const res = await t.createCustomer(s, {
|
||||
firstName: 'علی',
|
||||
lastName: 'محمدی',
|
||||
fullName: 'علی محمدی',
|
||||
mobile: '09121112233',
|
||||
phone: null,
|
||||
address: null,
|
||||
nationalCode: null,
|
||||
group: { id: '13', name: 'مشتري' },
|
||||
});
|
||||
expect(res.code).toBe('35');
|
||||
const args = sent().DoNewMoshtari;
|
||||
expect(args[0]).toBe('علی محمدی');
|
||||
expect(args[1]).toBe('مشتري');
|
||||
expect(args[2]).toBe('09121112233');
|
||||
expect(args[8]).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balances and statement', () => {
|
||||
it('flips the sign: in Tahesab negative means the customer owes', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply(200, { MandeHesab: [{ Code: '1', MandeyeMali: '-238999230', MandeyeVazni: '-177.431' }, { Code: '2', MandeyeMali: '5', MandeyeVazni: '1.5' }] }),
|
||||
);
|
||||
const b = await t.getBalances(s, ['1', '2', '999']);
|
||||
expect(sent()).toEqual({ getmandehesabbycode: ['1', '2', '999'] });
|
||||
expect(b).toEqual([
|
||||
{ code: '1', goldGrams: new Prisma.Decimal('177.431'), rial: new Prisma.Decimal('238999230') },
|
||||
{ code: '2', goldGrams: new Prisma.Decimal('-1.5'), rial: new Prisma.Decimal('-5') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps DoListAsnad rows (real test-server rows)', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply(200, {
|
||||
'11558': {
|
||||
ID: 11558, Radif: 1, Factor_Code: '123456789123421578191111985', MCode: 1, Tarikh: '1405/07/11', Tarikh_Time: '19:47:44',
|
||||
NO: 'ورود کارساخته', Sh_Factor: 228, Vazn: 1.25, No_Kar: 'testmm', Ayar: 750, Fi: 10000, Mazaneh: 'null', Count: 1,
|
||||
TabdilVazn: 1.25, Mali: 100000, Sharh1: 'sharhtest', TahesabVazni: 11526.174, TahesabMali: -209358396276,
|
||||
Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: false, DarsadSood: 5.5,
|
||||
},
|
||||
'12261': {
|
||||
ID: 12261, Radif: 1, Factor_Code: 'GBORDX', MCode: 1, Tarikh: '1405/07/12', NO: 'خروج کارساخته', Vazn: 2,
|
||||
TabdilVazn: -2, Mali: 'null', TahesabVazni: 0, TahesabMali: 0, Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rows = await t.getStatement(s, { code: '1', from: { jy: 1405, jm: 6, jd: 1 }, to: { jy: 1405, jm: 7, jd: 13 } });
|
||||
expect(sent()).toEqual({ DoListAsnad: [-1, 1, '1405-06-01', '1405-07-13', '', 0, 'IRR'] });
|
||||
expect(rows[0]).toMatchObject({
|
||||
documentRef: '123456789123421578191111985',
|
||||
accountCode: '1',
|
||||
title: 'ورود کارساخته',
|
||||
itemName: 'testmm',
|
||||
metal: 'طلا',
|
||||
temporary: false,
|
||||
});
|
||||
expect(rows[0].goldIn.toString()).toBe('1.25');
|
||||
expect(rows[0].goldOut.toString()).toBe('0');
|
||||
expect(rows[0].rialIn.toString()).toBe('100000');
|
||||
expect(rows[0].goldBalance?.toString()).toBe('-11526.174');
|
||||
expect(rows[0].rialBalance?.toString()).toBe('209358396276');
|
||||
// A temporary row moves nothing yet: no balance of its own.
|
||||
expect(rows[1]).toMatchObject({ documentRef: 'gbordx', temporary: true, goldBalance: null });
|
||||
expect(rows[1].goldOut.toString()).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('order documents', () => {
|
||||
const doc: OrderDocumentInput = {
|
||||
factorCode: 'gbordcm1abcdefghijklmnopqrstuv',
|
||||
customerCode: '2',
|
||||
date: new Date('2026-10-05T08:00:00Z'),
|
||||
description: 'سفارش 1001 از اپ',
|
||||
temporary: true,
|
||||
lines: [
|
||||
{ itemCode: 'انگشتر', itemName: 'انگشتر', weightGrams: new Prisma.Decimal('4.200'), count: 2, wagePercent: new Prisma.Decimal(10), flatRial: new Prisma.Decimal(0), karat: 750, description: 'line 1' },
|
||||
{ itemCode: 'النگو', itemName: 'النگو', weightGrams: new Prisma.Decimal('10'), count: 1, wagePercent: new Prisma.Decimal('18.5'), flatRial: new Prisma.Decimal(500000), karat: 750, description: 'line 2' },
|
||||
],
|
||||
};
|
||||
|
||||
it('raises the karat by the making charge — the only field that moves the gold balance', () => {
|
||||
expect(karatWithWage(750, new Prisma.Decimal(10)).toString()).toBe('825');
|
||||
expect(karatWithWage(750, new Prisma.Decimal('18.5')).toString()).toBe('888.75');
|
||||
});
|
||||
|
||||
it('posts one temporary exit row per line under one Factor_Code', async () => {
|
||||
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
|
||||
const progress: unknown[] = [];
|
||||
const res = await t.postOrderDocument(s, doc, {}, async (p) => {
|
||||
progress.push(p);
|
||||
});
|
||||
|
||||
const r1 = sent(0).DoNewSanadVKHKar;
|
||||
const r2 = sent(1).DoNewSanadVKHKar;
|
||||
expect(r1).toHaveLength(27);
|
||||
expect(r1[0]).toBe(0); // temporary
|
||||
expect(r1[1]).toBe(2); // Moshtari_Code
|
||||
expect([r1[3], r2[3]]).toEqual([1, 2]); // Radif_Number
|
||||
expect([r1[4], r1[5], r1[6]]).toEqual([1405, 7, 13]);
|
||||
expect(r1[7]).toBe(4.2); // physical weight
|
||||
expect(r1[8]).toBe(825); // karat with the charge
|
||||
expect(r1[9]).toBe(2);
|
||||
expect(r1[10]).toBe('انگشتر');
|
||||
expect(r1[11]).toBe(1); // exit
|
||||
expect(r1[16]).toBe(0); // Darsad_Talayee never double-counts
|
||||
expect(r2[13]).toBe(500000); // stones in MablaghKol
|
||||
expect(r2[20]).toBe(0); // not PoolSang
|
||||
expect(r1[22]).toBe(doc.factorCode);
|
||||
expect(r1[24]).toBe(1); // MultiRadif
|
||||
expect(progress).toEqual([{ doneLines: 1 }, { doneLines: 2 }]);
|
||||
expect(res.ref).toBe(doc.factorCode);
|
||||
});
|
||||
|
||||
it('resumes after the last confirmed line', async () => {
|
||||
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
|
||||
await t.postOrderDocument(s, doc, { doneLines: 1 }, async () => undefined);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
expect(sent(0).DoNewSanadVKHKar[3]).toBe(2);
|
||||
});
|
||||
|
||||
it('treats a duplicate-row rejection as "already there", which makes a retry safe', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(
|
||||
reply(400, {
|
||||
ERROR:
|
||||
"[EUniError] The statement has been terminated.\r\nCannot insert duplicate key row in object 'dbo.hesab_detail' with unique index 'UX_hesab_detail_UniqRowID'.",
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
|
||||
const res = await t.postOrderDocument(s, doc, {}, async () => undefined);
|
||||
expect(res.progress).toEqual({ doneLines: 2 });
|
||||
});
|
||||
|
||||
it('does not swallow other errors', async () => {
|
||||
http.mockReturnValueOnce(reply(400, { ERROR: 'کد حساب نامعتبر است' }));
|
||||
await expect(t.postOrderDocument(s, doc, {}, async () => undefined)).rejects.toBeInstanceOf(AccountingProviderError);
|
||||
});
|
||||
|
||||
it('inquires and deletes by Factor_Code', async () => {
|
||||
http.mockReturnValueOnce(reply(200, { Found: doc.factorCode })).mockReturnValueOnce(reply(404, { ERROR: 'Not_Found' }));
|
||||
expect(await t.documentExists(s, { factorCode: doc.factorCode, ref: null })).toBe(true);
|
||||
expect(await t.documentExists(s, { factorCode: 'x', ref: null })).toBe(false);
|
||||
|
||||
http.mockReturnValueOnce(reply(404, { ERROR: 'سند یافت نشد.' }));
|
||||
expect((await t.deleteDocument(s, { factorCode: 'x', ref: null, customerCode: '2' })).response).toEqual({ alreadyDeleted: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('items', () => {
|
||||
it('skips item names that lost their encoding and reads stock per name', async () => {
|
||||
http.mockReturnValueOnce(reply(200, [{ Name: 'انگشتر', Ayar: 750 }, { Name: '???????0.400????' }]));
|
||||
const items = await t.listKarSakhte(s);
|
||||
expect(items.map((i) => i.name)).toEqual(['انگشتر']);
|
||||
|
||||
http.mockReturnValueOnce(reply(200, { Name: 'انگشتر', VaznMojood: 12.5, TedadMojood: 3, MaxAyar: 750 }));
|
||||
const stock = await t.getKarSakhteStock(s, { code: 'انگشتر', name: 'انگشتر' });
|
||||
expect(sent(1)).toEqual({ GetMojoodiKarSakhte: ['انگشتر', 0] });
|
||||
expect(stock.weight?.toString()).toBe('12.5');
|
||||
expect(stock.count?.toString()).toBe('3');
|
||||
});
|
||||
|
||||
it('cannot create items through the API', async () => {
|
||||
await expect(t.createKarSakhte(s, { name: 'x', groupId: null, typeId: null })).rejects.toThrow('وجود ندارد');
|
||||
});
|
||||
});
|
||||
|
||||
describe('change feed (real-time sync)', () => {
|
||||
const row = (ID: number, MCode: number, extra: Record<string, unknown> = {}) => ({
|
||||
ID,
|
||||
MCode,
|
||||
NO: 'فروش طلا',
|
||||
No_Kar: 'null',
|
||||
ZamanEdit: null,
|
||||
...extra,
|
||||
});
|
||||
const feed = (rows: ReturnType<typeof row>[]) => reply(200, Object.fromEntries(rows.map((r) => [String(r.ID), r])));
|
||||
|
||||
it('the first poll only returns the starting cursor', async () => {
|
||||
http.mockReturnValueOnce(feed([row(10, 1), row(11, 2)]));
|
||||
expect(await t.getChanges(s, null)).toEqual({ cursor: '11|', accountCodes: [], itemNames: [], overflow: false });
|
||||
expect(sent()).toEqual({ DoListUpdatedAsnad: [200] });
|
||||
});
|
||||
|
||||
it('later polls report the accounts and کارساخته of new documents only', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
feed([row(11, 2), row(12, 7), row(13, 7), row(14, 9, { NO: 'خروج کارساخته', No_Kar: 'انگشتر' })]),
|
||||
);
|
||||
const r = await t.getChanges(s, '11|');
|
||||
expect(r.accountCodes.sort()).toEqual(['7', '9']);
|
||||
expect(r.itemNames).toEqual(['انگشتر']);
|
||||
expect(r.cursor).toBe('14|');
|
||||
expect(r.overflow).toBe(false);
|
||||
});
|
||||
|
||||
it('sees an edit of an old document through ZamanEdit', async () => {
|
||||
http.mockReturnValueOnce(feed([row(11, 2, { ZamanEdit: '1405/07/12 18:00:00' }), row(12, 3)]));
|
||||
const r = await t.getChanges(s, '12|1405/07/12 17:00:00');
|
||||
expect(r.accountCodes).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('says overflow when every row of the window is new', async () => {
|
||||
const rows = Array.from({ length: 200 }, (_, i) => row(100 + i, i));
|
||||
http.mockReturnValueOnce(feed(rows));
|
||||
expect((await t.getChanges(s, '50|')).overflow).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing when nothing changed', async () => {
|
||||
http.mockReturnValueOnce(feed([row(11, 2), row(12, 3)]));
|
||||
expect(await t.getChanges(s, '12|')).toMatchObject({ accountCodes: [], itemNames: [], overflow: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||||
import { formatJalali, toJalali } from '../jalali';
|
||||
import {
|
||||
AccountingNotSupportedError,
|
||||
AccountingProvider,
|
||||
AccountingProviderError,
|
||||
ExternalCustomer,
|
||||
ProviderSettings,
|
||||
StatementRow,
|
||||
ZERO,
|
||||
dec,
|
||||
normalizeMobile,
|
||||
str,
|
||||
} from './accounting-provider';
|
||||
|
||||
/**
|
||||
* Tahesab (تهحساب) — https://tahesab.ir/APiDoc/ (OpenAPI 3.1, API 10.1405.x).
|
||||
*
|
||||
* Wire format, confirmed against the public test server on 2026-10-05:
|
||||
* POST <base>/ body {"<Method>": [args by position]}
|
||||
* headers Authorization: Bearer <token>, DBname: TahesabDB (SQL Server) | DB (Access)
|
||||
* The server dispatches on the body key; the path in the docs is cosmetic.
|
||||
* Errors come back as {"ERROR": "..."} with HTTP 400/404.
|
||||
*
|
||||
* Behaviour measured on the test server (plan §9), which this driver relies on:
|
||||
* * Balance sign: an exit (خروج) LOWERS MandeyeVazni — negative = the
|
||||
* customer owes, as henza_server's `* -1` assumed. Flipped here.
|
||||
* * A temporary (موقت) document does not move the balance until the
|
||||
* accountant finalises it.
|
||||
* * The gold a document moves is Vazn × Ayar / 750. Darsad_Talayee and
|
||||
* Ojrat are stored but do NOT move the balance, so the making charge is sent
|
||||
* as a raised Ayar (750 at 10% → 825); Vazn stays the physical weight.
|
||||
* * PoolSang does not move the rial balance; MablaghKol does.
|
||||
* * Factor_Code is stored lowercased; re-sending a row with the same
|
||||
* Factor_Code + Radif_Number is rejected as a duplicate key — which is what
|
||||
* makes a retry safe.
|
||||
*/
|
||||
|
||||
const CUSTOMER_PAGE = 500;
|
||||
/** Consecutive empty code windows before the account list is considered done. */
|
||||
const EMPTY_WINDOWS_TO_STOP = 4;
|
||||
const MAX_CUSTOMER_CODE = 2_000_000;
|
||||
const BALANCE_CHUNK = 100;
|
||||
const RECENT_DOCUMENTS = 1000;
|
||||
const CHANGE_WINDOW = 200;
|
||||
|
||||
function baseUrl(s: ProviderSettings) {
|
||||
return s.baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function headers(s: ProviderSettings) {
|
||||
return {
|
||||
Authorization: `Bearer ${s.token}`,
|
||||
DBname: s.dbName || 'TahesabDB',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFoundMessage(msg: string) {
|
||||
return /یافت نشد|Not_Found|not found/i.test(msg);
|
||||
}
|
||||
|
||||
async function call(s: ProviderSettings, method: string, args: unknown[]): Promise<unknown> {
|
||||
let res;
|
||||
try {
|
||||
res = await httpRequest({
|
||||
method: 'POST',
|
||||
url: `${baseUrl(s)}/`,
|
||||
headers: headers(s),
|
||||
body: JSON.stringify({ [method]: args }),
|
||||
timeoutMs: s.timeoutMs,
|
||||
ca: s.caCert,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) {
|
||||
throw new AccountingProviderError(`تهحساب (${method}): ${err.message}`, err.ambiguous);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = parseLooseJson(res.body);
|
||||
} catch {
|
||||
throw new AccountingProviderError(
|
||||
`تهحساب (${method}): پاسخ نامعتبر (HTTP ${res.status})`,
|
||||
res.status >= 200 && res.status < 300,
|
||||
res.body.slice(0, 500),
|
||||
);
|
||||
}
|
||||
|
||||
const error = json && typeof json === 'object' && !Array.isArray(json) ? (json as { ERROR?: unknown }).ERROR : undefined;
|
||||
if (error !== undefined || res.status < 200 || res.status >= 300) {
|
||||
const msg = typeof error === 'string' ? error : `HTTP ${res.status}`;
|
||||
throw new AccountingProviderError(
|
||||
`تهحساب (${method}): ${msg.split('\r\n')[0]}`,
|
||||
false,
|
||||
trimDiagnostics(json),
|
||||
res.status === 404 || isNotFoundMessage(msg),
|
||||
);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Tahesab error bodies carry ~30 diagnostic fields; keep only the message. */
|
||||
function trimDiagnostics(json: unknown) {
|
||||
if (json && typeof json === 'object' && 'ERROR' in (json as object)) {
|
||||
return { ERROR: (json as { ERROR: unknown }).ERROR };
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** A method that answers "nothing found" with an ERROR, as an empty result. */
|
||||
async function callList(s: ProviderSettings, method: string, args: unknown[]) {
|
||||
try {
|
||||
return await call(s, method, args);
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tahesab keys row objects by id: {"1": {...}, "2": {...}}. */
|
||||
function rowsOf(json: unknown): Record<string, unknown>[] {
|
||||
if (!json) return [];
|
||||
if (Array.isArray(json)) return json as Record<string, unknown>[];
|
||||
if (typeof json === 'object') {
|
||||
return Object.values(json as Record<string, unknown>).filter(
|
||||
(v): v is Record<string, unknown> => !!v && typeof v === 'object',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toCustomer(r: Record<string, unknown>): ExternalCustomer {
|
||||
return {
|
||||
code: String(r.Code),
|
||||
name: str(r.Name) ?? '',
|
||||
mobile: normalizeMobile(r.Mobile),
|
||||
phone: null,
|
||||
nationalCode: str(r.CodeMelli),
|
||||
address: str(r.Address),
|
||||
groupId: str(r.GID),
|
||||
groupName: str(r.GoroupName),
|
||||
};
|
||||
}
|
||||
|
||||
const neg = (d: Prisma.Decimal | null) => (d ? d.negated() : null);
|
||||
|
||||
const METALS: Record<string, string> = { '0': 'طلا', '1': 'پلاتین', '2': 'نقره' };
|
||||
|
||||
/**
|
||||
* Rounds the raised karat to 3 decimals, which is what the desktop app keeps.
|
||||
* 750 × (1 + 10/100) = 825.
|
||||
*/
|
||||
export function karatWithWage(karat: number, wagePercent: Prisma.Decimal) {
|
||||
return new Prisma.Decimal(karat).mul(new Prisma.Decimal(1).add(wagePercent.div(100))).toDecimalPlaces(3);
|
||||
}
|
||||
|
||||
export const tahesabProvider: AccountingProvider = {
|
||||
name: 'tahesab',
|
||||
label: 'تهحساب',
|
||||
capabilities: { createKarSakhte: false, documentInquiry: true, movementScope: 'recent-documents', changeFeed: true },
|
||||
|
||||
async health(s) {
|
||||
const path = baseUrl(s).startsWith('https:') ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL';
|
||||
let info: Record<string, unknown> = {};
|
||||
try {
|
||||
const res = await httpRequest({
|
||||
method: 'GET',
|
||||
url: `${baseUrl(s)}/${path}`,
|
||||
headers: headers(s),
|
||||
timeoutMs: s.timeoutMs,
|
||||
ca: s.caCert,
|
||||
});
|
||||
const json = parseLooseJson(res.body) as Record<string, unknown>;
|
||||
info = {
|
||||
status: json.Status,
|
||||
api: json.Api_Status,
|
||||
db: json.DB_Status,
|
||||
version: json.TahesabVersion,
|
||||
dbType: json.DBType,
|
||||
dbName: json.DBName,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) throw new AccountingProviderError(`تهحساب: ${err.message}`);
|
||||
throw new AccountingProviderError('تهحساب: پاسخ CheckHealth نامعتبر است');
|
||||
}
|
||||
// CheckHealth ignores the token and DBname; a real call proves both.
|
||||
const groups = rowsOf(await call(s, 'DoListGorooh', []));
|
||||
return { ...info, customerGroups: groups.length };
|
||||
},
|
||||
|
||||
async listCustomerGroups(s) {
|
||||
const groups = rowsOf(await call(s, 'DoListGorooh', [])).map((g) => ({
|
||||
id: String(g.GID),
|
||||
name: str(g.Name) ?? String(g.GID),
|
||||
}));
|
||||
// Accounts outside every defined group carry GID 0 (seen on the test
|
||||
// server as «مشتري»); offer it so it can be picked for import.
|
||||
if (!groups.some((g) => g.id === '0')) groups.push({ id: '0', name: 'بدون گروه (GID 0)' });
|
||||
return groups;
|
||||
},
|
||||
|
||||
async listCustomers(s, cursor) {
|
||||
let [start, empty] = (cursor ?? '1:0').split(':').map(Number);
|
||||
if (!Number.isFinite(start) || start < 1) start = 1;
|
||||
if (!Number.isFinite(empty)) empty = 0;
|
||||
|
||||
const json = await callList(s, 'Dolistmoshtari', [start, start + CUSTOMER_PAGE - 1]);
|
||||
const items = rowsOf(json).map(toCustomer);
|
||||
empty = items.length === 0 ? empty + 1 : 0;
|
||||
const nextStart = start + CUSTOMER_PAGE;
|
||||
const done = empty >= EMPTY_WINDOWS_TO_STOP || nextStart > MAX_CUSTOMER_CODE;
|
||||
return { items, next: done ? null : `${nextStart}:${empty}` };
|
||||
},
|
||||
|
||||
async findCustomersByMobile(s, mobile) {
|
||||
const target = normalizeMobile(mobile);
|
||||
if (!target) return [];
|
||||
// The account's Mobile is free text: some rows hold 9121234567, others
|
||||
// 09121234567, and the lookup matches the stored form. Ask both ways.
|
||||
const found = new Map<string, ExternalCustomer>();
|
||||
for (const form of [Number(target.slice(1)), target]) {
|
||||
for (const r of rowsOf(await callList(s, 'DoListMoshtari', [form]))) {
|
||||
const c = toCustomer(r);
|
||||
if (c.mobile === target) found.set(c.code, c);
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
},
|
||||
|
||||
async createCustomer(s, p) {
|
||||
const json = (await call(s, 'DoNewMoshtari', [
|
||||
p.fullName,
|
||||
p.group.name ?? '',
|
||||
p.mobile,
|
||||
p.address ?? '',
|
||||
p.nationalCode ?? '',
|
||||
'',
|
||||
'',
|
||||
'-1',
|
||||
-1, // Moshtari_Code -1: Tahesab assigns the code
|
||||
0, // Jens_Felez: gold
|
||||
])) as { OK?: unknown };
|
||||
if (json?.OK === undefined || json.OK === null) {
|
||||
throw new AccountingProviderError('تهحساب: کد حساب جدید برنگشت', false, json);
|
||||
}
|
||||
return { code: String(json.OK), response: json };
|
||||
},
|
||||
|
||||
async updateCustomer(s, code, p) {
|
||||
// "-1" leaves a field unchanged (DoEditMoshtari example in the docs).
|
||||
const json = await call(s, 'DoEditMoshtari', [
|
||||
p.fullName,
|
||||
p.group.name ?? '-1',
|
||||
p.mobile,
|
||||
p.address ?? '-1',
|
||||
p.nationalCode ?? '-1',
|
||||
'-1',
|
||||
'-1',
|
||||
'-1',
|
||||
Number(code),
|
||||
]);
|
||||
return { response: json };
|
||||
},
|
||||
|
||||
async getBalances(s, codes) {
|
||||
const out = [];
|
||||
for (let i = 0; i < codes.length; i += BALANCE_CHUNK) {
|
||||
const chunk = codes.slice(i, i + BALANCE_CHUNK);
|
||||
// Unknown codes are simply left out of MandeHesab.
|
||||
const json = (await callList(s, 'getmandehesabbycode', chunk)) as { MandeHesab?: unknown } | null;
|
||||
for (const r of rowsOf(json?.MandeHesab)) {
|
||||
out.push({
|
||||
code: String(r.Code),
|
||||
goldGrams: (dec(r.MandeyeVazni) ?? ZERO).negated(),
|
||||
rial: (dec(r.MandeyeMali) ?? ZERO).negated(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getChanges(s, cursor) {
|
||||
// DoListUpdatedAsnad lists the newest documents by registration/edit time.
|
||||
// Cursor "maxId|latestEdit": a row is news when its ID is higher, or it
|
||||
// carries a later ZamanEdit. Nothing older than the window is visible, so
|
||||
// a window with no already-seen row means "too many changes: refresh all".
|
||||
const rows = rowsOf(await callList(s, 'DoListUpdatedAsnad', [CHANGE_WINDOW]));
|
||||
let maxId = 0;
|
||||
let latestEdit = '';
|
||||
for (const r of rows) {
|
||||
maxId = Math.max(maxId, Number(r.ID) || 0);
|
||||
const edited = String(r.ZamanEdit ?? '');
|
||||
if (edited && edited !== 'null' && edited > latestEdit) latestEdit = edited;
|
||||
}
|
||||
const next = `${maxId}|${latestEdit}`;
|
||||
if (cursor === null) return { cursor: next, accountCodes: [], itemNames: [], overflow: false };
|
||||
|
||||
const [seenId, seenEdit] = [Number(cursor.split('|')[0]) || 0, cursor.split('|')[1] ?? ''];
|
||||
const fresh = rows.filter((r) => {
|
||||
const edited = String(r.ZamanEdit ?? '');
|
||||
return (Number(r.ID) || 0) > seenId || (!!edited && edited !== 'null' && edited > seenEdit);
|
||||
});
|
||||
const accounts = new Set<string>();
|
||||
const items = new Set<string>();
|
||||
for (const r of fresh) {
|
||||
const code = str(r.MCode);
|
||||
if (code) accounts.add(code);
|
||||
const kar = str(r.No_Kar);
|
||||
if (kar && /کار/.test(String(r.NO ?? ''))) items.add(kar);
|
||||
}
|
||||
return {
|
||||
cursor: next,
|
||||
accountCodes: [...accounts],
|
||||
itemNames: [...items],
|
||||
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getStatement(s, q) {
|
||||
// Count_Last = -1 asks for the running balance on every row.
|
||||
const json = await callList(s, 'DoListAsnad', [
|
||||
-1,
|
||||
Number(q.code),
|
||||
formatJalali(q.from),
|
||||
formatJalali(q.to),
|
||||
'',
|
||||
0,
|
||||
'IRR',
|
||||
]);
|
||||
return rowsOf(json).map((r) => toStatementRow(r));
|
||||
},
|
||||
|
||||
async listKarSakhteGroups() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async listKarSakhteTypes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async listKarSakhte(s) {
|
||||
// Only items that already have a document appear here (Tahesab has no
|
||||
// item master in its API). Names that arrive as "????" lost their
|
||||
// encoding upstream and cannot be matched back; they are skipped.
|
||||
return rowsOf(await callList(s, 'DoListNameKarsakhte', []))
|
||||
.map((r) => str(r.Name))
|
||||
.filter((name): name is string => !!name && !name.includes('??'))
|
||||
.map((name) => ({
|
||||
code: name,
|
||||
name,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
typeId: null,
|
||||
typeName: null,
|
||||
saleWagePercent: null,
|
||||
stockWeight: null,
|
||||
stockCount: null,
|
||||
}));
|
||||
},
|
||||
|
||||
async getKarSakhteStock(s, item) {
|
||||
const json = (await callList(s, 'GetMojoodiKarSakhte', [item.name, 0])) as Record<string, unknown> | null;
|
||||
return { weight: dec(json?.VaznMojood), count: dec(json?.TedadMojood) };
|
||||
},
|
||||
|
||||
async getKarSakhteMovement(s, from, to) {
|
||||
// Tahesab cannot list documents across all accounts by date; the closest
|
||||
// is the most recent N documents, filtered here.
|
||||
const lo = formatJalali(from, '/');
|
||||
const hi = formatJalali(to, '/');
|
||||
return rowsOf(await callList(s, 'DoListUpdatedAsnad', [RECENT_DOCUMENTS]))
|
||||
.filter((r) => /کار/.test(String(r.NO ?? '')))
|
||||
.filter((r) => {
|
||||
const d = String(r.Tarikh ?? '');
|
||||
return d >= lo && d <= hi;
|
||||
})
|
||||
.map((r) => ({
|
||||
itemCode: str(r.No_Kar),
|
||||
itemName: str(r.No_Kar) ?? '—',
|
||||
date: String(r.Tarikh ?? ''),
|
||||
description: [str(r.NO), str(r.Sharh1)].filter(Boolean).join(' — ') || null,
|
||||
weight: dec(r.Vazn),
|
||||
amount: dec(r.Mali),
|
||||
count: dec(r.Count),
|
||||
}));
|
||||
},
|
||||
|
||||
async createKarSakhte() {
|
||||
throw new AccountingNotSupportedError('ساخت کارساخته');
|
||||
},
|
||||
|
||||
async postOrderDocument(s, doc, progress, onProgress) {
|
||||
const d = toJalali(doc.date);
|
||||
const multi = doc.lines.length > 1 ? 1 : -1;
|
||||
let done = progress.doneLines ?? 0;
|
||||
let last: unknown = null;
|
||||
|
||||
for (let i = done; i < doc.lines.length; i += 1) {
|
||||
const line = doc.lines[i];
|
||||
const args = [
|
||||
doc.temporary ? 0 : 1, // Sabte_Kol_Or_Movaghat: 0 = temporary
|
||||
Number(doc.customerCode),
|
||||
-1, // Factor_Number: assigned by Tahesab
|
||||
i + 1, // Radif_Number
|
||||
d.jy,
|
||||
d.jm,
|
||||
d.jd,
|
||||
Number(line.weightGrams.toFixed(3)),
|
||||
Number(karatWithWage(line.karat, line.wagePercent).toString()),
|
||||
line.count,
|
||||
line.itemName,
|
||||
1, // IsVoroodOrKhorooj: 1 = exit (goods to the customer)
|
||||
1, // OjratTedadiOrGerami: per gram
|
||||
Number(line.flatRial.toFixed(0)), // MablaghKol — the only rial field that moves the balance
|
||||
0,
|
||||
0,
|
||||
0, // Darsad_Talayee: the charge is already in Ayar; never count it twice
|
||||
0,
|
||||
'',
|
||||
0,
|
||||
0, // PoolSang: does not move the balance; the stones are in MablaghKol
|
||||
line.description,
|
||||
doc.factorCode,
|
||||
-1,
|
||||
multi,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
try {
|
||||
last = await call(s, 'DoNewSanadVKHKar', args);
|
||||
} catch (err) {
|
||||
// The row is already there (an earlier attempt landed but its reply
|
||||
// was lost): Factor_Code + Radif_Number is a unique key in Tahesab.
|
||||
if (!(err instanceof AccountingProviderError && /duplicate key/i.test(err.message + JSON.stringify(err.response ?? '')))) {
|
||||
throw err;
|
||||
}
|
||||
last = { duplicate: true };
|
||||
}
|
||||
done = i + 1;
|
||||
await onProgress({ doneLines: done });
|
||||
}
|
||||
|
||||
const ref = String((last as { Factor_Code?: unknown })?.Factor_Code ?? doc.factorCode).toLowerCase();
|
||||
return { ref, progress: { doneLines: done }, response: last };
|
||||
},
|
||||
|
||||
async documentExists(s, doc) {
|
||||
try {
|
||||
await call(s, 'DoNewSanadInquiry', [doc.factorCode]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return false;
|
||||
// A multi-row document answers {"ERROR":"Duplicate"}: found, more than
|
||||
// one row under the code (seen on the test server).
|
||||
if (err instanceof AccountingProviderError && /:\s*Duplicate$/i.test(err.message)) return true;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDocument(s, doc) {
|
||||
try {
|
||||
return { response: await call(s, 'DoDeleteSanad', [doc.factorCode]) };
|
||||
} catch (err) {
|
||||
// Already gone — the goal is reached.
|
||||
if (err instanceof AccountingProviderError && err.notFound) return { response: { alreadyDeleted: true } };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function toStatementRow(r: Record<string, unknown>): StatementRow {
|
||||
const metal = String(r.Jens_Felez ?? '0');
|
||||
const temporary = r.isSanadMovaghat === true;
|
||||
const isGold = metal === '0';
|
||||
const isRial = !r.Arz || String(r.Arz).toUpperCase() === 'IRR';
|
||||
// Positive TabdilVazn / Mali = in the customer's favour (they gave us gold
|
||||
// or money); negative = they took goods or money from us.
|
||||
const tabdil = isGold ? dec(r.TabdilVazn) ?? ZERO : ZERO;
|
||||
const mali = isRial ? dec(r.Mali) ?? ZERO : ZERO;
|
||||
return {
|
||||
key: `${r.ID}-${r.Radif ?? 1}`,
|
||||
documentRef: String(r.Factor_Code ?? r.ID).toLowerCase(),
|
||||
accountCode: String(r.MCode ?? ''),
|
||||
documentNo: String(r.Sh_Factor ?? ''),
|
||||
date: String(r.Tarikh ?? ''),
|
||||
time: str(r.Tarikh_Time),
|
||||
title: str(r.NO) ?? '',
|
||||
description: [str(r.Sharh1), str(r.Sharh2), str(r.Baabat)].filter(Boolean).join(' — ') || null,
|
||||
itemName: str(r.No_Kar),
|
||||
weight: dec(r.Vazn),
|
||||
karat: dec(r.Ayar)?.toNumber() ?? null,
|
||||
count: dec(r.Count),
|
||||
unitPrice: dec(r.Fi) ?? dec(r.Mazaneh),
|
||||
wagePercent: dec(r.DarsadSood),
|
||||
goldIn: tabdil.gt(0) ? tabdil : ZERO,
|
||||
goldOut: tabdil.lt(0) ? tabdil.negated() : ZERO,
|
||||
rialIn: mali.gt(0) ? mali : ZERO,
|
||||
rialOut: mali.lt(0) ? mali.negated() : ZERO,
|
||||
// A temporary row reports TahesabVazni/Mali = 0 because it moves nothing
|
||||
// yet; null lets the report carry the previous balance forward instead.
|
||||
goldBalance: temporary ? null : neg(dec(r.TahesabVazni)),
|
||||
rialBalance: temporary ? null : neg(dec(r.TahesabMali)),
|
||||
metal: METALS[metal] ?? metal,
|
||||
temporary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { httpRequest } from '../accounting-http';
|
||||
import { OrderDocumentInput, ProviderSettings } from './accounting-provider';
|
||||
import { __zarrinInternals, zarrinProvider as z } from './zarrin.provider';
|
||||
|
||||
jest.mock('../accounting-http', () => ({
|
||||
...jest.requireActual('../accounting-http'),
|
||||
httpRequest: jest.fn(),
|
||||
}));
|
||||
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
|
||||
|
||||
const s: ProviderSettings = {
|
||||
baseUrl: 'http://zarrin.local:8000/ZarrinService/accounting',
|
||||
token: 'SECRET',
|
||||
caCert: null,
|
||||
timeoutMs: 5000,
|
||||
balanceOwesWhen: 'negative',
|
||||
};
|
||||
|
||||
const reply = (body: unknown, status = 200) =>
|
||||
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
||||
|
||||
const url = (n = 0) => new URL(http.mock.calls[n][0].url);
|
||||
|
||||
beforeEach(() => http.mockReset());
|
||||
|
||||
describe('wire format', () => {
|
||||
it('sends GET ?kind=…&token=… with the parameters, and nothing blank', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '4', name: 'مشتری (خریدار)' }] }));
|
||||
const groups = await z.listCustomerGroups(s);
|
||||
const u = url();
|
||||
expect(http.mock.calls[0][0].method).toBe('GET');
|
||||
expect(u.pathname).toBe('/ZarrinService/accounting');
|
||||
expect(u.searchParams.get('kind')).toBe('listGorooheHesabha');
|
||||
expect(u.searchParams.get('token')).toBe('SECRET');
|
||||
expect(groups).toEqual([{ id: '4', name: 'مشتری (خریدار)' }]);
|
||||
});
|
||||
|
||||
it('unwraps the JSON-in-a-string replies the documentation shows', async () => {
|
||||
const inner = JSON.stringify({ state: true, msg: 'حساب با موفقیت ایجاد شد', id: 132 });
|
||||
http.mockReturnValueOnce(reply(JSON.stringify(inner)));
|
||||
const res = await z.createCustomer(s, {
|
||||
firstName: 'تست',
|
||||
lastName: 'تست',
|
||||
fullName: 'تست تست',
|
||||
mobile: '09121112233',
|
||||
phone: null,
|
||||
address: null,
|
||||
nationalCode: null,
|
||||
group: { id: '4', name: 'مشتری' },
|
||||
});
|
||||
expect(res.code).toBe('132');
|
||||
expect(url().searchParams.get('fkNoe')).toBe('4');
|
||||
expect(url().searchParams.has('telephone')).toBe(false);
|
||||
});
|
||||
|
||||
it('turns state:false into an error carrying the message', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: false, msg: 'دسترسی مجاز نیست' }));
|
||||
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
|
||||
});
|
||||
|
||||
it('shows the server reason on a non-2xx reply, without the token', async () => {
|
||||
http.mockReturnValueOnce(reply('<html><body><h1>Server Error</h1><p>kind=listGorooheHesabha&token=SECRET failed</p></body></html>', 500));
|
||||
const err = await z.listCustomerGroups(s).catch((e) => e);
|
||||
expect(err.message).toContain('HTTP 500 — Server Error');
|
||||
expect(err.message).toContain('failed');
|
||||
expect(err.message).not.toContain('SECRET');
|
||||
|
||||
http.mockReturnValueOnce(reply({ state: false, msg: 'شناسه نامعتبر' }, 400));
|
||||
await expect(z.listCustomerGroups(s)).rejects.toThrow('HTTP 400 — شناسه نامعتبر');
|
||||
});
|
||||
|
||||
it('lists the different envelopes Zarrin uses', () => {
|
||||
const { listOf, fullJalali } = __zarrinInternals;
|
||||
expect(listOf({ data: [1] })).toEqual([1]);
|
||||
expect(listOf({ date: [2] })).toEqual([2]); // Get100Account's typo
|
||||
expect(listOf([3])).toEqual([3]);
|
||||
expect(listOf({ id: '84', nam: 'x' })).toEqual([{ id: '84', nam: 'x' }]);
|
||||
expect(fullJalali('04/05/29')).toBe('1404/05/29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customers', () => {
|
||||
it('lists every account with its group from one listhesabha call', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
data: [
|
||||
{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' },
|
||||
{ id: '82', nam: 'آرین یوسفی', gorooh: 'سازنده (تامین کننده)', tell: '', mobile: '', codeMeli: '' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const res = await z.listCustomers(s, null);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
expect(url(0).searchParams.get('kind')).toBe('listhesabha');
|
||||
expect(url(0).searchParams.get('az')).toBe('1');
|
||||
expect(res.items).toHaveLength(2);
|
||||
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
|
||||
expect(res.items[1].mobile).toBeNull();
|
||||
expect(res.next).toBeNull();
|
||||
});
|
||||
|
||||
it('finds by mobile with searchAccount, keeping exact matches only', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
state: true,
|
||||
msg: '',
|
||||
date: [
|
||||
{ code: 84, name: 'سعید', namKhanevadegi: 'رجایی', mobile: '09121995971' },
|
||||
{ code: 90, name: 'x', namKhanevadegi: 'y', mobile: '09121995972' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const found = await z.findCustomersByMobile(s, '09121995971');
|
||||
expect(url(0).searchParams.get('kind')).toBe('searchAccount');
|
||||
expect(found.map((c) => c.code)).toEqual(['84']);
|
||||
expect(found[0].name).toBe('سعید رجایی');
|
||||
});
|
||||
|
||||
it('falls back to listhesabha when searchAccount is missing (bare 400)', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(reply('', 400))
|
||||
.mockReturnValueOnce(reply({ data: [{ id: '84', nam: 'رجایی * سعید', gorooh: 'مشتری (خریدار)', mobile: '09121995971' }, { id: '85', nam: 'z', mobile: '' }] }));
|
||||
const found = await z.findCustomersByMobile(s, '09121995971');
|
||||
expect(url(1).searchParams.get('kind')).toBe('listhesabha');
|
||||
expect(found.map((c) => c.code)).toEqual(['84']);
|
||||
});
|
||||
|
||||
it('does not hide other errors behind the fallback', async () => {
|
||||
http.mockReturnValueOnce(reply('', 500));
|
||||
await expect(z.findCustomersByMobile(s, '09121995971')).rejects.toThrow('HTTP 500');
|
||||
});
|
||||
|
||||
it('lists karsakhte groups with goroohSakhte when the installation has it', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }));
|
||||
expect(await z.listKarSakhteGroups!(s)).toEqual([{ id: '26', name: 'النگو' }]);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('scans karsakhte groups with infogorooh when goroohSakhte is missing, skipping ids that fail', async () => {
|
||||
http.mockImplementation((req) => {
|
||||
const u = new URL(req.url);
|
||||
if (u.searchParams.get('kind') === 'goroohSakhte') return reply('', 400);
|
||||
const id = u.searchParams.get('id');
|
||||
if (id === '26') return reply({ data: [{ nam: 'النگو', code: 26 }] });
|
||||
if (id === '115') return reply({ data: [{ nam: 'سکه', code: 115 }] });
|
||||
if (id === '3') return reply('', 500);
|
||||
return reply('{\n "data": \n ]\n}');
|
||||
});
|
||||
expect(await z.listKarSakhteGroups!(s)).toEqual([
|
||||
{ id: '26', name: 'النگو' },
|
||||
{ id: '115', name: 'سکه' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the account in its group on edit', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'ویرایش اطلاعات با موفقیت انجام شد' }));
|
||||
await z.updateCustomer(s, '101', {
|
||||
firstName: 'حسین',
|
||||
lastName: 'اصغری',
|
||||
fullName: 'حسین اصغری',
|
||||
mobile: '09121427233',
|
||||
phone: null,
|
||||
address: 'خیابان مولوی',
|
||||
nationalCode: null,
|
||||
group: { id: null, name: 'واسطه' },
|
||||
});
|
||||
expect(url().searchParams.get('nameG')).toBe('واسطه');
|
||||
expect(url().searchParams.get('id')).toBe('101');
|
||||
});
|
||||
});
|
||||
|
||||
describe('balances and statement', () => {
|
||||
it('applies the configured sign', async () => {
|
||||
http.mockReturnValue(reply({ state: true, msg: 'OK', data: [{ id: '108', name: 'x', geram: '-75.409', pool: '-3.211960271E9' }, { id: '9', geram: '1', pool: '1' }] }));
|
||||
const neg = await z.getBalances(s, ['108', '5']);
|
||||
expect(neg).toHaveLength(1);
|
||||
expect(neg[0].goldGrams.toString()).toBe('75.409');
|
||||
expect(neg[0].rial.toString()).toBe('3211960271');
|
||||
const pos = await z.getBalances({ ...s, balanceOwesWhen: 'positive' }, ['108', '9']);
|
||||
expect(pos[0].goldGrams.toString()).toBe('-75.409');
|
||||
});
|
||||
|
||||
it('anchors the running balance on the real balance; «مانده از قبل» is not a movement', async () => {
|
||||
// 1) the range, opening row first (seen on v228: its figures are not a balance)
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
state: true,
|
||||
detail: 'OK',
|
||||
data: [
|
||||
{ idRadif: '0', idFactor: '0', idHesab: '81', sharh: 'مانده از قبل', vaznTarazooPardakht: '10.14', vaznTarazooDaryaft: '10.24', poolPardakht: '5', poolDaryaft: '5' },
|
||||
{ idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', noeFactor: 'سند دریافت', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' },
|
||||
{ idRadif: '471', idFactor: '222', idHesab: '81', noeRadif: 'کار ساخته', noeFactor: 'فاکتور موقت', tarikh: '04/05/30', vaznTarazooPardakht: '1.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0', ayar: '750' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// 2) the account's balance now: −9.5 g in Zarrin = owes 9.5
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '81', geram: '-9.5', pool: '-810000000' }] }));
|
||||
// 3) what happened after the range
|
||||
http.mockReturnValueOnce(
|
||||
reply({ state: true, detail: 'OK', data: [{ idRadif: '0', sharh: 'مانده از قبل' }, { idRadif: '500', idHesab: '81', vaznTarazooPardakht: '1.0', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' }] }),
|
||||
);
|
||||
|
||||
const rows = await z.getStatement(s, { code: '81', from: { jy: 1404, jm: 5, jd: 1 }, to: { jy: 1404, jm: 5, jd: 31 } });
|
||||
expect(url().searchParams.get('dateAz')).toBe('2025-07-23');
|
||||
expect(url(1).searchParams.get('kind')).toBe('listMandeHesabha');
|
||||
expect(url(2).searchParams.get('dateAz')).toBe('2025-08-23'); // the day after `to`
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک', temporary: false });
|
||||
// closing(to) = 9.5 − 1.0 after = 8.5; opening = 8.5 − (−2.5 + 1.0) = 10
|
||||
expect(rows[0].goldBalance?.toString()).toBe('7.5');
|
||||
expect(rows[0].rialBalance?.toString()).toBe('810000000');
|
||||
expect(rows[1]).toMatchObject({ temporary: true });
|
||||
expect(rows[1].goldBalance?.toString()).toBe('8.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('order documents', () => {
|
||||
const line = (code: string, wage: number) => ({
|
||||
itemCode: code,
|
||||
itemName: code,
|
||||
weightGrams: new Prisma.Decimal('10'),
|
||||
count: 1,
|
||||
wagePercent: new Prisma.Decimal(wage),
|
||||
flatRial: new Prisma.Decimal(0),
|
||||
karat: 750,
|
||||
description: 'd',
|
||||
});
|
||||
const doc: OrderDocumentInput = {
|
||||
factorCode: 'gbordx00000000000000000',
|
||||
customerCode: '81',
|
||||
date: new Date(),
|
||||
description: 'سفارش',
|
||||
temporary: true,
|
||||
lines: [line('19', 12), line('20', 8)],
|
||||
};
|
||||
|
||||
it('opens the invoice with the account, appends with the invoice id, in bonak mode', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } })).mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } }));
|
||||
const progress: unknown[] = [];
|
||||
const res = await z.postOrderDocument(s, doc, {}, async (p) => {
|
||||
progress.push(p);
|
||||
});
|
||||
const a = url(0).searchParams;
|
||||
const b = url(1).searchParams;
|
||||
expect(a.get('kind')).toBe('foroushSakhte');
|
||||
expect(a.get('idHesab')).toBe('81');
|
||||
expect(a.has('idFactor')).toBe(false);
|
||||
expect(a.get('idSakhte')).toBe('19');
|
||||
expect(a.get('darsad')).toBe('12');
|
||||
expect(a.get('vazn')).toBe('10.000');
|
||||
expect(a.get('pd')).toBe('-1');
|
||||
expect(a.get('bonak')).toBe('1');
|
||||
expect(a.get('movaghat')).toBe('1');
|
||||
expect(b.get('idFactor')).toBe('117764');
|
||||
expect(b.has('idHesab')).toBe(false);
|
||||
expect(b.get('movaghat')).toBe('1'); // temporary, as the owner ruled
|
||||
expect(progress).toEqual([
|
||||
{ factorId: '117764', doneLines: 1 },
|
||||
{ factorId: '117764', doneLines: 2 },
|
||||
]);
|
||||
expect(res.ref).toBe('117764');
|
||||
});
|
||||
|
||||
it('resumes into the same invoice', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, data: { idFactor: 5 } }));
|
||||
await z.postOrderDocument(s, doc, { factorId: '5', doneLines: 1 }, async () => undefined);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
expect(url().searchParams.get('idFactor')).toBe('5');
|
||||
expect(url().searchParams.get('idSakhte')).toBe('20');
|
||||
});
|
||||
|
||||
it('finds the row to delete in the ledger, since foroushSakhte never returned it', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '0', idFactor: '0' }, { idRadif: '731', idFactor: '390' }] }))
|
||||
.mockReturnValueOnce(reply({ state: true, msg: 'حذف با موفقیت انجام شد :)' }));
|
||||
await z.deleteDocument(s, { factorCode: 'x', ref: '390', customerCode: '81' });
|
||||
expect(url(1).searchParams.get('kind')).toBe('hazfRadifSanad');
|
||||
expect(url(1).searchParams.get('pkRadifSanad')).toBe('731');
|
||||
expect(url(1).searchParams.get('kol')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('change feed (real-time sync)', () => {
|
||||
it('reads the newest journal rows and reports accounts above the cursor', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }, { idRadif: '733', idHesab: '40' }, { idRadif: '734', idHesab: '40' }] }),
|
||||
);
|
||||
const r = await z.getChanges(s, '731');
|
||||
expect(url().searchParams.get('kind')).toBe('parLastTenRadif');
|
||||
expect(url().searchParams.has('idHesab')).toBe(false);
|
||||
expect(r).toMatchObject({ cursor: '734', accountCodes: ['40'], overflow: false });
|
||||
});
|
||||
|
||||
it('first poll only returns the cursor', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }] }));
|
||||
expect(await z.getChanges(s, null)).toEqual({ cursor: '731', accountCodes: [], itemNames: [], overflow: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('v228 quirks', () => {
|
||||
it('reads every page of listKarSakhte (100 per page)', async () => {
|
||||
const page = (from: number, n: number) => reply({ state: true, msg: 'ok', data: Array.from({ length: n }, (_, i) => ({ id: String(from + i), namFelezsakhthe: `کار ${from + i}`, fkNoe: '16', NoeName: 'گرمی' })) });
|
||||
http.mockReturnValueOnce(page(1, 100)).mockReturnValueOnce(page(101, 100)).mockReturnValueOnce(page(201, 7));
|
||||
const items = await z.listKarSakhte(s);
|
||||
expect(items).toHaveLength(207);
|
||||
expect(url(1).searchParams.get('pageNum')).toBe('2');
|
||||
});
|
||||
|
||||
it('stops when the server ignores pageNum and repeats page 1', async () => {
|
||||
const page1 = () => reply({ state: true, msg: 'ok', data: Array.from({ length: 100 }, (_, i) => ({ id: String(i + 1), namFelezsakhthe: 'x' })) });
|
||||
http.mockReturnValueOnce(page1()).mockReturnValueOnce(page1());
|
||||
expect(await z.listKarSakhte(s)).toHaveLength(100);
|
||||
expect(http).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts an HTTP 400 that carries a good reply (goroohSakhte on v228)', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }, 400));
|
||||
expect(await z.listKarSakhteGroups(s)).toEqual([{ id: '26', name: 'النگو' }]);
|
||||
});
|
||||
|
||||
it('repairs the hand-printed JSON of infogardeshsakhte', async () => {
|
||||
// Verbatim shape of a v228 reply.
|
||||
const body = [
|
||||
'{',
|
||||
' "data": [',
|
||||
' {',
|
||||
' "code_kar": 19,',
|
||||
' "nam_kar": دستبند آروشا,',
|
||||
' "tarikh_akharin_gardesh": 05/07/15,',
|
||||
' "sharh_akharin_gardesh": دستبند آروشا,',
|
||||
' "vazn_akharin_gardesh": -15.12,',
|
||||
' "mablagh_akharin_gardesh": 0.0,',
|
||||
' },',
|
||||
' ]',
|
||||
'}',
|
||||
].join('\n');
|
||||
http.mockReturnValueOnce(reply(body));
|
||||
const rows = await z.getKarSakhteMovement(s, { jy: 1405, jm: 7, jd: 1 }, { jy: 1405, jm: 7, jd: 20 });
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ itemCode: '19', itemName: 'دستبند آروشا', date: '1405/07/15', weight: new Prisma.Decimal('-15.12') }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the «*» listhesabha puts between first and last name', async () => {
|
||||
const { accountName } = __zarrinInternals;
|
||||
expect(accountName('آرین * یوسفی')).toBe('آرین یوسفی');
|
||||
expect(accountName(' * هزینه هدایا (پیامک گروهی)')).toBe('هزینه هدایا (پیامک گروهی)');
|
||||
expect(accountName(' * ')).toBeNull();
|
||||
http.mockReturnValueOnce(reply({ data: [{ id: '953', nam: 'تست اپ * گلدبانک', gorooh: 'متفرقه', mobile: '09990001111' }] }));
|
||||
const { items } = await z.listCustomers(s, null);
|
||||
expect(items[0]).toMatchObject({ code: '953', name: 'تست اپ گلدبانک', groupName: 'متفرقه', mobile: '09990001111' });
|
||||
});
|
||||
|
||||
it('sees a document posted with movaghat=1 as temporary (shomareSanad «موقت»)', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
state: true,
|
||||
data: [
|
||||
{ idRadif: '71962', idFactor: '16792', idHesab: '953', noeFactor: 'فاکتور فروش', shomareSanad: 'موقت', noeRadif: 'کار ساخته', vaznTarazooPardakht: '1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' },
|
||||
{ idRadif: '71710', idFactor: '16722', idHesab: '953', noeFactor: 'فاکتور موقت', shomareSanad: 'باز', vaznTarazooPardakht: '0.1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' },
|
||||
{ idRadif: '52961', idFactor: '12282', idHesab: '953', noeFactor: 'فاکتور فروش', shomareSanad: '12282', vaznTarazooPardakht: '1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
http.mockReturnValueOnce(reply({ state: true, data: [{ id: '953', geram: '-2.1', pool: '0' }] }));
|
||||
const today = toJalaliToday();
|
||||
const rows = await z.getStatement(s, { code: '953', from: today, to: today });
|
||||
expect(rows.map((r) => r.temporary)).toEqual([true, true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
function toJalaliToday() {
|
||||
// Today in Tehran, so the statement needs no "after the range" call.
|
||||
const { toJalali } = jest.requireActual('../jalali');
|
||||
return toJalali(new Date());
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||||
import { JalaliDate, jalaliToGregorian, jalaliToIsoDate, toIsoDateTehran, toJalali } from '../jalali';
|
||||
|
||||
const jalaliKey = (d: JalaliDate) => d.jy * 10000 + d.jm * 100 + d.jd;
|
||||
function nextDay(d: JalaliDate): JalaliDate {
|
||||
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
|
||||
return toJalali(new Date(Date.UTC(g.gy, g.gm - 1, g.gd + 1, 12)));
|
||||
}
|
||||
import {
|
||||
AccountingProvider,
|
||||
AccountingProviderError,
|
||||
ExternalCustomer,
|
||||
ProviderSettings,
|
||||
StatementRow,
|
||||
ZERO,
|
||||
dec,
|
||||
normalizeMobile,
|
||||
splitName,
|
||||
str,
|
||||
} from './accounting-provider';
|
||||
|
||||
/**
|
||||
* Zarrin (زرین) — https://zarrin.ir/api-docs/ (the page renders
|
||||
* /wp-content/themes/Hirad/data/api.json, 82 entries).
|
||||
*
|
||||
* Wire format: GET <base>?kind=<api>&token=<token>&<params>, where <base> is
|
||||
* the installation's `http://host:8000/ZarrinService/accounting`. The token
|
||||
* travels in the query string, so a URL built here must never be logged.
|
||||
*
|
||||
* Reads verified on a real installation (v228, 870 accounts) on 2026-10-12,
|
||||
* ACC-01: negative balance = owes; a «فاکتور موقت» moves the balance at once
|
||||
* (unlike Tahesab); listKarSakhte pages by 100 (pageNum); parvande opens with
|
||||
* a «مانده از قبل» row that is not a movement; goroohSakhte answers HTTP 400
|
||||
* with a good body; infogardeshsakhte answers malformed JSON. Writes verified
|
||||
* the same day on test account 953 (addAccount, edithesab, foroushSakhte,
|
||||
* hazfRadifSanad); only addFelezsakhteModel is untested. Re-test with
|
||||
* scripts/test-accounting-zarrin.ts; the full list is in plan §13.
|
||||
*
|
||||
* Differences from Tahesab that shape the sync:
|
||||
* * No idempotency key on foroushSakhte. A timed-out document call cannot be
|
||||
* retried blindly — the worker parks it as NEEDS_REVIEW.
|
||||
* * foroushSakhte does not return the row id needed to delete a document;
|
||||
* deleteDocument looks it up from the account's ledger (parvande).
|
||||
* * کارساخته can be created (addFelezsakhteModel) but never edited.
|
||||
*/
|
||||
|
||||
const STATEMENT_PAGE = 250;
|
||||
const CHANGE_WINDOW = 50;
|
||||
const MAX_STATEMENT_PAGES = 20;
|
||||
/** listKarSakhte returns this many per page (pageNum=1,2,…). */
|
||||
const ITEM_PAGE = 100;
|
||||
const MAX_ITEM_PAGES = 200;
|
||||
/** infogardeshsakhte scans every item server-side; seen at 5–33 s. */
|
||||
const MOVEMENT_TIMEOUT_MS = 90_000;
|
||||
const OPENING_ROW = 'مانده از قبل';
|
||||
const ALL_CODES = { az: 1, ta: 999999 };
|
||||
const GROUP_SCAN_MAX = 300;
|
||||
const GROUP_SCAN_CONCURRENCY = 10;
|
||||
|
||||
function endpoint(s: ProviderSettings) {
|
||||
return s.baseUrl.trim();
|
||||
}
|
||||
|
||||
class ZarrinHttpError extends AccountingProviderError {
|
||||
constructor(
|
||||
message: string,
|
||||
body: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message, false, body, status === 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installations lack some documented kinds (v228 has no Get100Account,
|
||||
* goroohSakhte or getinfoByPhone) and answer them with a bare 400.
|
||||
*/
|
||||
function isMissingKind(err: unknown) {
|
||||
return err instanceof ZarrinHttpError && err.status === 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server said on a non-2xx reply, short enough for the admin screen.
|
||||
* Error pages may echo the request URL, so the token is scrubbed.
|
||||
*/
|
||||
function errorBodyText(body: string, token: string) {
|
||||
let text = body;
|
||||
try {
|
||||
const j = parseLooseJson(body) as any;
|
||||
const o = typeof j === 'string' && /^\s*[{[]/.test(j) ? parseLooseJson(j) : j;
|
||||
if (o && typeof o === 'object') text = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? str(o.Message) ?? str(o.title) ?? JSON.stringify(o);
|
||||
else if (typeof o === 'string') text = o;
|
||||
} catch {
|
||||
text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
|
||||
}
|
||||
if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* infogardeshsakhte prints its JSON by hand: string values unquoted and a
|
||||
* comma before every closing brace. Its layout is one "key": value per line,
|
||||
* so each bare value is quoted and the dangling commas dropped.
|
||||
*/
|
||||
export function repairZarrinJson(body: string): string {
|
||||
const lines = body.split(/\r?\n/).map((line) => {
|
||||
const m = line.match(/^(\s*"[^"]+"\s*:\s*)(.*?)(,?)\s*$/);
|
||||
if (!m) return line;
|
||||
const value = m[2].trim();
|
||||
if (value === '' || /^(-?\d+(\.\d+)?|true|false|null)$/.test(value) || /^["{[]/.test(value)) return line;
|
||||
return `${m[1]}${JSON.stringify(value)}${m[3]}`;
|
||||
});
|
||||
return lines.join('\n').replace(/,(\s*[}\]])/g, '$1');
|
||||
}
|
||||
|
||||
async function call(
|
||||
s: ProviderSettings,
|
||||
kind: string,
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
opts: { timeoutMs?: number; repair?: (body: string) => string } = {},
|
||||
): Promise<Record<string, unknown> | unknown[]> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint(s));
|
||||
} catch {
|
||||
throw new AccountingProviderError('زرین: آدرس سرور نامعتبر است');
|
||||
}
|
||||
url.searchParams.set('kind', kind);
|
||||
url.searchParams.set('token', s.token);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v));
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: opts.timeoutMs ?? s.timeoutMs, ca: s.caCert });
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) {
|
||||
throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
// v228 answers goroohSakhte with HTTP 400 and a perfectly good body.
|
||||
const good = goodReply(res.body);
|
||||
if (good) return good;
|
||||
const reason = errorBodyText(res.body, s.token);
|
||||
throw new ZarrinHttpError(`زرین (${kind}): HTTP ${res.status}${reason ? ` — ${reason}` : ''}`, res.body.slice(0, 500), res.status);
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = parseLooseJson(opts.repair ? opts.repair(res.body) : res.body);
|
||||
// The documented outputs are JSON-encoded strings of JSON ("{\"state\":…}").
|
||||
if (typeof json === 'string' && /^\s*[{[]/.test(json)) json = parseLooseJson(json);
|
||||
} catch {
|
||||
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, res.body.slice(0, 500));
|
||||
}
|
||||
if (!json || typeof json !== 'object') {
|
||||
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, json);
|
||||
}
|
||||
|
||||
const o = json as Record<string, unknown>;
|
||||
if (o.state === false || o.status === false) {
|
||||
const msg = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? 'خطای نامشخص';
|
||||
throw new AccountingProviderError(`زرین (${kind}): ${msg}`, false, json, /یافت نشد|موجود نیست/.test(msg));
|
||||
}
|
||||
return json as Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
function goodReply(body: string): Record<string, unknown> | null {
|
||||
try {
|
||||
let j = parseLooseJson(body) as unknown;
|
||||
if (typeof j === 'string' && /^\s*[{[]/.test(j)) j = parseLooseJson(j);
|
||||
const o = j as Record<string, unknown> | null;
|
||||
return o && typeof o === 'object' && (o.state === true || o.status === true) && Array.isArray(o.data) ? o : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Zarrin wraps lists as `data`, sometimes as `date` (searchAccount), or not at all. */
|
||||
function listOf(json: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(json)) return json as Record<string, unknown>[];
|
||||
if (json && typeof json === 'object') {
|
||||
const o = json as Record<string, unknown>;
|
||||
for (const key of ['data', 'date', 'mande']) {
|
||||
if (Array.isArray(o[key])) return o[key] as Record<string, unknown>[];
|
||||
}
|
||||
if ('id' in o || 'code' in o) return [o];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function dataOf(json: unknown): Record<string, unknown> {
|
||||
const o = (json ?? {}) as Record<string, unknown>;
|
||||
const d = o.data;
|
||||
if (d && typeof d === 'object' && !Array.isArray(d)) return d as Record<string, unknown>;
|
||||
return o;
|
||||
}
|
||||
|
||||
function joinName(first: unknown, last: unknown) {
|
||||
return [str(first), str(last)].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
/** Zarrin prints dates as yy/mm/dd of the 1400s: "04/05/29" → "1404/05/29". */
|
||||
function fullJalali(v: unknown): string {
|
||||
const s = str(v) ?? '';
|
||||
const m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
|
||||
if (!m) return s;
|
||||
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function accountName(v: unknown): string | null {
|
||||
const n = (str(v) ?? '').replace(/\s*\*\s*/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return n || null;
|
||||
}
|
||||
|
||||
async function allAccounts(s: ProviderSettings): Promise<ExternalCustomer[]> {
|
||||
return listOf(await call(s, 'listhesabha', ALL_CODES))
|
||||
.filter((r) => r.id !== undefined)
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
// listhesabha prints «first * last» (and « * name» without a first name).
|
||||
name: accountName(r.nam) ?? String(r.id),
|
||||
mobile: normalizeMobile(r.mobile),
|
||||
phone: str(r.tell),
|
||||
nationalCode: str(r.codeMeli),
|
||||
address: str(r.address),
|
||||
groupId: null,
|
||||
groupName: str(r.gorooh),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Every کارساخته: listKarSakhte pages by 100 (pageNum), undocumented. */
|
||||
async function allKarSakhte(s: ProviderSettings): Promise<Record<string, unknown>[]> {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let page = 1; page <= MAX_ITEM_PAGES; page += 1) {
|
||||
const batch = listOf(await call(s, 'listKarSakhte', { pageNum: page }));
|
||||
// An installation that ignores pageNum repeats page 1: stop there.
|
||||
const fresh = batch.filter((r) => !seen.has(String(r.id)));
|
||||
for (const r of fresh) seen.add(String(r.id));
|
||||
out.push(...fresh);
|
||||
if (batch.length < ITEM_PAGE || fresh.length === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** An account's ledger rows (parvande) in a date range, every page, without the opening row. */
|
||||
async function ledgerRows(s: ProviderSettings, code: string, from: string, to: string): Promise<Record<string, unknown>[]> {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) {
|
||||
const batch = listOf(await call(s, 'parvande', { id: code, dateAz: from, dateTa: to, pageNum: page, detailCount: STATEMENT_PAGE }));
|
||||
rows.push(...batch);
|
||||
if (batch.length < STATEMENT_PAGE) break;
|
||||
}
|
||||
// «مانده از قبل» (idRadif 0) is a summary, not a movement.
|
||||
return rows.filter((r) => String(r.idRadif ?? '') !== '0' && str(r.sharh) !== OPENING_ROW);
|
||||
}
|
||||
|
||||
/** What a row adds to what the customer owes (app convention: positive = owes). */
|
||||
function owesDelta(r: Record<string, unknown>) {
|
||||
return {
|
||||
gold: (dec(r.vaznTarazooPardakht) ?? ZERO).sub(dec(r.vaznTarazooDaryaft) ?? ZERO),
|
||||
rial: (dec(r.poolPardakht) ?? ZERO).sub(dec(r.poolDaryaft) ?? ZERO),
|
||||
};
|
||||
}
|
||||
|
||||
function signed(s: ProviderSettings, v: Prisma.Decimal) {
|
||||
// v228: negative = owes (the default). Kept a setting in case another install differs.
|
||||
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
|
||||
}
|
||||
|
||||
export const zarrinProvider: AccountingProvider = {
|
||||
name: 'zarrin',
|
||||
label: 'زرین',
|
||||
capabilities: { createKarSakhte: true, documentInquiry: false, movementScope: 'all', changeFeed: true },
|
||||
|
||||
async health(s) {
|
||||
const v = (await call(s, 'version')) as Record<string, unknown>;
|
||||
// `version` may not check the token; a group listing does.
|
||||
const groups = listOf(await call(s, 'listGorooheHesabha'));
|
||||
return { version: v.version, message: v.message, customerGroups: groups.length };
|
||||
},
|
||||
|
||||
async listCustomerGroups(s) {
|
||||
return listOf(await call(s, 'listGorooheHesabha')).map((g) => ({
|
||||
id: String(g.id),
|
||||
name: str(g.name) ?? String(g.id),
|
||||
}));
|
||||
},
|
||||
|
||||
async listCustomers(s) {
|
||||
// listhesabha carries everything (group, mobile, address) and returns the
|
||||
// whole code span in one reply (~865 accounts in under 2 s).
|
||||
return { items: await allAccounts(s), next: null };
|
||||
},
|
||||
|
||||
async findCustomersByMobile(s, mobile) {
|
||||
const target = normalizeMobile(mobile);
|
||||
if (!target) return [];
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
// Matches on name or mobile, possibly partially; filtered exactly below.
|
||||
rows = listOf(await call(s, 'searchAccount', { search: target }));
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return [];
|
||||
if (!isMissingKind(err)) throw err;
|
||||
return (await allAccounts(s)).filter((c) => c.mobile === target);
|
||||
}
|
||||
return rows
|
||||
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
|
||||
.map((r) => ({
|
||||
code: String(r.code),
|
||||
name: joinName(r.name, r.namKhanevadegi),
|
||||
mobile: target,
|
||||
phone: null,
|
||||
nationalCode: null,
|
||||
address: null,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
}));
|
||||
},
|
||||
|
||||
async createCustomer(s, p) {
|
||||
const json = (await call(s, 'addAccount', {
|
||||
firstName: p.firstName || p.fullName,
|
||||
lastName: p.lastName || '-',
|
||||
fkNoe: p.group.id,
|
||||
mobile: p.mobile,
|
||||
telephone: p.phone,
|
||||
address: p.address,
|
||||
codeMelli: p.nationalCode,
|
||||
})) as Record<string, unknown>;
|
||||
const id = json.id ?? dataOf(json).id;
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد حساب جدید برنگشت', false, json);
|
||||
return { code: String(id), response: json };
|
||||
},
|
||||
|
||||
async updateCustomer(s, code, p) {
|
||||
const json = await call(s, 'edithesab', {
|
||||
id: code,
|
||||
name: p.firstName || p.fullName,
|
||||
family: p.lastName,
|
||||
mobile: p.mobile,
|
||||
tel: p.phone,
|
||||
address: p.address,
|
||||
codeMeli: p.nationalCode,
|
||||
// Without nameG Zarrin moves the account into its customers group, so the
|
||||
// current group is always sent back.
|
||||
nameG: p.group.name,
|
||||
});
|
||||
return { response: json };
|
||||
},
|
||||
|
||||
async getBalances(s, codes) {
|
||||
if (codes.length === 0) return [];
|
||||
const wanted = new Set(codes);
|
||||
const rows =
|
||||
codes.length === 1
|
||||
? listOf(await call(s, 'listMandeHesabha', { id: codes[0], isRial: 0 }))
|
||||
: listOf(await call(s, 'listMandeHesabha', { isRial: 0 }));
|
||||
return rows
|
||||
.filter((r) => wanted.has(String(r.id)))
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
goldGrams: signed(s, dec(r.geram) ?? ZERO),
|
||||
rial: signed(s, dec(r.pool) ?? ZERO),
|
||||
}));
|
||||
},
|
||||
|
||||
async getChanges(s, cursor) {
|
||||
// parLastTenRadif without an account is the newest rows of the whole
|
||||
// journal; each row names its account (idHesab) and has an ascending idRadif.
|
||||
// Seen working on v228 (plan §13).
|
||||
const rows = listOf(await call(s, 'parLastTenRadif', { limit: CHANGE_WINDOW }));
|
||||
let maxId = 0;
|
||||
for (const r of rows) maxId = Math.max(maxId, Number(r.idRadif) || 0);
|
||||
if (cursor === null) return { cursor: String(maxId), accountCodes: [], itemNames: [], overflow: false };
|
||||
const seen = Number(cursor) || 0;
|
||||
const fresh = rows.filter((r) => (Number(r.idRadif) || 0) > seen);
|
||||
const accounts = new Set<string>();
|
||||
for (const r of fresh) {
|
||||
const code = str(r.idHesab);
|
||||
if (code) accounts.add(code);
|
||||
}
|
||||
return {
|
||||
cursor: String(Math.max(maxId, seen)),
|
||||
accountCodes: [...accounts],
|
||||
itemNames: [],
|
||||
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getStatement(s, q) {
|
||||
const rows = await ledgerRows(s, q.code, jalaliToIsoDate(q.from), jalaliToIsoDate(q.to));
|
||||
|
||||
// parvande has no running balance and its «مانده از قبل» row cannot be
|
||||
// read as one, so the balance is anchored on the account's real balance
|
||||
// now and walked back: closing(to) = now − movements after `to`.
|
||||
const [now] = await zarrinProvider.getBalances(s, [q.code]);
|
||||
let closingGold = now?.goldGrams ?? ZERO;
|
||||
let closingRial = now?.rial ?? ZERO;
|
||||
const today = toJalali(new Date());
|
||||
if (jalaliKey(q.to) < jalaliKey(today)) {
|
||||
for (const r of await ledgerRows(s, q.code, jalaliToIsoDate(nextDay(q.to)), jalaliToIsoDate(today))) {
|
||||
const d = owesDelta(r);
|
||||
closingGold = closingGold.sub(d.gold);
|
||||
closingRial = closingRial.sub(d.rial);
|
||||
}
|
||||
}
|
||||
let gold = closingGold;
|
||||
let rial = closingRial;
|
||||
for (const r of rows) {
|
||||
const d = owesDelta(r);
|
||||
gold = gold.sub(d.gold);
|
||||
rial = rial.sub(d.rial);
|
||||
}
|
||||
|
||||
return rows.map((r): StatementRow => {
|
||||
const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO;
|
||||
const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO;
|
||||
const rialIn = dec(r.poolDaryaft) ?? ZERO;
|
||||
const rialOut = dec(r.poolPardakht) ?? ZERO;
|
||||
gold = gold.add(goldOut).sub(goldIn);
|
||||
rial = rial.add(rialOut).sub(rialIn);
|
||||
const factor = str(r.idFactor);
|
||||
return {
|
||||
key: String(r.idRadif ?? ''),
|
||||
documentRef: factor && factor !== '0' ? factor : '',
|
||||
accountCode: String(r.idHesab ?? q.code),
|
||||
documentNo: factor && factor !== '0' ? factor : '',
|
||||
date: fullJalali(r.tarikh),
|
||||
time: null,
|
||||
title: str(r.noeRadif) ?? str(r.noeFactor) ?? str(r.sharh) ?? '',
|
||||
description: str(r.sharh),
|
||||
itemName: str(r.namFelez),
|
||||
weight: dec(r.vaznDaryaft)?.gt(0) ? dec(r.vaznDaryaft) : dec(r.vaznPardakht),
|
||||
karat: dec(r.ayar)?.toNumber() || null,
|
||||
count: null,
|
||||
unitPrice: null,
|
||||
wagePercent: null,
|
||||
goldIn,
|
||||
goldOut,
|
||||
rialIn,
|
||||
rialOut,
|
||||
goldBalance: gold,
|
||||
rialBalance: rial,
|
||||
metal: null,
|
||||
// A document posted with movaghat=1 says so in shomareSanad («موقت»);
|
||||
// one left open in Zarrin's own UI is a «فاکتور موقت».
|
||||
temporary: /موقت/.test(`${str(r.noeFactor) ?? ''} ${str(r.shomareSanad) ?? ''}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async listKarSakhteGroups(s) {
|
||||
try {
|
||||
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
|
||||
} catch (err) {
|
||||
if (!isMissingKind(err)) throw err;
|
||||
}
|
||||
// Without goroohSakhte the only way is one infogorooh per id. Ids are
|
||||
// sparse and unknown ids fail (some with a 500), so failures are skipped.
|
||||
const found: { id: string; name: string }[] = [];
|
||||
let next = 1;
|
||||
const worker = async () => {
|
||||
while (next <= GROUP_SCAN_MAX) {
|
||||
const id = next++;
|
||||
try {
|
||||
for (const g of listOf(await call(s, 'infogorooh', { id }))) {
|
||||
const name = str(g.nam);
|
||||
if (name) found.push({ id: String(g.code ?? id), name });
|
||||
}
|
||||
} catch {
|
||||
// not a group
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: GROUP_SCAN_CONCURRENCY }, worker));
|
||||
return found.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
},
|
||||
|
||||
async listKarSakhteTypes(s) {
|
||||
const types = new Map<string, string>();
|
||||
for (const r of await allKarSakhte(s)) {
|
||||
if (r.fkNoe !== undefined) types.set(String(r.fkNoe), str(r.NoeName) ?? String(r.fkNoe));
|
||||
}
|
||||
return [...types].map(([id, name]) => ({ id, name }));
|
||||
},
|
||||
|
||||
async listKarSakhte(s) {
|
||||
return (await allKarSakhte(s))
|
||||
.filter((r) => r.id !== undefined && str(r.namFelezsakhthe))
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
name: str(r.namFelezsakhthe) as string,
|
||||
// listKarSakhte carries the item's type (گرمی/تعدادی…) but not its
|
||||
// group (النگو/انگشتر…); category stays the app's call (D27).
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
typeId: str(r.fkNoe),
|
||||
typeName: str(r.NoeName),
|
||||
saleWagePercent: dec(r.darsadForoosh),
|
||||
stockWeight: dec(r.vazn),
|
||||
stockCount: dec(r.tedad),
|
||||
}));
|
||||
},
|
||||
|
||||
async getKarSakhteStock(s, item) {
|
||||
const d = dataOf(await call(s, 'mojoodiKala', { id: item.code, dateTa: toIsoDateTehran(new Date()) }));
|
||||
const qty = dec(d.mojoodi);
|
||||
const byWeight = /گرم/.test(String(d.noeName ?? 'گرمی'));
|
||||
return { weight: byWeight ? qty : null, count: byWeight ? null : qty };
|
||||
},
|
||||
|
||||
async getKarSakhteMovement(s, from, to) {
|
||||
const json = await call(
|
||||
s,
|
||||
'infogardeshsakhte',
|
||||
{ az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) },
|
||||
{ timeoutMs: Math.max(s.timeoutMs, MOVEMENT_TIMEOUT_MS), repair: repairZarrinJson },
|
||||
);
|
||||
return listOf(json).map(
|
||||
(r) => ({
|
||||
itemCode: str(r.code_kar),
|
||||
itemName: str(r.nam_kar) ?? '—',
|
||||
// One row per item: its LAST movement, which may predate the range.
|
||||
date: fullJalali(r.tarikh_akharin_gardesh),
|
||||
description: str(r.sharh_akharin_gardesh),
|
||||
weight: dec(r.vazn_akharin_gardesh),
|
||||
amount: dec(r.mablagh_akharin_gardesh),
|
||||
count: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async createKarSakhte(s, item) {
|
||||
const json = (await call(s, 'addFelezsakhteModel', {
|
||||
nameKar: item.name,
|
||||
noeKar: item.typeId,
|
||||
gorooheKar: item.groupId,
|
||||
})) as Record<string, unknown>;
|
||||
const id = json.idKar ?? dataOf(json).idKar;
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد کارساختهی جدید برنگشت', false, json);
|
||||
return { code: String(id), response: json };
|
||||
},
|
||||
|
||||
async postOrderDocument(s, doc, progress, onProgress) {
|
||||
let factorId = progress.factorId;
|
||||
let done = progress.doneLines ?? 0;
|
||||
let last: unknown = null;
|
||||
|
||||
for (let i = done; i < doc.lines.length; i += 1) {
|
||||
const line = doc.lines[i];
|
||||
const isLast = i === doc.lines.length - 1;
|
||||
last = await call(s, 'foroushSakhte', {
|
||||
// The first call opens the invoice for the account; the rest append.
|
||||
...(factorId ? { idFactor: factorId } : { idHesab: doc.customerCode }),
|
||||
idSakhte: line.itemCode,
|
||||
// Bonak mode: mablagh is the rial charge, darsad the per-gram making charge.
|
||||
mablagh: line.flatRial.toFixed(0),
|
||||
sharh: line.description,
|
||||
vazn: line.weightGrams.toFixed(3),
|
||||
vaznSang: 0,
|
||||
mablaghSang: 0,
|
||||
pd: -1, // پرداخت: goods leave to the customer
|
||||
darsad: line.wagePercent.toString(),
|
||||
// The invoice has to stay open while lines are still to come; the last
|
||||
// line closes it unless the document is to stay temporary (D27: it is).
|
||||
movaghat: doc.temporary || !isLast ? 1 : 0,
|
||||
bonak: 1,
|
||||
});
|
||||
const id = (dataOf(last).idFactor ?? (last as Record<string, unknown>).idFactor) as unknown;
|
||||
if (!factorId) {
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: شماره فاکتور برنگشت', true, last);
|
||||
factorId = String(id);
|
||||
}
|
||||
done = i + 1;
|
||||
await onProgress({ factorId, doneLines: done });
|
||||
}
|
||||
|
||||
return { ref: String(factorId ?? ''), progress: { factorId, doneLines: done }, response: last };
|
||||
},
|
||||
|
||||
async documentExists() {
|
||||
// No lookup by our own key exists in Zarrin's API.
|
||||
return false;
|
||||
},
|
||||
|
||||
async deleteDocument(s, doc) {
|
||||
if (!doc.ref) throw new AccountingProviderError('زرین: شماره فاکتور سند معلوم نیست');
|
||||
// hazfRadifSanad needs a row id, which foroushSakhte never returned; find
|
||||
// one in the account's ledger for the last year.
|
||||
const today = new Date();
|
||||
const yearAgo = new Date(today.getTime() - 366 * 24 * 3600 * 1000);
|
||||
const rows = await ledgerRows(s, doc.customerCode, toIsoDateTehran(yearAgo), toIsoDateTehran(today));
|
||||
const row = rows.find((r) => String(r.idFactor) === doc.ref && str(r.idRadif) && String(r.idRadif) !== '0');
|
||||
if (!row) return { response: { alreadyDeleted: true } };
|
||||
const json = await call(s, 'hazfRadifSanad', {
|
||||
pkRadifSanad: String(row.idRadif),
|
||||
kol: 1,
|
||||
commentUser: 'لغو سفارش در اپ',
|
||||
});
|
||||
return { response: json };
|
||||
},
|
||||
};
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __zarrinInternals = { listOf, fullJalali, repairZarrinJson, accountName };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||
import { envOrNull } from '../../common/env';
|
||||
|
||||
/**
|
||||
* Encrypts the accounting-software token at rest (D27).
|
||||
*
|
||||
* The token sits in the Setting table, so a database dump or a backup would
|
||||
* otherwise hand out a live credential to the shop's books. AES-256-GCM with a
|
||||
* key derived from `ACCOUNTING_SECRET_KEY`.
|
||||
*
|
||||
* When that variable is unset the key falls back to one derived from
|
||||
* `JWT_SECRET`, with a loud warning at boot — the same "warn, don't refuse"
|
||||
* trade-off D23 made — so an admin can configure the integration before ops
|
||||
* add the variable. The cost: rotating JWT_SECRET then makes the stored token
|
||||
* unreadable and the admin has to enter it again (the screen says so).
|
||||
*/
|
||||
|
||||
const PREFIX = 'enc:v1:';
|
||||
|
||||
export function secretKeySource(): 'ACCOUNTING_SECRET_KEY' | 'JWT_SECRET' | null {
|
||||
if (envOrNull('ACCOUNTING_SECRET_KEY')) return 'ACCOUNTING_SECRET_KEY';
|
||||
if (envOrNull('JWT_SECRET')) return 'JWT_SECRET';
|
||||
return null;
|
||||
}
|
||||
|
||||
function key(): Buffer {
|
||||
const material = envOrNull('ACCOUNTING_SECRET_KEY') ?? envOrNull('JWT_SECRET');
|
||||
if (!material) {
|
||||
throw new Error('ACCOUNTING_SECRET_KEY تنظیم نشده است؛ ذخیرهی توکن حسابداری ممکن نیست.');
|
||||
}
|
||||
return createHash('sha256').update(`hedena-gold-accounting:${material}`).digest();
|
||||
}
|
||||
|
||||
export function sealSecret(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return PREFIX + Buffer.concat([iv, tag, body]).toString('base64');
|
||||
}
|
||||
|
||||
/** Null when the value cannot be decrypted (wrong key, tampered, malformed). */
|
||||
export function openSecret(sealed: string | null | undefined): string | null {
|
||||
if (!sealed || !sealed.startsWith(PREFIX)) return null;
|
||||
try {
|
||||
const raw = Buffer.from(sealed.slice(PREFIX.length), 'base64');
|
||||
const iv = raw.subarray(0, 12);
|
||||
const tag = raw.subarray(12, 28);
|
||||
const body = raw.subarray(28);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { LedgerService } from './ledger.service';
|
||||
import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto';
|
||||
import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Ledger operations — admin only.
|
||||
*
|
||||
* These were `@Public()` until the security hotfix. That was faithful to the
|
||||
* reference, where the whole admin route file is unauthenticated (the
|
||||
* `auth:api` group wrapper is commented out at henza_server/routes/admin.php:12
|
||||
* and :272), but it contradicted decision D1 in docs/parity/DECISIONS.md, which
|
||||
* confirms the target keeps its stricter auth. Four of these five routes WRITE
|
||||
* to the ledger, so an anonymous caller could reconcile a balance or post and
|
||||
* confirm a repayment.
|
||||
*
|
||||
* Rids follow the legacy vocabulary: reading a balance is a wallet read,
|
||||
* repayments are financial documents, and confirming an entry is the existing
|
||||
* document-confirm permission.
|
||||
*/
|
||||
@Controller('accounting')
|
||||
export class AccountingController {
|
||||
constructor(private readonly ledger: LedgerService) {}
|
||||
|
||||
@Get('customers/:customerId/balance')
|
||||
@RequirePermission('wallet.list')
|
||||
getBalance(@Param('customerId') customerId: string) {
|
||||
return this.ledger.getBalance(customerId);
|
||||
}
|
||||
|
||||
@Post('customers/:customerId/reconcile')
|
||||
@RequirePermission('wallet.charge')
|
||||
reconcile(@Param('customerId') customerId: string) {
|
||||
return this.ledger.reconcileBalance(customerId);
|
||||
}
|
||||
|
||||
@Post('repayments/gold')
|
||||
@RequirePermission('document.create')
|
||||
postGoldRepayment(@Body() dto: PostGoldRepaymentDto) {
|
||||
return this.ledger.postGoldRepayment(dto);
|
||||
}
|
||||
|
||||
@Post('repayments/rial')
|
||||
@RequirePermission('document.create')
|
||||
postRialRepayment(@Body() dto: PostRialRepaymentDto) {
|
||||
return this.ledger.postRialRepayment(dto);
|
||||
}
|
||||
|
||||
@Post('entries/:entryId/confirm')
|
||||
@RequirePermission('document.list.confirm')
|
||||
confirmEntry(@Param('entryId') entryId: string) {
|
||||
return this.ledger.confirmEntry(entryId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LedgerService } from './ledger.service';
|
||||
import { AccountingController } from './accounting.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountingController],
|
||||
providers: [LedgerService],
|
||||
exports: [LedgerService],
|
||||
})
|
||||
export class AccountingModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsIn, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class PostGoldRepaymentDto {
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@IsIn(['MELTED_GOLD', 'TRANSFER', 'CHEQUE', 'CASH'])
|
||||
instrument!: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
rawValue!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
karatPerMille?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
pricePerGramRial?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class PostRialRepaymentDto {
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { LedgerService } from './ledger.service';
|
||||
|
||||
/**
|
||||
* ACC-03 / MON-04 — an order's debt follows its status, exactly as legacy's
|
||||
* recomputed `Σ orders.input WHERE sts > 4`: owed once while delivered (to the
|
||||
* expert or the customer), nothing otherwise, never twice.
|
||||
*/
|
||||
describe('LedgerService.syncOrderDebt', () => {
|
||||
type Entry = { orderId: string; currency: LedgerCurrency; direction: LedgerDirection; amount: Prisma.Decimal; confirmed: boolean };
|
||||
|
||||
function build(order: { status: OrderStatus; totalGoldWeightGrams: string; flatRialCharge: string }) {
|
||||
const entries: Entry[] = [];
|
||||
const balance = { gold: new Prisma.Decimal(0), rial: new Prisma.Decimal(0) };
|
||||
const row = { id: 'o1', customerId: 'c1', ...order };
|
||||
const tx = {
|
||||
order: { findUnique: jest.fn(async () => row) },
|
||||
ledgerEntry: {
|
||||
create: jest.fn(async ({ data }: { data: Entry }) => {
|
||||
entries.push(data);
|
||||
return data;
|
||||
}),
|
||||
groupBy: jest.fn(async ({ where }: { where: { orderId: string; currency: LedgerCurrency } }) => {
|
||||
const out: { direction: LedgerDirection; _sum: { amount: Prisma.Decimal } }[] = [];
|
||||
for (const d of [LedgerDirection.DEBIT, LedgerDirection.CREDIT]) {
|
||||
const hit = entries.filter((e) => e.orderId === where.orderId && e.currency === where.currency && e.direction === d && e.confirmed);
|
||||
if (hit.length) out.push({ direction: d, _sum: { amount: hit.reduce((s, e) => s.add(e.amount), new Prisma.Decimal(0)) } });
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
},
|
||||
customer: {
|
||||
update: jest.fn(async ({ data }: { data: { goldDebtGrams?: { increment: Prisma.Decimal }; fiatDebtRial?: { increment: Prisma.Decimal } } }) => {
|
||||
if (data.goldDebtGrams) balance.gold = balance.gold.add(data.goldDebtGrams.increment);
|
||||
if (data.fiatDebtRial) balance.rial = balance.rial.add(data.fiatDebtRial.increment);
|
||||
}),
|
||||
},
|
||||
};
|
||||
const service = new LedgerService(tx as never);
|
||||
const move = async (status: OrderStatus) => {
|
||||
row.status = status;
|
||||
await service.syncOrderDebt('o1', tx as never);
|
||||
};
|
||||
return { move, entries, balance };
|
||||
}
|
||||
|
||||
it('posts the gold and rial debt once on delivery to the expert', async () => {
|
||||
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '2000' });
|
||||
await t.move(OrderStatus.DELIVERED_TO_AGENT);
|
||||
expect(t.balance.gold.toString()).toBe('10.5');
|
||||
expect(t.balance.rial.toString()).toBe('2000');
|
||||
expect(t.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('never posts twice when the order reaches delivered again (the ACC-03 bug)', async () => {
|
||||
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '0' });
|
||||
await t.move(OrderStatus.DELIVERED_TO_AGENT);
|
||||
await t.move(OrderStatus.DELIVERED_TO_AGENT);
|
||||
await t.move(OrderStatus.DELIVERED_TO_CUSTOMER);
|
||||
expect(t.balance.gold.toString()).toBe('10.5');
|
||||
expect(t.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('takes the debt off when the order goes back, and puts it back once', async () => {
|
||||
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '0' });
|
||||
await t.move(OrderStatus.DELIVERED_TO_AGENT);
|
||||
await t.move(OrderStatus.PREPARING);
|
||||
expect(t.balance.gold.toString()).toBe('0');
|
||||
expect(t.entries[1].direction).toBe(LedgerDirection.CREDIT);
|
||||
await t.move(OrderStatus.DELIVERED_TO_AGENT);
|
||||
expect(t.balance.gold.toString()).toBe('10.5');
|
||||
});
|
||||
|
||||
it('posts the debt when an order goes straight to delivered-to-customer', async () => {
|
||||
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '0' });
|
||||
await t.move(OrderStatus.DELIVERED_TO_CUSTOMER);
|
||||
expect(t.balance.gold.toString()).toBe('3');
|
||||
});
|
||||
|
||||
it('a cancelled delivered order owes nothing', async () => {
|
||||
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '500' });
|
||||
await t.move(OrderStatus.DELIVERED_TO_AGENT);
|
||||
await t.move(OrderStatus.CANCELLED);
|
||||
expect(t.balance.gold.toString()).toBe('0');
|
||||
expect(t.balance.rial.toString()).toBe('0');
|
||||
});
|
||||
|
||||
it('posts nothing for an order that was never delivered', async () => {
|
||||
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '500' });
|
||||
await t.move(OrderStatus.ADMIN_APPROVED);
|
||||
await t.move(OrderStatus.PREPARING);
|
||||
expect(t.entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { LedgerCurrency, LedgerDirection, LedgerSourceType, OrderStatus, Prisma, PrismaClient } from '@prisma/client';
|
||||
import { GRAMS_PER_MESGHAL, STANDARD_KARAT_PER_MILLE } from '@hedena-gold/shared-types';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaClient;
|
||||
|
||||
const ZERO = new Prisma.Decimal(0);
|
||||
|
||||
/** Legacy `sts > 4`: delivered to the expert (5) or to the customer (6). */
|
||||
const OWING_STATUSES: OrderStatus[] = [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER];
|
||||
|
||||
export interface PostEntryInput {
|
||||
customerId: string;
|
||||
currency: LedgerCurrency;
|
||||
direction: LedgerDirection;
|
||||
sourceType: LedgerSourceType;
|
||||
amount: Prisma.Decimal.Value;
|
||||
orderId?: string;
|
||||
karatPerMille?: number;
|
||||
pricePerGramRialAtEntry?: Prisma.Decimal.Value;
|
||||
note?: string;
|
||||
/** Order-delivery debt entries are self-confirming; repayments start pending. */
|
||||
confirmed?: boolean;
|
||||
postedByAdminUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dual-currency accounting engine. LedgerEntry is append-only and is the
|
||||
* sole source of truth for customer debt; Customer.goldDebtGrams/fiatDebtRial
|
||||
* are denormalized caches kept in sync here and self-healed by reconcileBalance.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LedgerService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Posts a ledger entry and, if confirmed, applies it to the cached customer balance. */
|
||||
async postEntry(input: PostEntryInput, tx: Tx = this.prisma) {
|
||||
const amount = new Prisma.Decimal(input.amount);
|
||||
if (amount.lte(0)) {
|
||||
throw new BadRequestException('Ledger entry amount must be positive');
|
||||
}
|
||||
const confirmed = input.confirmed ?? false;
|
||||
|
||||
const entry = await tx.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: input.customerId,
|
||||
currency: input.currency,
|
||||
direction: input.direction,
|
||||
sourceType: input.sourceType,
|
||||
amount,
|
||||
orderId: input.orderId,
|
||||
karatPerMille: input.karatPerMille,
|
||||
pricePerGramRialAtEntry:
|
||||
input.pricePerGramRialAtEntry !== undefined ? new Prisma.Decimal(input.pricePerGramRialAtEntry) : undefined,
|
||||
note: input.note,
|
||||
confirmed,
|
||||
confirmedAt: confirmed ? new Date() : null,
|
||||
postedByAdminUserId: input.postedByAdminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
await this.applyToBalance(input.customerId, input.currency, input.direction, amount, tx);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a pending repayment entry confirmed. Legacy's confirmed toggle
|
||||
* (Adm\CashController::toggleConfiremCash) is bidirectional and used for
|
||||
* post-hoc correction/rejection — legacy Cash rows are auto-confirmed
|
||||
* (confirmed=1) at creation, not created pending. This system intentionally
|
||||
* requires an explicit confirmation step before a repayment counts (see
|
||||
* postGoldRepayment for the full rationale) — that is a deliberate design
|
||||
* change from legacy, not a faithful mirror of it.
|
||||
*/
|
||||
async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) {
|
||||
const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } });
|
||||
if (!entry) throw new NotFoundException('Ledger entry not found');
|
||||
if (entry.confirmed) return entry;
|
||||
|
||||
const updated = await tx.ledgerEntry.update({
|
||||
where: { id: entryId },
|
||||
data: { confirmed: true, confirmedAt: new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId },
|
||||
});
|
||||
|
||||
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async applyToBalance(
|
||||
customerId: string,
|
||||
currency: LedgerCurrency,
|
||||
direction: LedgerDirection,
|
||||
amount: Prisma.Decimal,
|
||||
tx: Tx,
|
||||
) {
|
||||
const signedDelta = direction === LedgerDirection.DEBIT ? amount : amount.neg();
|
||||
await tx.customer.update({
|
||||
where: { id: customerId },
|
||||
data:
|
||||
currency === LedgerCurrency.GOLD_GRAM
|
||||
? { goldDebtGrams: { increment: signedDelta } }
|
||||
: { fiatDebtRial: { increment: signedDelta } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings an order's debt in line with its status — legacy's rule exactly.
|
||||
*
|
||||
* Legacy never "posted" order debt; it RECOMPUTED it
|
||||
* (CustomerController::updateRemain): Σ orders.input WHERE sts > 4, i.e. an
|
||||
* order owes its full weight (and its rial charge) while it is delivered —
|
||||
* to the expert (5) or to the customer (6) — and nothing otherwise. So:
|
||||
* - moving into a delivered status twice never counts twice (ACC-03 /
|
||||
* MON-04: the old guard on `deliveredAt` let a second DELIVERED_TO_AGENT
|
||||
* post the debt again);
|
||||
* - going straight to DELIVERED_TO_CUSTOMER still creates the debt (the
|
||||
* old trigger fired only on DELIVERED_TO_AGENT, so that order owed nothing);
|
||||
* - moving back below delivered, or cancelling, takes it off again.
|
||||
*
|
||||
* It compares what the order SHOULD owe with the confirmed ORDER_STOCK
|
||||
* entries already on it and posts only the difference, as a confirmed
|
||||
* DEBIT or CREDIT — so it is idempotent and nothing is ever edited or
|
||||
* deleted (the ledger stays append-only). Entries imported from legacy carry
|
||||
* their orderId, so imported orders reconcile the same way.
|
||||
*/
|
||||
async syncOrderDebt(orderId: string, tx: Tx = this.prisma) {
|
||||
const order = await tx.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new NotFoundException('Order not found');
|
||||
|
||||
const owes = OWING_STATUSES.includes(order.status);
|
||||
const targets: [LedgerCurrency, Prisma.Decimal, string][] = [
|
||||
[LedgerCurrency.GOLD_GRAM, owes ? new Prisma.Decimal(order.totalGoldWeightGrams) : ZERO, 'Gold-weight debt'],
|
||||
[LedgerCurrency.RIAL, owes ? new Prisma.Decimal(order.flatRialCharge) : ZERO, 'Flat Rial charge (e.g. stones)'],
|
||||
];
|
||||
|
||||
for (const [currency, should, label] of targets) {
|
||||
const posted = await tx.ledgerEntry.groupBy({
|
||||
by: ['direction'],
|
||||
where: { orderId, currency, sourceType: LedgerSourceType.ORDER_STOCK, confirmed: true },
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const sum = (d: LedgerDirection) => posted.find((p) => p.direction === d)?._sum.amount ?? ZERO;
|
||||
const current = new Prisma.Decimal(sum(LedgerDirection.DEBIT)).sub(sum(LedgerDirection.CREDIT));
|
||||
const diff = should.sub(current);
|
||||
if (diff.isZero()) continue;
|
||||
|
||||
await this.postEntry(
|
||||
{
|
||||
customerId: order.customerId,
|
||||
currency,
|
||||
direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
|
||||
sourceType: LedgerSourceType.ORDER_STOCK,
|
||||
amount: diff.abs(),
|
||||
orderId: order.id,
|
||||
confirmed: true,
|
||||
note: owes ? `${label} — order delivered (${order.status})` : `${label} reversed — order moved to ${order.status}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a physical repayment instrument into gold grams and posts a
|
||||
* pending (unconfirmed) CREDIT entry against the customer's gold debt.
|
||||
* Formulas preserved verbatim from legacy CashController::store. Note:
|
||||
* legacy auto-confirms (confirmed=1) every Cash row at creation — posting
|
||||
* this as pending instead is an intentional design change, not a mirror of
|
||||
* legacy's default.
|
||||
*/
|
||||
async postGoldRepayment(
|
||||
params: {
|
||||
customerId: string;
|
||||
instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
|
||||
rawValue: Prisma.Decimal.Value;
|
||||
karatPerMille?: number;
|
||||
pricePerGramRial?: Prisma.Decimal.Value;
|
||||
note?: string;
|
||||
postedByAdminUserId?: string;
|
||||
},
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
const raw = new Prisma.Decimal(params.rawValue);
|
||||
let goldGramsValue: Prisma.Decimal;
|
||||
let sourceType: LedgerSourceType;
|
||||
|
||||
switch (params.instrument) {
|
||||
case 'MELTED_GOLD': {
|
||||
if (!params.karatPerMille) {
|
||||
throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
|
||||
}
|
||||
// legacy: value = original_value * ayar / 750
|
||||
goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE);
|
||||
sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD;
|
||||
break;
|
||||
}
|
||||
case 'CASH': {
|
||||
if (!params.pricePerGramRial) {
|
||||
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
|
||||
}
|
||||
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
|
||||
// flagged in migration analysis as a formula worth stakeholder review.
|
||||
const pricePerGram = new Prisma.Decimal(params.pricePerGramRial);
|
||||
goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL));
|
||||
sourceType = LedgerSourceType.REPAYMENT_CASH;
|
||||
break;
|
||||
}
|
||||
case 'TRANSFER':
|
||||
goldGramsValue = raw;
|
||||
sourceType = LedgerSourceType.REPAYMENT_TRANSFER;
|
||||
break;
|
||||
case 'CHEQUE':
|
||||
goldGramsValue = raw;
|
||||
sourceType = LedgerSourceType.REPAYMENT_CHEQUE;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.postEntry(
|
||||
{
|
||||
customerId: params.customerId,
|
||||
currency: LedgerCurrency.GOLD_GRAM,
|
||||
direction: LedgerDirection.CREDIT,
|
||||
sourceType,
|
||||
amount: goldGramsValue,
|
||||
karatPerMille: params.karatPerMille,
|
||||
pricePerGramRialAtEntry: params.pricePerGramRial,
|
||||
note: params.note,
|
||||
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct Rial-debt repayment. Not present in the legacy system (its
|
||||
* rial_remain had no repayment offset at all — a gap flagged during
|
||||
* migration analysis) but supported here since the new ledger is symmetric.
|
||||
*/
|
||||
async postRialRepayment(
|
||||
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminUserId?: string },
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
return this.postEntry(
|
||||
{
|
||||
customerId: params.customerId,
|
||||
currency: LedgerCurrency.RIAL,
|
||||
direction: LedgerDirection.CREDIT,
|
||||
sourceType: LedgerSourceType.REPAYMENT_CASH,
|
||||
amount: params.amount,
|
||||
note: params.note,
|
||||
confirmed: false,
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
async postAdjustment(
|
||||
params: {
|
||||
customerId: string;
|
||||
currency: LedgerCurrency;
|
||||
direction: LedgerDirection;
|
||||
amount: Prisma.Decimal.Value;
|
||||
note: string;
|
||||
postedByAdminUserId: string;
|
||||
},
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
return this.postEntry(
|
||||
{
|
||||
customerId: params.customerId,
|
||||
currency: params.currency,
|
||||
direction: params.direction,
|
||||
sourceType: LedgerSourceType.ADJUSTMENT,
|
||||
amount: params.amount,
|
||||
note: params.note,
|
||||
confirmed: true,
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
async getBalance(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true, goldDebtGrams: true, fiatDebtRial: true, goldCreditLimitGrams: true },
|
||||
});
|
||||
if (!customer) throw new NotFoundException('Customer not found');
|
||||
return {
|
||||
customerId: customer.id,
|
||||
goldDebtGrams: customer.goldDebtGrams,
|
||||
fiatDebtRial: customer.fiatDebtRial,
|
||||
goldCreditLimitGrams: customer.goldCreditLimitGrams,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes cached balances from confirmed LedgerEntry rows and overwrites
|
||||
* the cache — a self-healing reconciliation, run periodically or on demand,
|
||||
* unlike legacy's recompute-on-every-write (Customer::updateRemain).
|
||||
*/
|
||||
async reconcileBalance(customerId: string) {
|
||||
const [goldDebits, goldCredits, rialDebits, rialCredits] = await Promise.all([
|
||||
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.DEBIT),
|
||||
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.CREDIT),
|
||||
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.DEBIT),
|
||||
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.CREDIT),
|
||||
]);
|
||||
|
||||
return this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: {
|
||||
goldDebtGrams: goldDebits.sub(goldCredits),
|
||||
fiatDebtRial: rialDebits.sub(rialCredits),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async sumConfirmed(customerId: string, currency: LedgerCurrency, direction: LedgerDirection) {
|
||||
const result = await this.prisma.ledgerEntry.aggregate({
|
||||
where: { customerId, currency, direction, confirmed: true },
|
||||
_sum: { amount: true },
|
||||
});
|
||||
return result._sum.amount ?? new Prisma.Decimal(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminBranchesService } from './admin-branches.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (BranchController + WorkshopController):
|
||||
* POST branch/table
|
||||
* POST branch, workshop -> create
|
||||
* GET/PUT branch/{id}, workshop/{id}
|
||||
* GET branch/active/{id}, workshop/active/{id} -> toggle
|
||||
* GET user/list -> the branch-manager picker
|
||||
*/
|
||||
@Controller('admin/branches')
|
||||
@SuperAdminOnly()
|
||||
export class AdminBranchesController {
|
||||
constructor(private readonly branches: AdminBranchesService) {}
|
||||
|
||||
// ── branches ──────────────────────────────────────────────────────
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('branch.list')
|
||||
listBranches(@Body() query: TableQueryDto) {
|
||||
return this.branches.listBranches(query);
|
||||
}
|
||||
|
||||
@Get('user-list')
|
||||
@RequirePermission('branch.create')
|
||||
userList() {
|
||||
return this.branches.userList();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('branch.create')
|
||||
createBranch(@Body() data: Record<string, unknown>) {
|
||||
return this.branches.createBranch(data);
|
||||
}
|
||||
|
||||
@Get('active/:id')
|
||||
@RequirePermission('branch.list')
|
||||
toggleBranchActive(@Param('id') id: string) {
|
||||
return this.branches.toggleBranchActive(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('branch.list')
|
||||
findBranch(@Param('id') id: string) {
|
||||
return this.branches.findBranch(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('branch.list')
|
||||
updateBranch(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.branches.updateBranch(id, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminBranchesController } from './admin-branches.controller';
|
||||
import { AdminBranchesService } from './admin-branches.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminBranchesController],
|
||||
providers: [AdminBranchesService],
|
||||
// The workshops module reuses this service — branches and workshops are
|
||||
// near-identical screens and share their logic.
|
||||
exports: [AdminBranchesService],
|
||||
})
|
||||
export class AdminBranchesModule {}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AdminRole, Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { NO_PASSWORD_YET_HASH } from '../auth/password-hash';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Branches and workshops — legacy views/branch/* and views/workshop/*, served by
|
||||
* BranchController and WorkshopController:
|
||||
* POST branch/table | workshop/table -> the grids
|
||||
* POST branch | workshop -> create
|
||||
* GET branch/{id} | workshop/{id} -> one row
|
||||
* PUT branch/{id} | workshop/{id} -> update
|
||||
* GET branch/active/{id} | workshop/active/{id} -> toggle active
|
||||
*
|
||||
* The two screens differ in how their manager is handled, and that difference is
|
||||
* preserved: a BRANCH picks an existing admin (`user_id`), while a WORKSHOP
|
||||
* creates its supervisor inline from three fields (user_fname/lname/phone).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminBranchesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
// ── Branches ───────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST branch/table`. */
|
||||
async listBranches(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const aliases: Record<string, string> = { 'user.name': 'adminUsers.some.fullName' };
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['name', 'address', 'phone'],
|
||||
allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'],
|
||||
defaultOrderBy: { name: 'asc' },
|
||||
});
|
||||
void aliases;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.branch.findMany({
|
||||
where: where as Prisma.BranchWhereInput,
|
||||
orderBy: orderBy as Prisma.BranchOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
city: true,
|
||||
adminUsers: { where: { isActive: true }, take: 1 },
|
||||
_count: { select: { adminUsers: true, orders: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
address: b.address,
|
||||
phone: b.phone,
|
||||
active: b.isActive ? 1 : 0,
|
||||
is_active: b.isActive,
|
||||
city_id: b.cityId,
|
||||
city: b.city ? { id: b.city.id, title: b.city.name } : null,
|
||||
// legacy column key is `user.name` — the branch manager
|
||||
user: b.adminUsers[0]
|
||||
? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName }
|
||||
: null,
|
||||
users_count: b._count.adminUsers,
|
||||
orders_count: b._count.orders,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async findBranch(id: string) {
|
||||
const b = await this.prisma.branch.findUnique({
|
||||
where: { id },
|
||||
include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } },
|
||||
});
|
||||
if (!b) throw new NotFoundException('شعبه یافت نشد');
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
address: b.address,
|
||||
phone: b.phone,
|
||||
is_active: b.isActive,
|
||||
city_id: b.cityId,
|
||||
user_id: b.adminUsers[0]?.id ?? null,
|
||||
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST branch` — the manager is an EXISTING admin picked by id. */
|
||||
async createBranch(data: Record<string, unknown>) {
|
||||
const name = String(data.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException('نام شعبه الزامی است');
|
||||
|
||||
const branch = await this.prisma.branch.create({
|
||||
data: {
|
||||
name,
|
||||
address: (data.address as string) || null,
|
||||
phone: (data.phone as string) || null,
|
||||
cityId: (data.city_id as string) || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.user_id) {
|
||||
await this.assignBranchManager(branch.id, String(data.user_id));
|
||||
}
|
||||
return this.findBranch(branch.id);
|
||||
}
|
||||
|
||||
/** Legacy `PUT branch/{id}`. */
|
||||
async updateBranch(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.branch.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('شعبه یافت نشد');
|
||||
|
||||
const patch: Prisma.BranchUpdateInput = {};
|
||||
if (data.name !== undefined) patch.name = String(data.name);
|
||||
if (data.address !== undefined) patch.address = (data.address as string) || null;
|
||||
if (data.phone !== undefined) patch.phone = (data.phone as string) || null;
|
||||
if (data.city_id !== undefined) {
|
||||
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
|
||||
}
|
||||
|
||||
await this.prisma.branch.update({ where: { id }, data: patch });
|
||||
|
||||
if (data.user_id !== undefined) {
|
||||
await this.assignBranchManager(id, (data.user_id as string) || null);
|
||||
}
|
||||
return this.findBranch(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the named admin onto this branch and clears whoever was on it before,
|
||||
* so a branch keeps exactly one manager. Legacy did the same through its
|
||||
* `->update(['branch_id' => null])` / `->update(['branch_id' => $id])` pair.
|
||||
*/
|
||||
private async assignBranchManager(branchId: string, userId: string | null) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } });
|
||||
if (userId) {
|
||||
const user = await tx.adminUser.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('کاربر انتخابشده یافت نشد');
|
||||
await tx.adminUser.update({ where: { id: userId }, data: { branchId } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET branch/active/{id}` — a toggle. */
|
||||
async toggleBranchActive(id: string) {
|
||||
const b = await this.prisma.branch.findUnique({ where: { id } });
|
||||
if (!b) throw new NotFoundException('شعبه یافت نشد');
|
||||
return this.prisma.branch.update({
|
||||
where: { id },
|
||||
data: { isActive: !b.isActive },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workshops ──────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST workshop/table`. */
|
||||
async listWorkshops(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['name', 'address', 'phone'],
|
||||
allowed: ['id', 'name', 'address', 'phone', 'isActive'],
|
||||
defaultOrderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.workshop.findMany({
|
||||
where: where as Prisma.WorkshopWhereInput,
|
||||
orderBy: orderBy as Prisma.WorkshopOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { supervisor: true, _count: { select: { preOrders: true } } },
|
||||
}),
|
||||
this.prisma.workshop.count({ where: where as Prisma.WorkshopWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((w) => this.workshopRow(w)),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
private workshopRow(
|
||||
w: Prisma.WorkshopGetPayload<{
|
||||
include: { supervisor: true; _count: { select: { preOrders: true } } };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
address: w.address,
|
||||
phone: w.phone,
|
||||
active: w.isActive ? 1 : 0,
|
||||
is_active: w.isActive,
|
||||
user: w.supervisor ? { id: w.supervisor.id, name: w.supervisor.fullName } : null,
|
||||
user_id: w.supervisorId,
|
||||
preorders_count: w._count.preOrders,
|
||||
};
|
||||
}
|
||||
|
||||
async findWorkshop(id: string) {
|
||||
const w = await this.prisma.workshop.findUnique({
|
||||
where: { id },
|
||||
include: { supervisor: true, _count: { select: { preOrders: true } } },
|
||||
});
|
||||
if (!w) throw new NotFoundException('کارگاه یافت نشد');
|
||||
|
||||
const row = this.workshopRow(w);
|
||||
// The edit form splits the supervisor back into its three fields.
|
||||
const parts = String(w.supervisor?.fullName ?? '').trim().split(' ');
|
||||
return {
|
||||
...row,
|
||||
workshop_name: w.name,
|
||||
workshop_address: w.address,
|
||||
workshop_phone: w.phone,
|
||||
user_fname: parts[0] ?? '',
|
||||
user_lname: parts.slice(1).join(' '),
|
||||
user_phone: w.supervisor?.mobile ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST workshop` — creates the workshop AND its supervisor together,
|
||||
* from user_fname / user_lname / user_phone.
|
||||
*/
|
||||
async createWorkshop(data: Record<string, unknown>) {
|
||||
const name = String(data.workshop_name ?? data.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException('نام کارگاه الزامی است');
|
||||
|
||||
const supervisorId = await this.upsertSupervisor(data, null);
|
||||
|
||||
const w = await this.prisma.workshop.create({
|
||||
data: {
|
||||
name,
|
||||
address: (data.workshop_address as string) || (data.address as string) || null,
|
||||
phone: (data.workshop_phone as string) || (data.phone as string) || null,
|
||||
supervisorId,
|
||||
},
|
||||
});
|
||||
return this.findWorkshop(w.id);
|
||||
}
|
||||
|
||||
/** Legacy `PUT workshop/{id}`. */
|
||||
async updateWorkshop(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.workshop.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('کارگاه یافت نشد');
|
||||
|
||||
const patch: Prisma.WorkshopUpdateInput = {};
|
||||
if (data.workshop_name !== undefined || data.name !== undefined) {
|
||||
patch.name = String(data.workshop_name ?? data.name);
|
||||
}
|
||||
if (data.workshop_address !== undefined || data.address !== undefined) {
|
||||
patch.address = ((data.workshop_address ?? data.address) as string) || null;
|
||||
}
|
||||
if (data.workshop_phone !== undefined || data.phone !== undefined) {
|
||||
patch.phone = ((data.workshop_phone ?? data.phone) as string) || null;
|
||||
}
|
||||
|
||||
const supervisorId = await this.upsertSupervisor(data, existing.supervisorId);
|
||||
if (supervisorId !== undefined) {
|
||||
patch.supervisor = supervisorId
|
||||
? { connect: { id: supervisorId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
await this.prisma.workshop.update({ where: { id }, data: patch });
|
||||
return this.findWorkshop(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the workshop's supervisor from the inline fields.
|
||||
* Returns the id to link, or `undefined` when the form sent no supervisor data
|
||||
* at all (so an update leaves the existing link alone).
|
||||
*/
|
||||
private async upsertSupervisor(
|
||||
data: Record<string, unknown>,
|
||||
currentId: string | null,
|
||||
): Promise<string | null | undefined> {
|
||||
const fname = String(data.user_fname ?? '').trim();
|
||||
const lname = String(data.user_lname ?? '').trim();
|
||||
const phone = String(data.user_phone ?? '').trim();
|
||||
|
||||
if (!fname && !lname && !phone) return undefined;
|
||||
if (!phone) throw new BadRequestException('موبایل سرپرست کارگاه الزامی است');
|
||||
|
||||
const fullName = `${fname} ${lname}`.trim();
|
||||
|
||||
if (currentId) {
|
||||
const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } });
|
||||
if (current) {
|
||||
if (phone !== current.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
||||
if (clash && clash.id !== currentId) {
|
||||
throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
await this.prisma.adminUser.update({
|
||||
where: { id: currentId },
|
||||
data: { fullName, mobile: phone },
|
||||
});
|
||||
return currentId;
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse an existing admin with that mobile rather than colliding on the
|
||||
// unique constraint.
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
||||
if (existing) return existing.id;
|
||||
|
||||
const created = await this.prisma.adminUser.create({
|
||||
data: {
|
||||
fullName,
|
||||
mobile: phone,
|
||||
// No password is set: the supervisor cannot sign in until one is issued.
|
||||
passwordHash: NO_PASSWORD_YET_HASH,
|
||||
role: AdminRole.WORKSHOP_SUPERVISOR,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Legacy `GET workshop/active/{id}` — a toggle. */
|
||||
async toggleWorkshopActive(id: string) {
|
||||
const w = await this.prisma.workshop.findUnique({ where: { id } });
|
||||
if (!w) throw new NotFoundException('کارگاه یافت نشد');
|
||||
return this.prisma.workshop.update({
|
||||
where: { id },
|
||||
data: { isActive: !w.isActive },
|
||||
});
|
||||
}
|
||||
|
||||
/** The admin picker the branch form uses — legacy `GET user/list`. */
|
||||
async userList() {
|
||||
const rows = await this.prisma.adminUser.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true, branchId: true },
|
||||
});
|
||||
return rows.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.fullName,
|
||||
mobile: u.mobile,
|
||||
branch_id: u.branchId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { AdminCategoriesService } from './admin-categories.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CategoryController + the collection screens):
|
||||
* POST category -> the grid (a read expressed as POST, legacy's shape)
|
||||
* POST category/ -> create
|
||||
* PUT category/{id} -> update
|
||||
* DELETE category/{id} -> delete
|
||||
* GET ciny -> collections ("سینی")
|
||||
*/
|
||||
@Controller('admin/categories')
|
||||
export class AdminCategoriesController {
|
||||
constructor(private readonly categories: AdminCategoriesService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('collection.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.categories.list(query);
|
||||
}
|
||||
|
||||
@Get('tree')
|
||||
@RequirePermission('collection.list')
|
||||
tree(@Query('type') type?: string) {
|
||||
return this.categories.tree(type ? Number(type) : undefined);
|
||||
}
|
||||
|
||||
@Get('type-options')
|
||||
@RequirePermission('collection.list')
|
||||
typeOptions() {
|
||||
return this.categories.typeOptions();
|
||||
}
|
||||
|
||||
// ── collections ("سینی") ─────────────────────────────────────────
|
||||
@Post('collections/list')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('collection.list')
|
||||
listCollections(@Body() query: TableQueryDto) {
|
||||
return this.categories.listCollections(query);
|
||||
}
|
||||
|
||||
@Post('collections')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('collection.create')
|
||||
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
|
||||
return this.categories.createCollection(body);
|
||||
}
|
||||
|
||||
@Put('collections/:id')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('collection.list.edit')
|
||||
updateCollection(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { title?: string; description?: string; is_active?: boolean },
|
||||
) {
|
||||
return this.categories.updateCollection(id, body);
|
||||
}
|
||||
|
||||
@Delete('collections/:id')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('collection.list.edit')
|
||||
removeCollection(@Param('id') id: string) {
|
||||
return this.categories.removeCollection(id);
|
||||
}
|
||||
|
||||
// ── categories ────────────────────────────────────────────────────
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('collection.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.categories.create(data);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('collection.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.categories.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('collection.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.categories.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('collection.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categories.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCategoriesController } from './admin-categories.controller';
|
||||
import { AdminCategoriesService } from './admin-categories.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCategoriesController],
|
||||
providers: [AdminCategoriesService],
|
||||
})
|
||||
export class AdminCategoriesModule {}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Category + Collection administration — legacy views/apps/category/*
|
||||
* (CategoryController: `POST category`, `POST category/`, `PUT category/{id}`,
|
||||
* `DELETE category/{id}`) and the "سینی" collection screens.
|
||||
*
|
||||
* Legacy Category.type: 1 سایت | 2 اپلیکشن موبایل.
|
||||
*/
|
||||
const TYPE_LABELS: Record<number, string> = {
|
||||
1: 'سایت',
|
||||
2: 'اپلیکشن موبایل',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminCategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'title',
|
||||
'type',
|
||||
'parentId',
|
||||
'parent.title',
|
||||
// legacy filter keys
|
||||
'type_str',
|
||||
];
|
||||
|
||||
/** Legacy `POST category` — the category grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) =>
|
||||
f.field === 'type_str' ? { ...f, field: 'type', value: Number(f.value) } : f,
|
||||
),
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['title'],
|
||||
allowed: AdminCategoriesService.ALLOWED,
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where: where as Prisma.CategoryWhereInput,
|
||||
orderBy: orderBy as Prisma.CategoryOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { parent: true, _count: { select: { products: true, children: true } } },
|
||||
}),
|
||||
this.prisma.category.count({ where: where as Prisma.CategoryWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
c: Prisma.CategoryGetPayload<{
|
||||
include: { parent: true; _count: { select: { products: true; children: true } } };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type,
|
||||
type_str: TYPE_LABELS[c.type] ?? '-',
|
||||
image: c.imageUrl,
|
||||
icon: c.iconUrl,
|
||||
meta: c.metaTag,
|
||||
body_html: c.description,
|
||||
parent_id_id: c.parentId,
|
||||
parent: c.parent ? { id: c.parent.id, title: c.parent.title } : null,
|
||||
products_count: c._count.products,
|
||||
children_count: c._count.children,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const c = await this.prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { parent: true, _count: { select: { products: true, children: true } } },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
|
||||
return this.toRow(c);
|
||||
}
|
||||
|
||||
/** The parent picker — legacy loaded the full list and built a tree client-side. */
|
||||
async tree(type?: number) {
|
||||
const rows = await this.prisma.category.findMany({
|
||||
where: type ? { type } : {},
|
||||
orderBy: { title: 'asc' },
|
||||
include: { parent: true },
|
||||
});
|
||||
return rows.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type,
|
||||
parent_id_id: c.parentId,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Legacy `POST category/`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const title = String(data.title ?? '').trim();
|
||||
if (!title) throw new BadRequestException('عنوان دسته الزامی است');
|
||||
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
title,
|
||||
type: data.type !== undefined ? Number(data.type) : 1,
|
||||
imageUrl: (data.image as string) || null,
|
||||
iconUrl: (data.icon as string) || null,
|
||||
metaTag: (data.meta as string) || null,
|
||||
description: (data.body_html as string) || null,
|
||||
parentId: (data.parent_id_id as string) || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `PUT category/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('دسته بندی یافت نشد');
|
||||
|
||||
// A category cannot be its own parent, nor a descendant's child — that would
|
||||
// create a cycle in the tree and hang any recursive render. Legacy had no
|
||||
// such guard.
|
||||
const newParent = data.parent_id_id as string | undefined;
|
||||
if (newParent) {
|
||||
if (newParent === id) {
|
||||
throw new BadRequestException('یک دسته نمیتواند پدر خودش باشد');
|
||||
}
|
||||
if (await this.isDescendant(newParent, id)) {
|
||||
throw new BadRequestException('دستهی انتخابشده زیرمجموعهی همین دسته است');
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Prisma.CategoryUpdateInput = {};
|
||||
if (data.title !== undefined) patch.title = String(data.title);
|
||||
if (data.type !== undefined) patch.type = Number(data.type);
|
||||
if (data.image !== undefined) patch.imageUrl = (data.image as string) || null;
|
||||
if (data.icon !== undefined) patch.iconUrl = (data.icon as string) || null;
|
||||
if (data.meta !== undefined) patch.metaTag = (data.meta as string) || null;
|
||||
if (data.body_html !== undefined) patch.description = (data.body_html as string) || null;
|
||||
if (data.parent_id_id !== undefined) {
|
||||
patch.parent = newParent ? { connect: { id: newParent } } : { disconnect: true };
|
||||
}
|
||||
|
||||
return this.prisma.category.update({ where: { id }, data: patch });
|
||||
}
|
||||
|
||||
/** Walks up from `candidate` to see whether `ancestorId` is above it. */
|
||||
private async isDescendant(candidate: string, ancestorId: string): Promise<boolean> {
|
||||
let cursor: string | null = candidate;
|
||||
const seen = new Set<string>();
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const row: { parentId: string | null } | null = await this.prisma.category.findUnique({
|
||||
where: { id: cursor },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!row?.parentId) return false;
|
||||
if (row.parentId === ancestorId) return true;
|
||||
cursor = row.parentId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Legacy `DELETE category/{id}`. */
|
||||
async remove(id: string) {
|
||||
const c = await this.prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true, children: true } } },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
|
||||
|
||||
// Refuses while anything still hangs off it. Legacy deleted regardless,
|
||||
// which orphaned products and left child categories pointing at a missing
|
||||
// parent. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
if (c._count.products > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف ممکن نیست: ${c._count.products} محصول در این دسته قرار دارد.`,
|
||||
);
|
||||
}
|
||||
if (c._count.children > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف ممکن نیست: این دسته ${c._count.children} زیردسته دارد.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.category.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
typeOptions() {
|
||||
return Object.entries(TYPE_LABELS).map(([value, title]) => ({ value: Number(value), title }));
|
||||
}
|
||||
|
||||
// ── Collections ("سینی") ───────────────────────────────────────────
|
||||
|
||||
/** Legacy `GET ciny` / the collection grid. */
|
||||
async listCollections(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title'],
|
||||
allowed: ['id', 'title', 'isActive'],
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.collection.findMany({
|
||||
where: where as Prisma.CollectionWhereInput,
|
||||
orderBy: orderBy as Prisma.CollectionOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { _count: { select: { products: true } } },
|
||||
}),
|
||||
this.prisma.collection.count({ where: where as Prisma.CollectionWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
is_active: c.isActive,
|
||||
products_count: c._count.products,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async createCollection(data: { title: string; description?: string; is_active?: boolean }) {
|
||||
if (!data.title?.trim()) throw new BadRequestException('عنوان سینی الزامی است');
|
||||
return this.prisma.collection.create({
|
||||
data: {
|
||||
title: data.title.trim(),
|
||||
description: data.description ?? null,
|
||||
isActive: data.is_active ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateCollection(
|
||||
id: string,
|
||||
data: { title?: string; description?: string; is_active?: boolean },
|
||||
) {
|
||||
const existing = await this.prisma.collection.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سینی یافت نشد');
|
||||
|
||||
return this.prisma.collection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.title !== undefined ? { title: data.title } : {}),
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.is_active !== undefined ? { isActive: data.is_active } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeCollection(id: string) {
|
||||
const c = await this.prisma.collection.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException('سینی یافت نشد');
|
||||
|
||||
// The join rows are this collection's own membership list, so clearing them
|
||||
// removes no product — unlike the category case above.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.collectionProduct.deleteMany({ where: { collectionId: id } }),
|
||||
this.prisma.collection.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Legacy `POST cities/list` — the city lookup the table filters use.
|
||||
* Returns `{ rows }`, matching what the users store's setCities() reads.
|
||||
*
|
||||
* Deliberately NOT permission-gated: legacy called this from table models at
|
||||
* module load, before any rid check, and it exposes nothing sensitive.
|
||||
*/
|
||||
@Controller('admin/cities')
|
||||
export class AdminCitiesController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
async list() {
|
||||
const cities = await this.prisma.city.findMany({ orderBy: { name: 'asc' } });
|
||||
// legacy shape: the cities row — { id, title, parent, percent, percent_aux }.
|
||||
// `parent` lets the admin pickers draw legacy's tree (PERS-02).
|
||||
return {
|
||||
rows: cities.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
parent: c.parentId,
|
||||
percent: c.percent,
|
||||
percent_aux: c.percentAux,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCitiesController } from './admin-cities.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCitiesController],
|
||||
})
|
||||
export class AdminCitiesModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
|
||||
import { AdminCommentsService } from './admin-comments.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CommentController, routes/admin.php:149-152):
|
||||
* POST comment/table -> the moderation grid
|
||||
* POST comment/sts -> approve / reject
|
||||
* POST comment -> staff reply
|
||||
* DELETE comment/{id}
|
||||
*
|
||||
* All gated on the legacy rid `comment`.
|
||||
*/
|
||||
@Controller('admin/comments')
|
||||
export class AdminCommentsController {
|
||||
constructor(private readonly comments: AdminCommentsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('comment')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.comments.list(query);
|
||||
}
|
||||
|
||||
@Get('status-options')
|
||||
@RequirePermission('comment')
|
||||
statusOptions() {
|
||||
return this.comments.statusOptions();
|
||||
}
|
||||
|
||||
@Post('sts')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('comment')
|
||||
changeStatus(@Body() body: { id: string; sts: number }) {
|
||||
return this.comments.changeStatus(body.id, body.sts);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('comment')
|
||||
create(
|
||||
@Body() body: { product_id: string; customer_id: string; comment: string; reply_to?: string },
|
||||
) {
|
||||
return this.comments.create(body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('comment')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.comments.findOne(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('comment')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.comments.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCommentsController } from './admin-comments.controller';
|
||||
import { AdminCommentsService } from './admin-comments.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCommentsController],
|
||||
providers: [AdminCommentsService],
|
||||
})
|
||||
export class AdminCommentsModule {}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CommentStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Product comment moderation — legacy views/customer/Comments/Comments.vue,
|
||||
* served by CommentController (`POST comment/table`, `POST comment/sts`,
|
||||
* `POST comment`, `DELETE comment/{id}`).
|
||||
*
|
||||
* Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده'].
|
||||
* The numeric codes are preserved on the wire (`sts`) so the ported view's
|
||||
* status dropdown posts unchanged, while the DB stores an enum.
|
||||
*/
|
||||
const STATUS_BY_CODE: Record<number, CommentStatus> = {
|
||||
1: CommentStatus.PENDING,
|
||||
2: CommentStatus.APPROVED,
|
||||
3: CommentStatus.REJECTED,
|
||||
};
|
||||
|
||||
const CODE_BY_STATUS: Record<CommentStatus, number> = {
|
||||
PENDING: 1,
|
||||
APPROVED: 2,
|
||||
REJECTED: 3,
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<CommentStatus, string> = {
|
||||
PENDING: 'در انتظار تایید',
|
||||
APPROVED: 'تایید شده',
|
||||
REJECTED: 'رد شده',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminCommentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'body',
|
||||
'customer.fullName',
|
||||
'customer.mobile',
|
||||
'product.title',
|
||||
// legacy filter keys
|
||||
'sts',
|
||||
'comment',
|
||||
'caj',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
sts: 'status',
|
||||
comment: 'body',
|
||||
caj: 'createdAt',
|
||||
uaj: 'updatedAt',
|
||||
'customer.name': 'customer.fullName',
|
||||
'product.name': 'product.title',
|
||||
};
|
||||
|
||||
/** Legacy `POST comment/table`. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) =>
|
||||
f ? (AdminCommentsService.FIELD_ALIASES[f] ?? f) : f;
|
||||
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
// legacy sent the numeric status code
|
||||
if (mapped.field === 'status' && mapped.value !== undefined) {
|
||||
mapped.value = STATUS_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['body', 'customer.fullName', 'product.title'],
|
||||
allowed: AdminCommentsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.comment.findMany({
|
||||
where: where as Prisma.CommentWhereInput,
|
||||
orderBy: orderBy as Prisma.CommentOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, product: true, replyTo: true, replies: true },
|
||||
}),
|
||||
this.prisma.comment.count({ where: where as Prisma.CommentWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
c: Prisma.CommentGetPayload<{
|
||||
include: { customer: true; product: true; replyTo: true; replies?: true };
|
||||
}> & { replies?: { id: string; body: string }[] },
|
||||
) {
|
||||
return {
|
||||
id: c.id,
|
||||
comment: c.body,
|
||||
body: c.body,
|
||||
sts: CODE_BY_STATUS[c.status],
|
||||
sts_str: STATUS_LABELS[c.status],
|
||||
status: c.status,
|
||||
reply_to: c.replyToId,
|
||||
// legacy view reads: text.length > 0 && text[0].comment !== null
|
||||
reply: (c.replies ?? []).map((r) => ({ id: r.id, comment: r.body })),
|
||||
product_id: c.productId,
|
||||
customer_id: c.customerId,
|
||||
product: { id: c.product.id, title: c.product.title, name: c.product.title },
|
||||
customer: {
|
||||
id: c.customer.id,
|
||||
name: c.customer.fullName,
|
||||
// D0 — real name parts
|
||||
fname: c.customer.firstName ?? c.customer.fullName,
|
||||
lname: c.customer.lastName ?? '',
|
||||
mobile: c.customer.mobile,
|
||||
},
|
||||
caj: c.createdAt,
|
||||
uaj: c.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const c = await this.prisma.comment.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, product: true, replyTo: true, replies: true },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
|
||||
return this.toRow(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST comment/sts` — moderate a comment.
|
||||
* Payload is `{ id, sts }` where sts is 1/2/3.
|
||||
*/
|
||||
async changeStatus(id: string, sts: number) {
|
||||
const c = await this.prisma.comment.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
|
||||
|
||||
const status = STATUS_BY_CODE[Number(sts)];
|
||||
if (!status) {
|
||||
throw new NotFoundException('وضعیت نامعتبر است');
|
||||
}
|
||||
return this.prisma.comment.update({ where: { id }, data: { status } });
|
||||
}
|
||||
|
||||
/** Legacy `POST comment` — post a staff reply to a comment. */
|
||||
async create(data: {
|
||||
product_id: string;
|
||||
customer_id: string;
|
||||
comment: string;
|
||||
reply_to?: string;
|
||||
}) {
|
||||
return this.prisma.comment.create({
|
||||
data: {
|
||||
productId: data.product_id,
|
||||
customerId: data.customer_id,
|
||||
body: data.comment,
|
||||
replyToId: data.reply_to ?? null,
|
||||
// A staff reply is visible immediately; legacy did the same.
|
||||
status: CommentStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `DELETE comment/{id}`. */
|
||||
async remove(id: string) {
|
||||
const c = await this.prisma.comment.findUnique({
|
||||
where: { id },
|
||||
include: { replies: true },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
|
||||
|
||||
// Remove replies first so the self-relation does not block the delete.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.comment.deleteMany({ where: { replyToId: id } }),
|
||||
this.prisma.comment.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Farsi labels for the status dropdown, keyed by legacy's numeric codes. */
|
||||
statusOptions() {
|
||||
return Object.entries(STATUS_LABELS).map(([status, title]) => ({
|
||||
value: CODE_BY_STATUS[status as CommentStatus],
|
||||
title,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Wholesale customer lists. Legacy endpoints, one per near-identical view:
|
||||
* POST customer -> OmdeList
|
||||
* POST banker -> BankerList
|
||||
* POST shopkeeper -> ShopkeeperList
|
||||
* POST major/verify/table -> OmdeNonVerifiedList
|
||||
* POST major/archive/table -> OmdeArchivedList
|
||||
* POST customer/toggle-active, customer/toggle-sts, major/verify
|
||||
* GET major/archive/{id}, major/dashboard/{id}
|
||||
*/
|
||||
@Controller('admin/customers-omde')
|
||||
export class AdminCustomersController {
|
||||
constructor(private readonly customers: AdminCustomersService) {}
|
||||
|
||||
private list(query: TableQueryDto, scope: OmdeScope) {
|
||||
return this.customers.list(query, scope);
|
||||
}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listAll(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'all');
|
||||
}
|
||||
|
||||
@Post('banker/list')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listBanker(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'banker');
|
||||
}
|
||||
|
||||
@Post('shopkeeper/list')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listShopkeeper(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'shopkeeper');
|
||||
}
|
||||
|
||||
@Post('non-verified/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listNonVerified(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'non_verified');
|
||||
}
|
||||
|
||||
@Post('archived/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listArchived(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'archived');
|
||||
}
|
||||
|
||||
/** Legacy POST single-customer — the retail customer list. */
|
||||
@Post('single/list')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly({ allowBranch: true })
|
||||
@RequirePermission('persons.list.client.single')
|
||||
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.customers.listSingle(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
|
||||
@Post('single')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly({ allowBranch: true })
|
||||
@RequirePermission('persons.create')
|
||||
createSingle(@Body() data: Record<string, unknown>) {
|
||||
return this.customers.createSingle(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET query/reagent` — the sales-expert picker used by the customer
|
||||
* form. Returns a plain array of {id, name}, as legacy did.
|
||||
*/
|
||||
@Get('query/reagent')
|
||||
@RequirePermission('persons.create')
|
||||
queryReagents() {
|
||||
return this.customers.queryReagents();
|
||||
}
|
||||
|
||||
/** Legacy POST wallet/charge. */
|
||||
@Post('wallet/charge')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('wallet')
|
||||
chargeWallet(@Body() body: { customer_id: string; value: string | number }) {
|
||||
return this.customers.chargeWallet(body);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.customers.create(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.customers.update(id, data);
|
||||
}
|
||||
|
||||
/** Legacy POST reagent — the sales-expert list. */
|
||||
@Post('reagent/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.sellers')
|
||||
listReagents(@Body() query: TableQueryDto) {
|
||||
return this.customers.listReagents(query);
|
||||
}
|
||||
|
||||
/** A sales agent's own detail screen — see reagentDetail()'s header comment. */
|
||||
@Get('reagent/:id')
|
||||
@RequirePermission('persons.list.sellers.view')
|
||||
reagentDetail(@Param('id') id: string) {
|
||||
return this.customers.reagentDetail(id);
|
||||
}
|
||||
|
||||
/** Legacy `GET single/order/{id}` — that customer's orders. */
|
||||
@Get(':id/orders')
|
||||
@RequirePermission('persons.list.client.omde.view.orders')
|
||||
customerOrders(@Param('id') id: string) {
|
||||
return this.customers.customerOrders(id);
|
||||
}
|
||||
|
||||
/** Legacy `GET single/report/{id}` — that customer's ledger documents. */
|
||||
@Get(':id/documents')
|
||||
@RequirePermission('persons.list.client.omde.view.documents')
|
||||
customerDocuments(@Param('id') id: string) {
|
||||
return this.customers.customerDocuments(id);
|
||||
}
|
||||
|
||||
/** Legacy `GET single/wallet/{id}` — that customer's wallet bonuses. */
|
||||
@Get(':id/wallet')
|
||||
@RequirePermission('wallet')
|
||||
customerWallet(@Param('id') id: string) {
|
||||
return this.customers.customerWallet(id);
|
||||
}
|
||||
|
||||
@Get('dashboard/:id')
|
||||
@RequirePermission('persons.list.client.omde.view')
|
||||
dashboard(@Param('id') id: string) {
|
||||
return this.customers.dashboard(id);
|
||||
}
|
||||
|
||||
@Get('archive/:id')
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
toggleArchive(@Param('id') id: string) {
|
||||
return this.customers.toggleArchive(id);
|
||||
}
|
||||
|
||||
@Post('toggle-active')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
toggleActive(@Body() body: { id: string }) {
|
||||
return this.customers.toggleActive(body.id);
|
||||
}
|
||||
|
||||
@Post('toggle-sts')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
toggleSts(@Body() body: { id: string }) {
|
||||
return this.customers.toggleSts(body.id);
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
verify(
|
||||
@Body()
|
||||
body: {
|
||||
customer_id: string;
|
||||
sts_verify_step_one?: number;
|
||||
sts_verify_step_two?: number;
|
||||
/** Optional, only read on a reject. */
|
||||
reason?: string | null;
|
||||
},
|
||||
) {
|
||||
return this.customers.verify(body);
|
||||
}
|
||||
|
||||
@Post('credit')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
updateCredit(@Body() body: { id: string; gram_level?: string; percent?: string }) {
|
||||
return this.customers.updateCredit(body.id, body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('persons.list.client.omde.view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.customers.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCustomersController } from './admin-customers.controller';
|
||||
import { AdminCustomersService } from './admin-customers.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCustomersController],
|
||||
providers: [AdminCustomersService],
|
||||
exports: [AdminCustomersService],
|
||||
})
|
||||
export class AdminCustomersModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { VerificationStep } from '@prisma/client';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { AdminCustomersService } from './admin-customers.service';
|
||||
|
||||
/**
|
||||
* Owner rulings 2026-10-07:
|
||||
* - a reject stores REJECTED with an optional reason (it used to fall back to
|
||||
* UNVERIFIED / STEP_ONE_SUBMITTED and leave no trace);
|
||||
* - every value sits in its own column: no mobile in nationalCode, no ''
|
||||
* password — an absent national code is NULL, an absent password is the
|
||||
* explicit OTP-only marker.
|
||||
*/
|
||||
describe('AdminCustomersService', () => {
|
||||
function build(current: Record<string, unknown> = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const tx = {
|
||||
customer: {
|
||||
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
writes.push(data);
|
||||
return { ...current, ...data };
|
||||
}),
|
||||
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
writes.push(data);
|
||||
return { id: 'new', ...data };
|
||||
}),
|
||||
findUnique: jest.fn(async () => current),
|
||||
},
|
||||
};
|
||||
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
|
||||
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
|
||||
const sms = { send: jest.fn(async () => undefined) };
|
||||
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never, {} as never);
|
||||
return { service, writes, accounting };
|
||||
}
|
||||
|
||||
describe('verify()', () => {
|
||||
it('a step-one reject stores REJECTED with the reason and time', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_one: 3, reason: ' کارت ملی خوانا نیست ' });
|
||||
expect(writes[0]).toMatchObject({
|
||||
verificationStep: VerificationStep.REJECTED,
|
||||
verificationRejectionReason: 'کارت ملی خوانا نیست',
|
||||
});
|
||||
expect(writes[0].verificationRejectedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('a step-two reject is REJECTED too, and the reason is optional', async () => {
|
||||
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
|
||||
expect(writes[0]).toMatchObject({ verificationStep: VerificationStep.REJECTED, verificationRejectionReason: null });
|
||||
});
|
||||
|
||||
it('accepting a rejected customer clears the reason', async () => {
|
||||
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.REJECTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_one: 2 });
|
||||
expect(writes[0]).toEqual({
|
||||
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
|
||||
verificationRejectionReason: null,
|
||||
verificationRejectedAt: null,
|
||||
// Accepting step one activates the account (an app self-registration
|
||||
// is created inactive).
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('step-two accept verifies and tells accounting once', async () => {
|
||||
const { service, writes, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 2 });
|
||||
expect(writes[0].verificationStep).toBe(VerificationStep.VERIFIED);
|
||||
expect(accounting.customerVerified).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a reject never tells accounting', async () => {
|
||||
const { service, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
|
||||
expect(accounting.customerVerified).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('national code and password', () => {
|
||||
it('create() without a national code or password stores NULL and the OTP-only marker', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.create({ fname: 'علی', lname: 'رضایی', mobile: '09120000000', percent: '3' });
|
||||
expect(writes[0].nationalCode).toBeNull();
|
||||
expect(writes[0].mobile).toBe('09120000000');
|
||||
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
||||
});
|
||||
|
||||
it('create() keeps a typed national code, digits normalised', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.create({ fname: 'علی', mobile: '09120000000', percent: '3', national_code: ' ۰۰۱۲۳۴۵۶۷۸ ' });
|
||||
expect(writes[0].nationalCode).toBe('0012345678');
|
||||
});
|
||||
|
||||
it('createSingle() never copies the mobile into nationalCode', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.createSingle({ fname: 'علی', mobile: '09120000000' });
|
||||
expect(writes[0].nationalCode).toBeNull();
|
||||
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { isSuperAdmin } from '../auth/super-admin';
|
||||
|
||||
/**
|
||||
* Legacy shipped two dashboards behind two rids:
|
||||
* GET get-analytics -> views/DashboardECommerce.vue (wholesale), dashboard.omde
|
||||
* GET get-analytics-tak -> views/DashboardAnalytics.vue (retail), dashboard.tak
|
||||
*
|
||||
* They are now one screen ("پیشخوان") behind one rid (dashboard.view), so this
|
||||
* is one endpoint returning the union of both plus the new chart aggregates.
|
||||
* `omde()`/`tak()` stay as thin aliases to the old paths so nothing that still
|
||||
* calls get-analytics / get-analytics-tak (e.g. a cached branch build) 404s.
|
||||
*/
|
||||
@Controller('admin/dashboard')
|
||||
export class AdminDashboardController {
|
||||
constructor(private readonly dashboard: AdminDashboardService) {}
|
||||
|
||||
@Get('overview')
|
||||
@RequirePermission('dashboard.view')
|
||||
overview(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.dashboard.overview(isSuperAdmin(admin.mobile));
|
||||
}
|
||||
|
||||
/** @deprecated superseded by GET overview; kept as an alias. */
|
||||
@Get('omde')
|
||||
@RequirePermission('dashboard.view')
|
||||
omde() {
|
||||
return this.dashboard.omdeAnalytics();
|
||||
}
|
||||
|
||||
/** @deprecated superseded by GET overview; kept as an alias. */
|
||||
@Get('tak')
|
||||
@RequirePermission('dashboard.view')
|
||||
tak() {
|
||||
return this.dashboard.takAnalytics();
|
||||
}
|
||||
|
||||
/** Legacy views/report/order_item.vue. */
|
||||
@Get('report/order-item')
|
||||
@RequirePermission('report.order.item')
|
||||
orderItemReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
|
||||
return this.dashboard.orderItemReport(from, to);
|
||||
}
|
||||
|
||||
/** Legacy views/report/product.vue. */
|
||||
@Get('report/product')
|
||||
@RequirePermission('report.product')
|
||||
productReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
|
||||
return this.dashboard.productReport(from, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminDashboardController],
|
||||
providers: [AdminDashboardService],
|
||||
})
|
||||
export class AdminDashboardModule {}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { agentSalesWhere } from '../sales-agent/agent-orders';
|
||||
|
||||
/**
|
||||
* Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and
|
||||
* views/DashboardAnalytics.vue (`GET get-analytics-tak`).
|
||||
*
|
||||
* Legacy's ECommerce page is a Vuexy template with most of its English demo
|
||||
* cards COMMENTED OUT (Congratulations, Support Tracker, Product Orders, Sales
|
||||
* Stats, Activity Timeline — verified line by line). What is actually live:
|
||||
* - three 30-day stat cards: new customers, gold sold, order count
|
||||
* - "عملکرد کارشناسان فروش": agents ranked by grams sold
|
||||
* - "میزان فروش": sales volume
|
||||
* - "سفارشات هفته جاری": this week's orders
|
||||
*
|
||||
* So this returns exactly those figures rather than the template's demo series.
|
||||
*
|
||||
* The two dashboards (عمده/تک) were merged into one screen behind one rid
|
||||
* (dashboard.view) — see `overview()`, which returns the union of both legacy
|
||||
* payloads (nothing dropped) plus new chart aggregates built from data that
|
||||
* already exists (order channel, status, GoldPriceQuote, customer debt).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private daysAgo(n: number) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Orders that have reached the debt-recognition point count as "sold". */
|
||||
private static readonly SOLD: Prisma.OrderWhereInput = {
|
||||
status: { in: [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER] },
|
||||
};
|
||||
|
||||
/**
|
||||
* The single merged dashboard endpoint (`GET admin/dashboard/overview`).
|
||||
* Retail («تک فروشی») figures are only built for the super admin; for anyone
|
||||
* else they never leave the server.
|
||||
*/
|
||||
async overview(viewerIsSuperAdmin = false) {
|
||||
const since30 = this.daysAgo(30);
|
||||
const since7 = this.daysAgo(7);
|
||||
|
||||
const [
|
||||
omde,
|
||||
tak,
|
||||
ordersLast30,
|
||||
statusGroups,
|
||||
channelGroups,
|
||||
topProductRows,
|
||||
latestGoldQuote,
|
||||
debtAgg,
|
||||
] = await Promise.all([
|
||||
this.omdeAnalytics(),
|
||||
this.takAnalytics(),
|
||||
|
||||
// روند سفارشها در ۳۰ روز گذشته — one row per order, bucketed by day below.
|
||||
this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: since30 } },
|
||||
select: { createdAt: true, channel: true },
|
||||
}),
|
||||
|
||||
// دونات وضعیت سفارشات (۳۰ روز گذشته)
|
||||
this.prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
where: { createdAt: { gte: since30 } },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// تفکیک عمده/تک (۳۰ روز گذشته)
|
||||
this.prisma.order.groupBy({
|
||||
by: ['channel'],
|
||||
where: { createdAt: { gte: since30 } },
|
||||
_count: true,
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
}),
|
||||
|
||||
// محصولات پرفروش (۳۰ روز گذشته) — by shipped weight, top 6.
|
||||
this.prisma.orderItem.findMany({
|
||||
where: { order: { createdAt: { gte: since30 } } },
|
||||
select: {
|
||||
quantity: true,
|
||||
computedWeightGrams: true,
|
||||
productVariant: { select: { product: { select: { id: true, title: true } } } },
|
||||
},
|
||||
}),
|
||||
|
||||
// نرخ فعلی طلا — legacy's goldPrice widget (18 عیار / 750‰).
|
||||
this.prisma.goldPriceQuote.findFirst({
|
||||
where: { karatPerMille: 750 },
|
||||
orderBy: { quotedAt: 'desc' },
|
||||
}),
|
||||
|
||||
// مجموع بدهی مشتریان (گرم طلا) — cached balance, see Customer.goldDebtGrams.
|
||||
this.prisma.customer.aggregate({ _sum: { goldDebtGrams: true } }),
|
||||
]);
|
||||
|
||||
// Bucket the 30-day order list into day-strings client-side: Prisma has no
|
||||
// portable "group by day" across the sqlite/postgres split this project
|
||||
// supports, and 30 days of order rows is tiny.
|
||||
const trendByDay = new Map<string, { total: number; retail: number; wholesale: number }>();
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
trendByDay.set(this.dayKey(this.daysAgo(i)), { total: 0, retail: 0, wholesale: 0 });
|
||||
}
|
||||
for (const o of ordersLast30) {
|
||||
const key = this.dayKey(o.createdAt);
|
||||
const bucket = trendByDay.get(key);
|
||||
if (!bucket) continue; // safety net; every key in range was pre-seeded above
|
||||
bucket.total += 1;
|
||||
if (o.channel === 'WHOLESALE') bucket.wholesale += 1;
|
||||
else bucket.retail += 1;
|
||||
}
|
||||
// Non-super admins get wholesale only: their `total` is the wholesale count
|
||||
// and there is no `retail` field, so the retail number cannot be derived.
|
||||
const ordersTrend = [...trendByDay.entries()].map(([date, v]) =>
|
||||
viewerIsSuperAdmin ? { date, ...v } : { date, total: v.wholesale, wholesale: v.wholesale },
|
||||
);
|
||||
|
||||
const statusLabels: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'معلق (سقف اعتبار)',
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
SOURCING: 'در حال تامین',
|
||||
PREPARING: 'در حال آمادهسازی',
|
||||
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
||||
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
const statusBreakdown = statusGroups.map((g) => ({
|
||||
status: g.status,
|
||||
label: statusLabels[g.status],
|
||||
count: g._count,
|
||||
}));
|
||||
|
||||
const channelLabels: Record<string, string> = { RETAIL: 'تک فروشی', WHOLESALE: 'عمده فروشی' };
|
||||
const channelBreakdown = viewerIsSuperAdmin
|
||||
? channelGroups.map((g) => ({
|
||||
channel: g.channel,
|
||||
label: channelLabels[g.channel] ?? g.channel,
|
||||
count: g._count,
|
||||
grams: g._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const productTotals = new Map<string, { title: string; weight: Prisma.Decimal; qty: number }>();
|
||||
for (const it of topProductRows) {
|
||||
const p = it.productVariant.product;
|
||||
const row = productTotals.get(p.id) ?? { title: p.title, weight: new Prisma.Decimal(0), qty: 0 };
|
||||
row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity));
|
||||
row.qty += it.quantity;
|
||||
productTotals.set(p.id, row);
|
||||
}
|
||||
const topProducts = [...productTotals.values()]
|
||||
.sort((a, b) => Number(b.weight) - Number(a.weight))
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
// ── everything the two legacy dashboards returned, unchanged ──────
|
||||
...omde,
|
||||
takLastOrder: tak.lastOrder,
|
||||
|
||||
// ── new chart data ─────────────────────────────────────────────
|
||||
ordersTrend,
|
||||
statusBreakdown,
|
||||
channelBreakdown,
|
||||
topProducts,
|
||||
goldPricePerGram: latestGoldQuote?.pricePerGramRial ?? null,
|
||||
goldPriceQuotedAt: latestGoldQuote?.quotedAt ?? null,
|
||||
totalCustomerDebtGrams: debtAgg._sum.goldDebtGrams ?? new Prisma.Decimal(0),
|
||||
};
|
||||
}
|
||||
|
||||
private dayKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Legacy `GET get-analytics` — the wholesale dashboard. */
|
||||
async omdeAnalytics() {
|
||||
const since30 = this.daysAgo(30);
|
||||
const since7 = this.daysAgo(7);
|
||||
|
||||
const [customerCount, gramsAgg, orderCount, lastOrderRows, agents, monthAgg] =
|
||||
await Promise.all([
|
||||
// تعداد مشتریان ثبت نامی در 30 روز گذشته
|
||||
this.prisma.customer.count({ where: { createdAt: { gte: since30 } } }),
|
||||
|
||||
// طلای فروش رفته در 30 روز گذشته
|
||||
this.prisma.order.aggregate({
|
||||
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
}),
|
||||
|
||||
// تعداد سفارشات 30 روز گذشته
|
||||
this.prisma.order.count({ where: { createdAt: { gte: since30 } } }),
|
||||
|
||||
// سفارشات هفته جاری
|
||||
this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: since7 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { customer: true, branch: true },
|
||||
}),
|
||||
|
||||
// عملکرد کارشناسان فروش
|
||||
this.prisma.adminUser.findMany({
|
||||
where: { role: 'SALES_EXPERT', isActive: true },
|
||||
select: { id: true, fullName: true },
|
||||
}),
|
||||
|
||||
// میزان فروش (same 30-day window, kept separate for clarity)
|
||||
this.prisma.order.aggregate({
|
||||
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Grams sold per agent, through the customers they referred. The view sorts
|
||||
// this itself by grams_sell, so the shape matters more than the order.
|
||||
const reagent = await Promise.all(
|
||||
agents.map(async (a) => {
|
||||
const agg = await this.prisma.order.aggregate({
|
||||
where: {
|
||||
AND: [
|
||||
AdminDashboardService.SOLD,
|
||||
agentSalesWhere(a.id),
|
||||
{ createdAt: { gte: since30 } },
|
||||
],
|
||||
},
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
_count: true,
|
||||
});
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.fullName,
|
||||
grams_sell: agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0),
|
||||
order_count: agg._count,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
customerCount,
|
||||
allgram: gramsAgg._sum.totalGoldWeightGrams ?? 0,
|
||||
ordercount: orderCount,
|
||||
reagent,
|
||||
lastOrder: lastOrderRows.map((o) => this.orderRow(o)),
|
||||
sumLastOrder: monthAgg._count,
|
||||
sumLastOrderMonth: monthAgg._sum.totalGoldWeightGrams ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET get-analytics-tak` — the retail dashboard. */
|
||||
async takAnalytics() {
|
||||
const since7 = this.daysAgo(7);
|
||||
|
||||
const rows = await this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: since7 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { customer: true, branch: true },
|
||||
});
|
||||
|
||||
// The retail view reads only `model.lastOrder`; the rest of its cards are
|
||||
// the Vuexy demo series and are not rendered from server data.
|
||||
return { lastOrder: rows.map((o) => this.orderRow(o)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `report/order_item` — "گزارش تجمیعی سفارشات و اسناد مالی": order
|
||||
* lines aggregated over a date range.
|
||||
*/
|
||||
async orderItemReport(from?: string, to?: string) {
|
||||
const where = this.dateWhere(from, to);
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where,
|
||||
include: {
|
||||
customer: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const rows = orders.flatMap((o) =>
|
||||
o.items.map((it) => ({
|
||||
order_id: o.id,
|
||||
order_number: o.orderNumber,
|
||||
customer: o.customer.fullName,
|
||||
product: it.productVariant.product.title,
|
||||
variant: it.productVariant.title,
|
||||
qty: it.quantity,
|
||||
w: it.computedWeightGrams,
|
||||
total_w: it.computedWeightGrams.mul(it.quantity),
|
||||
caj: o.createdAt,
|
||||
})),
|
||||
);
|
||||
|
||||
const totalWeight = rows.reduce(
|
||||
(s, r) => s.add(r.total_w),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
summary: {
|
||||
order_count: orders.length,
|
||||
item_count: rows.length,
|
||||
total_weight: totalWeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `report/product` — per-product sales over a date range. */
|
||||
async productReport(from?: string, to?: string) {
|
||||
const where = this.dateWhere(from, to);
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where,
|
||||
include: { items: { include: { productVariant: { include: { product: true } } } } },
|
||||
});
|
||||
|
||||
const byProduct = new Map<
|
||||
string,
|
||||
{ id: string; title: string; qty: number; weight: Prisma.Decimal; orders: Set<string> }
|
||||
>();
|
||||
|
||||
for (const o of orders) {
|
||||
for (const it of o.items) {
|
||||
const p = it.productVariant.product;
|
||||
const row = byProduct.get(p.id) ?? {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
qty: 0,
|
||||
weight: new Prisma.Decimal(0),
|
||||
orders: new Set<string>(),
|
||||
};
|
||||
row.qty += it.quantity;
|
||||
row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity));
|
||||
row.orders.add(o.id);
|
||||
byProduct.set(p.id, row);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...byProduct.values()]
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
total: r.qty,
|
||||
w: r.weight,
|
||||
order_count: r.orders.size,
|
||||
}))
|
||||
.sort((a, b) => Number(b.w) - Number(a.w));
|
||||
|
||||
return { rows, summary: { product_count: rows.length } };
|
||||
}
|
||||
|
||||
/** Shared date filter for both reports; an open end stays open. */
|
||||
private dateWhere(from?: string, to?: string): Prisma.OrderWhereInput {
|
||||
if (!from && !to) return {};
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
if (from) createdAt.gte = new Date(from);
|
||||
if (to) {
|
||||
// Include the whole end day, not just its midnight.
|
||||
const end = new Date(to);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
createdAt.lte = end;
|
||||
}
|
||||
return { createdAt };
|
||||
}
|
||||
|
||||
/** The row shape both dashboards' tables read. */
|
||||
private orderRow(
|
||||
o: Prisma.OrderGetPayload<{ include: { customer: true; branch: true } }>,
|
||||
) {
|
||||
const labels: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'معلق (سقف اعتبار)',
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
SOURCING: 'در حال تامین',
|
||||
PREPARING: 'در حال آمادهسازی',
|
||||
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
||||
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
// legacy reads `statusColor` straight off the row
|
||||
const colors: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'warning',
|
||||
PLACED: 'primary',
|
||||
ADMIN_APPROVED: 'primary',
|
||||
SOURCING: 'warning',
|
||||
PREPARING: 'warning',
|
||||
DELIVERED_TO_AGENT: 'success',
|
||||
DELIVERED_TO_CUSTOMER: 'success',
|
||||
CANCELLED: 'danger',
|
||||
};
|
||||
|
||||
return {
|
||||
id: o.id,
|
||||
name: o.orderNumber,
|
||||
customer: o.customer.fullName,
|
||||
w: o.totalGoldWeightGrams,
|
||||
sts_str: labels[o.status],
|
||||
statusColor: colors[o.status],
|
||||
caj: o.createdAt,
|
||||
// legacy rendered a parent/children tree for split orders; the redesigned
|
||||
// schema has no such relation, so both are empty rather than faked.
|
||||
parent: null,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminDocumentsService } from './admin-documents.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CashController + the wallet screens):
|
||||
* POST cash/table -> the documents grid
|
||||
* POST cash -> file a repayment document
|
||||
* GET cash/{id} -> one document
|
||||
* PUT cash/{id} -> edit
|
||||
* DELETE cash/{id} -> delete
|
||||
* GET query/customer -> the customer picker
|
||||
* POST wallet -> granted wallet bonuses
|
||||
*/
|
||||
@Controller('admin/documents')
|
||||
export class AdminDocumentsController {
|
||||
constructor(private readonly documents: AdminDocumentsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('document.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.documents.list(query);
|
||||
}
|
||||
|
||||
@Get('type-options')
|
||||
@RequirePermission('document.list')
|
||||
typeOptions() {
|
||||
return this.documents.typeOptions();
|
||||
}
|
||||
|
||||
@Get('query/customer')
|
||||
@RequirePermission('document.create')
|
||||
queryCustomers() {
|
||||
return this.documents.queryCustomers();
|
||||
}
|
||||
|
||||
@Post('wallet/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('wallet')
|
||||
listWallet(@Body() query: TableQueryDto) {
|
||||
return this.documents.listWallet(query);
|
||||
}
|
||||
|
||||
// ── wallet settings (views/manage/wallet.vue) ────────────────────
|
||||
@Get('wallet/config')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('config.wallet')
|
||||
getWalletConfig() {
|
||||
return this.documents.getSetting('wallet.config');
|
||||
}
|
||||
|
||||
@Post('wallet/config')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('config.wallet')
|
||||
setWalletConfig(@Body() body: Record<string, unknown>) {
|
||||
return this.documents.setSetting('wallet.config', body);
|
||||
}
|
||||
|
||||
@Get('wallet/birthday/config')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('config.wallet')
|
||||
getBirthdayConfig() {
|
||||
return this.documents.getSetting('wallet.birthday');
|
||||
}
|
||||
|
||||
@Post('wallet/birthday/config')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('config.wallet')
|
||||
setBirthdayConfig(@Body() body: Record<string, unknown>) {
|
||||
return this.documents.setSetting('wallet.birthday', body);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('document.create')
|
||||
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.documents.create(data, admin.id);
|
||||
}
|
||||
|
||||
/** Legacy POST cash/confirem/{id} — a toggle, not a one-way confirm. */
|
||||
@Post(':id/confirm')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('document.list.edit')
|
||||
toggleConfirm(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.documents.toggleConfirm(id, admin.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('document.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.documents.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('document.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.documents.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('document.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.documents.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { AdminDocumentsController } from './admin-documents.controller';
|
||||
import { AdminDocumentsService } from './admin-documents.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AccountingModule],
|
||||
controllers: [AdminDocumentsController],
|
||||
providers: [AdminDocumentsService],
|
||||
})
|
||||
export class AdminDocumentsModule {}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { brandName } from '../../common/brand';
|
||||
|
||||
/**
|
||||
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
|
||||
* CashController (`POST cash/table`, `POST cash`, `PUT cash/{id}`,
|
||||
* `GET cash/{id}`, `DELETE cash/{id}`).
|
||||
*
|
||||
* A legacy "cash" row IS a ledger entry: a customer repayment recorded as melted
|
||||
* gold, a transfer, a cheque or cash. The four legacy `type` codes map onto the
|
||||
* existing LedgerSourceType enum:
|
||||
*
|
||||
* 1 آب شده -> REPAYMENT_MELTED_GOLD (karat-normalized: value * ayar / 750)
|
||||
* 2 حواله -> REPAYMENT_TRANSFER
|
||||
* 3 چک -> REPAYMENT_CHEQUE
|
||||
* 4 پولی -> REPAYMENT_CASH (normalized via the gold price per gram)
|
||||
*
|
||||
* Posting delegates to LedgerService, which owns the karat and price-per-gram
|
||||
* conversions and the confirm/balance rules. Re-implementing them here would let
|
||||
* the admin path drift from the customer-facing one.
|
||||
*/
|
||||
const TYPE_BY_CODE: Record<number, LedgerSourceType> = {
|
||||
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
|
||||
2: LedgerSourceType.REPAYMENT_TRANSFER,
|
||||
3: LedgerSourceType.REPAYMENT_CHEQUE,
|
||||
4: LedgerSourceType.REPAYMENT_CASH,
|
||||
};
|
||||
|
||||
/** The instrument name LedgerService.postGoldRepayment expects. */
|
||||
const INSTRUMENT_BY_TYPE: Record<string, 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'> = {
|
||||
REPAYMENT_MELTED_GOLD: 'MELTED_GOLD',
|
||||
REPAYMENT_TRANSFER: 'TRANSFER',
|
||||
REPAYMENT_CHEQUE: 'CHEQUE',
|
||||
REPAYMENT_CASH: 'CASH',
|
||||
};
|
||||
|
||||
const CODE_BY_TYPE: Partial<Record<LedgerSourceType, number>> = {
|
||||
REPAYMENT_MELTED_GOLD: 1,
|
||||
REPAYMENT_TRANSFER: 2,
|
||||
REPAYMENT_CHEQUE: 3,
|
||||
REPAYMENT_CASH: 4,
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<LedgerSourceType, string> = {
|
||||
ORDER_STOCK: 'سفارش',
|
||||
ORDER_PRE_ORDER: 'سفارش ساختنی',
|
||||
REPAYMENT_MELTED_GOLD: 'آب شده',
|
||||
REPAYMENT_TRANSFER: 'حواله',
|
||||
REPAYMENT_CHEQUE: 'چک',
|
||||
REPAYMENT_CASH: 'پولی',
|
||||
WALLET_BONUS: 'هدیه کیف پول',
|
||||
ADJUSTMENT: 'اصلاح',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminDocumentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly ledger: LedgerService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'sourceType',
|
||||
'currency',
|
||||
'direction',
|
||||
'amount',
|
||||
'confirmed',
|
||||
'createdAt',
|
||||
'karatPerMille',
|
||||
'labName',
|
||||
'customer.fullName',
|
||||
// legacy filter keys
|
||||
'type_str',
|
||||
'ayar',
|
||||
'lab',
|
||||
'caj',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
type_str: 'sourceType',
|
||||
ayar: 'karatPerMille',
|
||||
lab: 'labName',
|
||||
caj: 'createdAt',
|
||||
uaj: 'createdAt',
|
||||
'customer.name': 'customer.fullName',
|
||||
value: 'amount',
|
||||
price: 'priceRial',
|
||||
original_value: 'originalValue',
|
||||
account_name: 'accountName',
|
||||
bank_name: 'bankName',
|
||||
to: 'transferTo',
|
||||
recive_date: 'receivedAt',
|
||||
price_per_gram: 'pricePerGramRialAtEntry',
|
||||
ang: 'hallmark',
|
||||
};
|
||||
|
||||
/** Legacy `POST cash/table` — the documents grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminDocumentsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'sourceType' && mapped.value !== undefined) {
|
||||
mapped.value = TYPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
if (mapped.field === 'confirmed' && mapped.value !== undefined) {
|
||||
mapped.value = String(mapped.value) === '1';
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['note', 'customer.fullName', 'labName'],
|
||||
allowed: AdminDocumentsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The grid is the REPAYMENT document list; order-generated debt entries are
|
||||
// not documents an operator files, so they are excluded as legacy's separate
|
||||
// `cash` table naturally did.
|
||||
const scoped: Prisma.LedgerEntryWhereInput = {
|
||||
AND: [
|
||||
where as Prisma.LedgerEntryWhereInput,
|
||||
{ sourceType: { in: Object.values(TYPE_BY_CODE) } },
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.ledgerEntry.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
}),
|
||||
this.prisma.ledgerEntry.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names preserved so the ported table model binds unchanged. */
|
||||
private toRow(
|
||||
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: e.id,
|
||||
type: CODE_BY_TYPE[e.sourceType] ?? null,
|
||||
type_str: TYPE_LABELS[e.sourceType],
|
||||
sourceType: e.sourceType,
|
||||
currency: e.currency,
|
||||
direction: e.direction,
|
||||
// legacy `value` is the normalized final weight, `original_value` the raw one
|
||||
value: e.amount,
|
||||
original_value: e.originalValue,
|
||||
price: e.priceRial,
|
||||
price_per_gram: e.pricePerGramRialAtEntry,
|
||||
ayar: e.karatPerMille,
|
||||
lab: e.labName,
|
||||
ang: e.hallmark,
|
||||
account_name: e.accountName,
|
||||
bank_name: e.bankName,
|
||||
to: e.transferTo,
|
||||
recive_date: e.receivedAt,
|
||||
note: e.note,
|
||||
// legacy grid renders `confirmed` through a slot using 0/1
|
||||
confirmed: e.confirmed ? 1 : 0,
|
||||
confirmedAt: e.confirmedAt,
|
||||
customer: {
|
||||
id: e.customer.id,
|
||||
name: e.customer.fullName,
|
||||
// D0 — real name parts
|
||||
fname: e.customer.firstName ?? e.customer.fullName,
|
||||
lname: e.customer.lastName ?? '',
|
||||
mobile: e.customer.mobile,
|
||||
},
|
||||
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
|
||||
caj: e.createdAt,
|
||||
uaj: e.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET cash/{id}`. */
|
||||
async findOne(id: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
});
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
return this.toRow(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST cash` — file a repayment document.
|
||||
*
|
||||
* The gram/karat/price conversions are LedgerService's job; this method only
|
||||
* translates the legacy form shape and attaches the instrument metadata.
|
||||
*/
|
||||
async create(data: Record<string, unknown>, actorAdminUserId: string) {
|
||||
const typeCode = Number(data.type ?? 2);
|
||||
const sourceType = TYPE_BY_CODE[typeCode];
|
||||
if (!sourceType) throw new BadRequestException('نوع سند نامعتبر است');
|
||||
|
||||
const customerId = String(data.customer_id ?? '');
|
||||
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
|
||||
|
||||
// LedgerService takes the legacy instrument name and the RAW figure, then
|
||||
// applies the karat / price-per-gram conversion itself.
|
||||
const instrument = INSTRUMENT_BY_TYPE[sourceType];
|
||||
|
||||
// Which field carries the raw figure depends on the instrument, exactly as
|
||||
// the legacy form did: cash posts a Rial price, melted gold posts the
|
||||
// pre-normalization weight, transfer/cheque post grams directly.
|
||||
const rawValue =
|
||||
instrument === 'CASH'
|
||||
? String(data.price ?? 0)
|
||||
: instrument === 'MELTED_GOLD'
|
||||
? String(data.original_value ?? data.value ?? 0)
|
||||
: String(data.value ?? 0);
|
||||
|
||||
const entry = await this.ledger.postGoldRepayment({
|
||||
customerId,
|
||||
instrument,
|
||||
rawValue,
|
||||
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
|
||||
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
|
||||
note: (data.note as string) ?? undefined,
|
||||
postedByAdminUserId: actorAdminUserId,
|
||||
});
|
||||
|
||||
// Attach the instrument metadata the ledger service does not model.
|
||||
await this.prisma.ledgerEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: this.metadataPatch(data),
|
||||
});
|
||||
|
||||
return this.findOne(entry.id);
|
||||
}
|
||||
|
||||
private metadataPatch(data: Record<string, unknown>): Prisma.LedgerEntryUpdateInput {
|
||||
const patch: Prisma.LedgerEntryUpdateInput = {};
|
||||
if (data.lab !== undefined) patch.labName = (data.lab as string) || null;
|
||||
if (data.ang !== undefined) patch.hallmark = (data.ang as string) || null;
|
||||
if (data.account_name !== undefined) patch.accountName = (data.account_name as string) || null;
|
||||
if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null;
|
||||
if (data.to !== undefined) patch.transferTo = (data.to as string) || null;
|
||||
if (data.original_value !== undefined && data.original_value !== null) {
|
||||
patch.originalValue = new Prisma.Decimal(String(data.original_value));
|
||||
}
|
||||
if (data.price !== undefined && data.price !== null) {
|
||||
patch.priceRial = new Prisma.Decimal(String(data.price));
|
||||
}
|
||||
if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date));
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `PUT cash/{id}` — edit a document.
|
||||
*
|
||||
* Only metadata and the note are editable. Changing the AMOUNT of a confirmed
|
||||
* entry would silently rewrite a customer's debt, so that is refused: the
|
||||
* correct accounting action is a reversing adjustment, not an edit. Legacy
|
||||
* allowed it. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
const amountChanged =
|
||||
data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount);
|
||||
|
||||
if (existing.confirmed && amountChanged) {
|
||||
throw new BadRequestException(
|
||||
'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const patch = this.metadataPatch(data);
|
||||
if (data.note !== undefined) patch.note = (data.note as string) || null;
|
||||
if (!existing.confirmed && amountChanged) {
|
||||
patch.amount = new Prisma.Decimal(String(data.value));
|
||||
}
|
||||
|
||||
await this.prisma.ledgerEntry.update({ where: { id }, data: patch });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid
|
||||
* shows a tick on an unconfirmed row and a cross on a confirmed one, both
|
||||
* calling this same endpoint.
|
||||
*
|
||||
* Confirming delegates to LedgerService so the customer balance moves through
|
||||
* the one code path that owns it. Un-confirming reverses that same amount
|
||||
* rather than editing the balance directly, so the two stay consistent.
|
||||
*/
|
||||
async toggleConfirm(id: string, adminUserId: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
if (!e.confirmed) {
|
||||
await this.ledger.confirmEntry(id, adminUserId);
|
||||
this.notifyDocumentConfirm(e.customerId, true);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// Un-confirm: undo this entry's effect on the cached balance, then clear the
|
||||
// flag. A repayment is a CREDIT against gold debt, so reversing it adds the
|
||||
// amount back.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delta =
|
||||
e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated();
|
||||
const field =
|
||||
e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial';
|
||||
|
||||
await tx.customer.update({
|
||||
where: { id: e.customerId },
|
||||
data: { [field]: { increment: delta } },
|
||||
});
|
||||
await tx.ledgerEntry.update({
|
||||
where: { id },
|
||||
data: { confirmed: false, confirmedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
this.notifyDocumentConfirm(e.customerId, false);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// Legacy CashController::toggleConfiremCash sent an SMS on both outcomes.
|
||||
private notifyDocumentConfirm(customerId: string, confirmed: boolean) {
|
||||
const text = confirmed
|
||||
? `${brandName()}\nسند مالی شما توسط مدیر سیستم تایید شد.`
|
||||
: `${brandName()}\nسند مالی شما توسط مدیر سیستم رد شد.`;
|
||||
|
||||
this.prisma.customer
|
||||
.findUnique({ where: { id: customerId }, select: { mobile: true } })
|
||||
.then((c) => {
|
||||
if (c) this.sms.send(c.mobile, text);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE cash/{id}`.
|
||||
*
|
||||
* A confirmed entry has already moved the customer's balance; deleting it
|
||||
* would leave that balance overstated with no trace. Refused — legacy allowed
|
||||
* it. Unconfirmed entries never touched a balance, so they delete cleanly.
|
||||
*/
|
||||
async remove(id: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
if (e.confirmed) {
|
||||
throw new BadRequestException(
|
||||
'حذف سند تاییدشده ممکن نیست، چون در تراز مشتری اعمال شده است.',
|
||||
);
|
||||
}
|
||||
await this.prisma.ledgerEntry.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
typeOptions() {
|
||||
return Object.entries(TYPE_BY_CODE).map(([code, sourceType]) => ({
|
||||
value: Number(code),
|
||||
title: TYPE_LABELS[sourceType],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Wallet ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST wallet` grid — the list of granted wallet bonuses. */
|
||||
async listWallet(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['customer.fullName'],
|
||||
allowed: ['id', 'goldGrams', 'grantedAt', 'expiresAt', 'isActive', 'customer.fullName'],
|
||||
defaultOrderBy: { grantedAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.walletBonus.findMany({
|
||||
where: where as Prisma.WalletBonusWhereInput,
|
||||
orderBy: orderBy as Prisma.WalletBonusOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true },
|
||||
}),
|
||||
this.prisma.walletBonus.count({ where: where as Prisma.WalletBonusWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((w) => ({
|
||||
id: w.id,
|
||||
value: w.goldGrams,
|
||||
grams: w.goldGrams,
|
||||
is_active: w.isActive,
|
||||
consumed: Boolean(w.consumedAt),
|
||||
caj: w.grantedAt,
|
||||
expires_at: w.expiresAt,
|
||||
customer: {
|
||||
id: w.customer.id,
|
||||
name: w.customer.fullName,
|
||||
mobile: w.customer.mobile,
|
||||
},
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET /wallet/config` and `/wallet/birthday/config`, plus their POST
|
||||
* counterparts — the wallet settings screen (views/manage/wallet.vue).
|
||||
* Stored in the Setting key/value table that replaces legacy `config_static`.
|
||||
*/
|
||||
async getSetting(key: string) {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? {};
|
||||
}
|
||||
|
||||
async setSetting(key: string, value: Record<string, unknown>) {
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: value as Prisma.InputJsonValue },
|
||||
create: { key, value: value as Prisma.InputJsonValue },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
/** The customer picker used by the document form — legacy `GET query/customer`. */
|
||||
async queryCustomers() {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, firstName: true, lastName: true, mobile: true },
|
||||
});
|
||||
// D0 — real name parts.
|
||||
return rows.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.fullName,
|
||||
fname: c.firstName ?? c.fullName,
|
||||
lname: c.lastName ?? '',
|
||||
mobile: c.mobile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminInvoicesService } from './admin-invoices.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (BranchController's `branch/product` family):
|
||||
* POST branch/product/table
|
||||
* POST branch/product
|
||||
* GET branch/product/{id}
|
||||
* DELETE branch/product/{id}
|
||||
* GET branch/list -> the branch picker
|
||||
*/
|
||||
@Controller('admin/invoices')
|
||||
export class AdminInvoicesController {
|
||||
constructor(private readonly invoices: AdminInvoicesService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('invoice.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.invoices.list(query);
|
||||
}
|
||||
|
||||
@Get('branch-list')
|
||||
@RequirePermission('invoice.create')
|
||||
branchList() {
|
||||
return this.invoices.branchList();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('invoice.create')
|
||||
create(
|
||||
@Body() data: { branch_id?: string; products?: string[]; note?: string },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.invoices.create(data, admin.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('invoice.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.invoices.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('invoice.list')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() data: { branch_id?: string; products?: string[]; note?: string },
|
||||
) {
|
||||
return this.invoices.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('invoice.list')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.invoices.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminInvoicesController } from './admin-invoices.controller';
|
||||
import { AdminInvoicesService } from './admin-invoices.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminInvoicesController],
|
||||
providers: [AdminInvoicesService],
|
||||
})
|
||||
export class AdminInvoicesModule {}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Branch stock allocations — legacy views/invoice/* ("فاکتور انتقال"), served by
|
||||
* BranchController as `branch/product`:
|
||||
* POST branch/product/table -> the grid
|
||||
* POST branch/product -> create { branch_id, products: [id...] }
|
||||
* GET branch/product/{id} -> one allocation
|
||||
* DELETE branch/product/{id} -> delete
|
||||
*
|
||||
* Despite the screen being labelled an invoice, it is not a sales document: it
|
||||
* records which products head office allocated to a branch, and who did it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminInvoicesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'note',
|
||||
'createdAt',
|
||||
'branchId',
|
||||
'branch.name',
|
||||
'allocatorAdminUser.fullName',
|
||||
// legacy filter keys
|
||||
'name',
|
||||
'caj',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
name: 'branch.name',
|
||||
caj: 'createdAt',
|
||||
'allocator_user.name': 'allocatorAdminUser.fullName',
|
||||
};
|
||||
|
||||
/** Legacy `POST branch/product/table`. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminInvoicesService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => ({ ...f, field: mapField(f.field) })),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['note', 'branch.name'],
|
||||
allowed: AdminInvoicesService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.branchAllocation.findMany({
|
||||
where: where as Prisma.BranchAllocationWhereInput,
|
||||
orderBy: orderBy as Prisma.BranchAllocationOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
branch: true,
|
||||
allocatorAdminUser: true,
|
||||
items: { include: { product: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.branchAllocation.count({ where: where as Prisma.BranchAllocationWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
a: Prisma.BranchAllocationGetPayload<{
|
||||
include: {
|
||||
branch: true;
|
||||
allocatorAdminUser: true;
|
||||
items: { include: { product: true } };
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const sumWeight = a.items.reduce(
|
||||
(sum, it) => sum.add(it.weightGrams ?? new Prisma.Decimal(0)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
// legacy column key is `name` — the receiving branch
|
||||
name: a.branch.name,
|
||||
branch_id: a.branchId,
|
||||
branch: { id: a.branch.id, name: a.branch.name },
|
||||
sum_weight: sumWeight,
|
||||
count_item: a.items.length,
|
||||
note: a.note,
|
||||
allocator_user: a.allocatorAdminUser
|
||||
? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName }
|
||||
: null,
|
||||
caj: a.createdAt,
|
||||
products: a.items.map((it) => ({
|
||||
id: it.productId,
|
||||
pid: it.productId,
|
||||
title: it.product.title,
|
||||
product_code: it.product.productCode,
|
||||
grams: it.weightGrams,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET branch/product/{id}`. */
|
||||
async findOne(id: string) {
|
||||
const a = await this.prisma.branchAllocation.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
branch: true,
|
||||
allocatorAdminUser: true,
|
||||
items: { include: { product: true } },
|
||||
},
|
||||
});
|
||||
if (!a) throw new NotFoundException('فاکتور یافت نشد');
|
||||
return this.toRow(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST branch/product` with `{ branch_id, products: [id...] }`.
|
||||
*
|
||||
* Each product's current lightest variant weight is snapshotted, so a later
|
||||
* catalog edit does not silently rewrite what this document says was moved.
|
||||
*/
|
||||
async create(data: { branch_id?: string; products?: string[]; note?: string }, actorId: string) {
|
||||
const branchId = String(data.branch_id ?? '');
|
||||
if (!branchId) throw new BadRequestException('شعبه انتخاب نشده است');
|
||||
|
||||
const branch = await this.prisma.branch.findUnique({ where: { id: branchId } });
|
||||
if (!branch) throw new NotFoundException('شعبه یافت نشد');
|
||||
|
||||
const productIds = [...new Set((data.products ?? []).map((p) => String(p)).filter(Boolean))];
|
||||
if (productIds.length === 0) throw new BadRequestException('هیچ محصولی انتخاب نشده است');
|
||||
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
include: { variants: { where: { isActive: true } } },
|
||||
});
|
||||
if (products.length !== productIds.length) {
|
||||
throw new BadRequestException('برخی از محصولات انتخابشده یافت نشدند');
|
||||
}
|
||||
|
||||
const allocation = await this.prisma.branchAllocation.create({
|
||||
data: {
|
||||
branchId,
|
||||
allocatorAdminUserId: actorId,
|
||||
note: data.note ?? null,
|
||||
items: {
|
||||
create: products.map((p) => ({
|
||||
productId: p.id,
|
||||
weightGrams: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.findOne(allocation.id);
|
||||
}
|
||||
|
||||
/** Legacy `PUT branch/product/{id}` — the edit screen replaces the product set. */
|
||||
async update(id: string, data: { branch_id?: string; products?: string[]; note?: string }) {
|
||||
const existing = await this.prisma.branchAllocation.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('فاکتور یافت نشد');
|
||||
|
||||
if (data.branch_id) {
|
||||
const branch = await this.prisma.branch.findUnique({
|
||||
where: { id: String(data.branch_id) },
|
||||
});
|
||||
if (!branch) throw new NotFoundException('شعبه یافت نشد');
|
||||
}
|
||||
|
||||
if (Array.isArray(data.products)) {
|
||||
const productIds = [...new Set(data.products.map((p) => String(p)).filter(Boolean))];
|
||||
if (productIds.length === 0) {
|
||||
throw new BadRequestException('هیچ محصولی انتخاب نشده است');
|
||||
}
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
include: { variants: { where: { isActive: true } } },
|
||||
});
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.branchAllocationItem.deleteMany({ where: { allocationId: id } }),
|
||||
this.prisma.branchAllocationItem.createMany({
|
||||
data: products.map((p) => ({
|
||||
allocationId: id,
|
||||
productId: p.id,
|
||||
weightGrams: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
await this.prisma.branchAllocation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.branch_id ? { branchId: String(data.branch_id) } : {}),
|
||||
...(data.note !== undefined ? { note: data.note } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/** Legacy `DELETE branch/product/{id}`. Items cascade. */
|
||||
async remove(id: string) {
|
||||
const a = await this.prisma.branchAllocation.findUnique({ where: { id } });
|
||||
if (!a) throw new NotFoundException('فاکتور یافت نشد');
|
||||
await this.prisma.branchAllocation.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET branch/list` — the branch picker for the invoice form. */
|
||||
async branchList() {
|
||||
const rows = await this.prisma.branch.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AdminMiscService } from './admin-misc.service';
|
||||
|
||||
/** Cities as a tree (PERS-02): legacy's percent inheritance and loop guard. */
|
||||
describe('AdminMiscService — city tree', () => {
|
||||
type Row = { id: string; parentId: string | null; percent: Prisma.Decimal | null; percentAux: Prisma.Decimal | null };
|
||||
const D = (v: number) => new Prisma.Decimal(v);
|
||||
let rows: Map<string, Row>;
|
||||
let service: AdminMiscService;
|
||||
let update: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// ایران > استان تهران > تهران > تهران شمال
|
||||
rows = new Map(
|
||||
[
|
||||
{ id: 'iran', parentId: null, percent: null, percentAux: null },
|
||||
{ id: 'prov', parentId: 'iran', percent: D(10.12), percentAux: D(10.12) },
|
||||
{ id: 'teh', parentId: 'prov', percent: D(0), percentAux: D(8) },
|
||||
{ id: 'north', parentId: 'teh', percent: null, percentAux: null },
|
||||
].map((r) => [r.id, r]),
|
||||
);
|
||||
update = jest.fn(async ({ where, data }) => ({ ...rows.get(where.id), ...data }));
|
||||
const prisma = {
|
||||
city: {
|
||||
findUnique: jest.fn(async ({ where }) => rows.get(where.id) ?? null),
|
||||
update,
|
||||
},
|
||||
};
|
||||
service = new AdminMiscService(prisma as never, {} as never, {} as never);
|
||||
});
|
||||
|
||||
it('a city without its own percent takes the nearest ancestor’s (legacy _getP)', async () => {
|
||||
expect(String(await service.getCityPercent('north', true))).toBe('8');
|
||||
// 0 counts as "none", as legacy's `> 0`: تهران has 0, so استان تهران's.
|
||||
expect(String(await service.getCityPercent('north', false))).toBe('10.12');
|
||||
});
|
||||
|
||||
it('0 when no ancestor has one', async () => {
|
||||
expect(await service.getCityPercent('iran', false)).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to put a city under its own descendant', async () => {
|
||||
await expect(service.updateCity('teh', { parent: 'north' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.updateCity('teh', { parent: 'teh' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves a city and clears a parent', async () => {
|
||||
await service.updateCity('north', { parent: 'prov' });
|
||||
expect(update).toHaveBeenLastCalledWith({ where: { id: 'north' }, data: { parentId: 'prov' } });
|
||||
await service.updateCity('north', { parent: '' });
|
||||
expect(update).toHaveBeenLastCalledWith({ where: { id: 'north' }, data: { parentId: null } });
|
||||
});
|
||||
|
||||
it('refuses an unknown parent', async () => {
|
||||
await expect(service.updateCity('north', { parent: 'nope' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import { AdminMiscService } from './admin-misc.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { isAccountingSettingKey } from '../accounting-sync/accounting-config';
|
||||
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
|
||||
|
||||
/**
|
||||
* Cities, SMS, notifications, newsletter, activity log and settings — the
|
||||
* remaining admin screens. See AdminMiscService for the legacy endpoint map.
|
||||
*/
|
||||
@Controller('admin/misc')
|
||||
export class AdminMiscController {
|
||||
constructor(private readonly misc: AdminMiscService) {}
|
||||
|
||||
// ── cities ────────────────────────────────────────────────────────
|
||||
@Post('cities/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('cities.list')
|
||||
listCities(@Body() query: TableQueryDto) {
|
||||
return this.misc.listCities(query);
|
||||
}
|
||||
|
||||
@Post('cities')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('cities.create')
|
||||
createCity(@Body() data: Record<string, never>) {
|
||||
return this.misc.createCity(data);
|
||||
}
|
||||
|
||||
@Get('cities/percent/:id')
|
||||
@RequirePermission('cities.list')
|
||||
getCityPercent(@Param('id') id: string) {
|
||||
return this.misc.getCityPercent(id, false);
|
||||
}
|
||||
|
||||
@Get('cities/percent-aux/:id')
|
||||
@RequirePermission('cities.list')
|
||||
getCityPercentAux(@Param('id') id: string) {
|
||||
return this.misc.getCityPercent(id, true);
|
||||
}
|
||||
|
||||
@Put('cities/:id')
|
||||
@RequirePermission('cities.list.view.edit')
|
||||
updateCity(@Param('id') id: string, @Body() data: Record<string, never>) {
|
||||
return this.misc.updateCity(id, data);
|
||||
}
|
||||
|
||||
@Delete('cities/:id')
|
||||
@RequirePermission('cities.list.remove')
|
||||
removeCity(@Param('id') id: string) {
|
||||
return this.misc.removeCity(id);
|
||||
}
|
||||
|
||||
// ── sms ───────────────────────────────────────────────────────────
|
||||
@Post('sms/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('sms.list')
|
||||
listSms(@Body() query: TableQueryDto) {
|
||||
return this.misc.listSms(query);
|
||||
}
|
||||
|
||||
@Post('sms')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('sms.create')
|
||||
sendSms(
|
||||
@Body() data: { type?: string | number; mobile?: string; text?: string },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.misc.sendSms(data, admin.id);
|
||||
}
|
||||
|
||||
// ── notifications ─────────────────────────────────────────────────
|
||||
@Post('notify/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('notify.me')
|
||||
listNotifications(@Body() query: TableQueryDto) {
|
||||
return this.misc.listNotifications(query);
|
||||
}
|
||||
|
||||
@Post('notify')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('notify.create')
|
||||
createNotification(
|
||||
@Body() data: { title?: string; text?: string; body?: string; type?: string | number },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.misc.createNotification(data, admin.id);
|
||||
}
|
||||
|
||||
@Post('notify/customer')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('notify.create')
|
||||
notifyCustomer(
|
||||
@Body() data: { customer_id?: string; title?: string; text?: string; body?: string },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.misc.notifyCustomer(data, admin.id);
|
||||
}
|
||||
|
||||
@Get('notify/read/:id')
|
||||
@RequirePermission('notify.me')
|
||||
markRead(@Param('id') id: string) {
|
||||
return this.misc.markNotificationRead(id);
|
||||
}
|
||||
|
||||
@Get('notify/:id')
|
||||
@RequirePermission('notify.me')
|
||||
findNotification(@Param('id') id: string) {
|
||||
return this.misc.findNotification(id);
|
||||
}
|
||||
|
||||
// ── newsletter ────────────────────────────────────────────────────
|
||||
@Post('newsletter/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('newsletters')
|
||||
listNewsletter(@Body() query: TableQueryDto) {
|
||||
return this.misc.listNewsletter(query);
|
||||
}
|
||||
|
||||
// ── activity log ──────────────────────────────────────────────────
|
||||
@Get('log/customers')
|
||||
@RequirePermission('statics')
|
||||
logCustomers() {
|
||||
return this.misc.logCustomers();
|
||||
}
|
||||
|
||||
@Get('log/customer/:id')
|
||||
@RequirePermission('statics')
|
||||
customerLog(@Param('id') id: string) {
|
||||
return this.misc.customerLog(id);
|
||||
}
|
||||
|
||||
// ── settings + brands ("مدیریت ثابت ها") ─────────────────────────
|
||||
@Get('dashboard-links')
|
||||
@RequirePermission('config.report')
|
||||
dashboardLinks() {
|
||||
return this.misc.dashboardLinks();
|
||||
}
|
||||
|
||||
@Get('const')
|
||||
@RequirePermission('config.statics')
|
||||
listConsts() {
|
||||
return this.misc.listConsts();
|
||||
}
|
||||
|
||||
@Post('brands')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('config.statics.brand.create')
|
||||
createBrand(@Body() body: { title: string }) {
|
||||
return this.misc.createBrand(body.title);
|
||||
}
|
||||
|
||||
@Put('brands/:id')
|
||||
@RequirePermission('config.statics.brand.edit')
|
||||
updateBrand(@Param('id') id: string, @Body() body: { title: string }) {
|
||||
return this.misc.updateBrand(id, body.title);
|
||||
}
|
||||
|
||||
@Delete('brands/:id')
|
||||
@RequirePermission('config.statics.brand.remove')
|
||||
removeBrand(@Param('id') id: string) {
|
||||
return this.misc.removeBrand(id);
|
||||
}
|
||||
|
||||
// The mobile home slider ("اسلایدر موبایل") is stored under the same
|
||||
// `Setting` table as every other `const/:key` entry (key: mobile_main_slider),
|
||||
// but it gets its own permission (`custom.config.statics.slider`, per
|
||||
// acl.constants.ts / permissionConst.ts) rather than the broad `config.statics`
|
||||
// that guards the rest of the "مدیریت ثابت ها" screen — uploading/replacing the
|
||||
// public site banner is more sensitive than editing brand/tag lists, so it
|
||||
// should not be reachable by everyone who can merely open that screen.
|
||||
// Routes declared ahead of `const/:key` so Nest doesn't match `const/slider`
|
||||
// (the legacy REST shape) to the generic key handler instead.
|
||||
@Get('const/slider')
|
||||
@RequirePermission('custom.config.statics.slider')
|
||||
getSlider() {
|
||||
return this.misc.getConst('mobile_main_slider');
|
||||
}
|
||||
|
||||
@Put('const/slider')
|
||||
@RequirePermission('custom.config.statics.slider')
|
||||
setSlider(@Body() body: Record<string, unknown>) {
|
||||
return this.misc.setConst('mobile_main_slider', body.val !== undefined ? body.val : body);
|
||||
}
|
||||
|
||||
@Delete('const/slider')
|
||||
@RequirePermission('custom.config.statics.slider')
|
||||
removeSlider() {
|
||||
return this.misc.removeConst('mobile_main_slider');
|
||||
}
|
||||
|
||||
@Get('const/:key')
|
||||
@RequirePermission('config.statics')
|
||||
getConst(@Param('key') key: string) {
|
||||
this.assertNotSliderKey(key);
|
||||
return this.misc.getConst(key);
|
||||
}
|
||||
|
||||
@Put('const/:key')
|
||||
@RequirePermission('config.statics')
|
||||
setConst(@Param('key') key: string, @Body() body: Record<string, unknown>) {
|
||||
this.assertNotSliderKey(key);
|
||||
// Legacy posts either a bare value under `val` or a whole object.
|
||||
return this.misc.setConst(key, body.val !== undefined ? body.val : body);
|
||||
}
|
||||
|
||||
@Delete('const/:key')
|
||||
@RequirePermission('config.statics')
|
||||
removeConst(@Param('key') key: string) {
|
||||
this.assertNotSliderKey(key);
|
||||
return this.misc.removeConst(key);
|
||||
}
|
||||
|
||||
// Blocks the generic `config.statics`-gated routes from reaching the slider
|
||||
// setting by its raw key — otherwise anyone with `config.statics` could
|
||||
// bypass the dedicated `const/slider` routes (guarded by the stricter
|
||||
// `custom.config.statics.slider`) simply by calling `const/mobile_main_slider`
|
||||
// directly, since the underlying Setting row is keyed the same either way.
|
||||
private assertNotSliderKey(key: string) {
|
||||
if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) {
|
||||
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminMiscController } from './admin-misc.controller';
|
||||
import { PublicSliderController } from './public-slider.controller';
|
||||
import { AdminMiscService } from './admin-misc.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminMiscController, PublicSliderController],
|
||||
providers: [AdminMiscService],
|
||||
})
|
||||
export class AdminMiscModule {}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MessageAudience, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
|
||||
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
|
||||
|
||||
/**
|
||||
* The remaining admin screens — legacy views/city, views/sms, views/notify,
|
||||
* views/newsletters, views/statics and views/manage/*:
|
||||
*
|
||||
* cities, cities/{id}, cities/get-percent/{id}, cities/get-percent-aux/{id}
|
||||
* sms/table, sms
|
||||
* notify, notify/customer, notify/{id}, notify/read/{id}
|
||||
* ddf -> the newsletter list (legacy's own opaque name)
|
||||
* log/list/customer, log/ago/{id}
|
||||
* const, const/{key}, const/by-id/{id}
|
||||
* dashboard/link
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminMiscService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
// ── Cities ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Legacy cities are a tree (country > province > city > district) and so are
|
||||
// the target's again (PERS-02): each city may hang under a `parent`.
|
||||
|
||||
/** Legacy `POST cities/list` grid. */
|
||||
async listCities(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['name'],
|
||||
allowed: ['id', 'name', 'parentId', 'percent', 'percentAux'],
|
||||
defaultOrderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.city.findMany({
|
||||
where: where as Prisma.CityWhereInput,
|
||||
orderBy: orderBy as Prisma.CityOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
parent: { select: { id: true, name: true } },
|
||||
_count: { select: { customers: true, branches: true, children: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.city.count({ where: where as Prisma.CityWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
name: c.name,
|
||||
parent: c.parentId,
|
||||
parent_title: c.parent?.name ?? null,
|
||||
percent: c.percent,
|
||||
percent_aux: c.percentAux,
|
||||
customers_count: c._count.customers,
|
||||
branches_count: c._count.branches,
|
||||
children_count: c._count.children,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent a create/update asks for: `parent` (legacy's field name) or
|
||||
* `parent_id`; empty means top level. Refuses an unknown city, the city
|
||||
* itself, or one of its own descendants (that would make a loop).
|
||||
*/
|
||||
private async resolveCityParent(
|
||||
data: { parent?: unknown; parent_id?: unknown },
|
||||
selfId?: string,
|
||||
): Promise<string | null | undefined> {
|
||||
const raw = data.parent !== undefined ? data.parent : data.parent_id;
|
||||
if (raw === undefined) return undefined;
|
||||
if (raw === null || String(raw).trim() === '') return null;
|
||||
const parentId = String(raw).trim();
|
||||
|
||||
// Walk up from the requested parent; meeting selfId means a loop.
|
||||
let cur: string | null = parentId;
|
||||
for (let guard = 0; cur && guard < 50; guard++) {
|
||||
if (cur === selfId) throw new BadRequestException('شهر نمیتواند زیرمجموعهی خودش باشد.');
|
||||
const row: { parentId: string | null } | null = await this.prisma.city.findUnique({
|
||||
where: { id: cur },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!row) {
|
||||
if (cur === parentId) throw new BadRequestException('والد انتخابشده پیدا نشد.');
|
||||
break;
|
||||
}
|
||||
cur = row.parentId;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
private static cityPercent(v: unknown): Prisma.Decimal | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || String(v).trim() === '') return null;
|
||||
try {
|
||||
return new Prisma.Decimal(String(v));
|
||||
} catch {
|
||||
throw new BadRequestException('درصد باید عدد باشد.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy `POST cities` — name, parent and the two percents. */
|
||||
async createCity(data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown }) {
|
||||
const name = String(data.title ?? data.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException('نام شهر الزامی است');
|
||||
const parentId = await this.resolveCityParent(data);
|
||||
return this.prisma.city.create({
|
||||
data: {
|
||||
name,
|
||||
parentId: parentId ?? null,
|
||||
percent: AdminMiscService.cityPercent(data.percent) ?? null,
|
||||
percentAux: AdminMiscService.cityPercent(data.percent_aux) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `PUT cities/{id}` — name, parent and the two percents. */
|
||||
async updateCity(
|
||||
id: string,
|
||||
data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown },
|
||||
) {
|
||||
const existing = await this.prisma.city.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('شهر یافت نشد');
|
||||
|
||||
const patch: Prisma.CityUncheckedUpdateInput = {};
|
||||
if (data.title !== undefined || data.name !== undefined) {
|
||||
const name = String(data.title ?? data.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException('نام شهر الزامی است');
|
||||
patch.name = name;
|
||||
}
|
||||
const parentId = await this.resolveCityParent(data, id);
|
||||
if (parentId !== undefined) patch.parentId = parentId;
|
||||
const percent = AdminMiscService.cityPercent(data.percent);
|
||||
if (percent !== undefined) patch.percent = percent;
|
||||
const percentAux = AdminMiscService.cityPercent(data.percent_aux);
|
||||
if (percentAux !== undefined) patch.percentAux = percentAux;
|
||||
|
||||
return this.prisma.city.update({ where: { id }, data: patch });
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE cities/{id}`.
|
||||
*
|
||||
* Refuses while customers, branches or child cities still point at the city —
|
||||
* legacy deleted regardless, orphaning those rows.
|
||||
*/
|
||||
async removeCity(id: string) {
|
||||
const c = await this.prisma.city.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { customers: true, branches: true, addresses: true, children: true } } },
|
||||
});
|
||||
if (!c) throw new NotFoundException('شهر یافت نشد');
|
||||
|
||||
if (c._count.children > 0) {
|
||||
throw new BadRequestException(`حذف این شهر ممکن نیست، چون ${c._count.children} شهر زیرمجموعهی آن است.`);
|
||||
}
|
||||
const refs = c._count.customers + c._count.branches + c._count.addresses;
|
||||
if (refs > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این شهر ممکن نیست، چون ${refs} مشتری، شعبه یا آدرس به آن ارجاع دارد.`,
|
||||
);
|
||||
}
|
||||
await this.prisma.city.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET cities/get-percent/{id}` — returns the bare number. As legacy's
|
||||
* CitiesController::_getP: a city without its own percent (null or 0) takes
|
||||
* its parent's, and so on up; 0 when no ancestor has one.
|
||||
*/
|
||||
async getCityPercent(id: string, aux: boolean) {
|
||||
let cur: string | null = id;
|
||||
for (let guard = 0; cur && guard < 50; guard++) {
|
||||
const c: { parentId: string | null; percent: Prisma.Decimal | null; percentAux: Prisma.Decimal | null } | null =
|
||||
await this.prisma.city.findUnique({
|
||||
where: { id: cur },
|
||||
select: { parentId: true, percent: true, percentAux: true },
|
||||
});
|
||||
if (!c) {
|
||||
if (cur === id) throw new NotFoundException('شهر یافت نشد');
|
||||
break;
|
||||
}
|
||||
const v = aux ? c.percentAux : c.percent;
|
||||
if (v !== null && v.gt(0)) return v;
|
||||
cur = c.parentId;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST sms/table` — the sent-message log. */
|
||||
async listSms(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['body', 'recipients'],
|
||||
allowed: ['id', 'audience', 'body', 'createdAt', 'sentCount'],
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.smsMessage.findMany({
|
||||
where: where as Prisma.SmsMessageWhereInput,
|
||||
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { sentByAdminUser: true },
|
||||
}),
|
||||
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((s) => ({
|
||||
id: s.id,
|
||||
type: s.audience,
|
||||
text: s.body,
|
||||
mobile: s.recipients,
|
||||
sent_count: s.sentCount,
|
||||
sender: s.sentByAdminUser?.fullName ?? null,
|
||||
caj: s.createdAt,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST sms` — resolves the audience, substitutes `%name` per
|
||||
* recipient (legacy SmsController::batch + replaceName), delivers via the
|
||||
* configured gateway (D26 relay), and records the send.
|
||||
*/
|
||||
async sendSms(
|
||||
data: { type?: string | number; mobile?: string; text?: string },
|
||||
actorId: string,
|
||||
) {
|
||||
const body = String(data.text ?? '').trim();
|
||||
if (!body) throw new BadRequestException('متن پیامک الزامی است');
|
||||
|
||||
const audience = this.resolveAudience(data.type);
|
||||
const recipients = await this.resolveRecipients(audience, data.mobile);
|
||||
|
||||
const needsNameSub = body.includes('%name');
|
||||
const customersByMobile = needsNameSub
|
||||
? await this.customerNamesByMobile(recipients)
|
||||
: new Map<string, string>();
|
||||
|
||||
for (const mobile of recipients) {
|
||||
const text = needsNameSub
|
||||
? body.replace(/%name/g, customersByMobile.get(mobile) ?? 'مشتری عزیز')
|
||||
: body;
|
||||
this.sms.send(mobile, text, { audience });
|
||||
}
|
||||
|
||||
return this.prisma.smsMessage.create({
|
||||
data: {
|
||||
audience,
|
||||
recipients: recipients.join(','),
|
||||
body,
|
||||
sentCount: recipients.length,
|
||||
sentByAdminUserId: actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async customerNamesByMobile(mobiles: string[]): Promise<Map<string, string>> {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { mobile: { in: mobiles } },
|
||||
select: { mobile: true, fullName: true },
|
||||
});
|
||||
const map = new Map<string, string>();
|
||||
for (const r of rows) {
|
||||
if (r.fullName) map.set(r.mobile, r.fullName + ' عزیز');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy's numeric audience codes, read off views/sms/create.vue where each
|
||||
* option is gated by its own rid:
|
||||
* -1 sms.create.single | 0 sms.create.all | 1 sms.create.reagent
|
||||
* 2 sms.create.omde | 9 sms.create.multiple
|
||||
* (there is no option for sms.create.single-customer "tak" in the legacy
|
||||
* markup even though the rid exists — preserved as unreachable.)
|
||||
*/
|
||||
private resolveAudience(type?: string | number): MessageAudience {
|
||||
const byCode: Record<string, MessageAudience> = {
|
||||
'-1': MessageAudience.SINGLE_NUMBER,
|
||||
'0': MessageAudience.ALL_CUSTOMERS,
|
||||
'1': MessageAudience.SALES_AGENTS,
|
||||
'2': MessageAudience.WHOLESALE_CUSTOMERS,
|
||||
'9': MessageAudience.MULTIPLE_NUMBERS,
|
||||
};
|
||||
const byName: Record<string, MessageAudience> = {
|
||||
single: MessageAudience.SINGLE_NUMBER,
|
||||
multiple: MessageAudience.MULTIPLE_NUMBERS,
|
||||
all: MessageAudience.ALL_CUSTOMERS,
|
||||
omde: MessageAudience.WHOLESALE_CUSTOMERS,
|
||||
tak: MessageAudience.RETAIL_CUSTOMERS,
|
||||
reagent: MessageAudience.SALES_AGENTS,
|
||||
};
|
||||
const key = String(type ?? '');
|
||||
return byCode[key] ?? byName[key] ?? MessageAudience.SINGLE_NUMBER;
|
||||
}
|
||||
|
||||
/** Expands an audience into the actual phone numbers it covers. */
|
||||
private async resolveRecipients(
|
||||
audience: MessageAudience,
|
||||
explicit?: string,
|
||||
): Promise<string[]> {
|
||||
if (
|
||||
audience === MessageAudience.SINGLE_NUMBER ||
|
||||
audience === MessageAudience.MULTIPLE_NUMBERS
|
||||
) {
|
||||
const list = String(explicit ?? '')
|
||||
.split(/[,\s\n]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length === 0) throw new BadRequestException('شماره موبایل وارد نشده است');
|
||||
return list;
|
||||
}
|
||||
|
||||
if (audience === MessageAudience.SALES_AGENTS) {
|
||||
const rows = await this.prisma.adminUser.findMany({
|
||||
where: { role: 'SALES_EXPERT', isActive: true },
|
||||
select: { mobile: true },
|
||||
});
|
||||
return rows.map((r) => r.mobile);
|
||||
}
|
||||
|
||||
// The customer audiences. WHOLESALE/RETAIL had a Customer.type column in
|
||||
// legacy that the schema redesign removed, so both currently resolve to all
|
||||
// active customers — the same gap recorded for the omde/tak lists.
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
select: { mobile: true },
|
||||
});
|
||||
return rows.map((r) => r.mobile);
|
||||
}
|
||||
|
||||
// ── Notifications ──────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST notify` — broadcast to an audience. */
|
||||
async createNotification(
|
||||
data: { title?: string; text?: string; body?: string; type?: string | number },
|
||||
actorId: string,
|
||||
) {
|
||||
const title = String(data.title ?? '').trim();
|
||||
const body = String(data.body ?? data.text ?? '').trim();
|
||||
if (!title) throw new BadRequestException('عنوان نوتیفیکیشن الزامی است');
|
||||
if (!body) throw new BadRequestException('متن نوتیفیکیشن الزامی است');
|
||||
|
||||
const audience = this.resolveAudience(data.type);
|
||||
const customers = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return this.prisma.notification.create({
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
audience,
|
||||
sentByAdminUserId: actorId,
|
||||
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
|
||||
},
|
||||
include: { _count: { select: { recipients: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `POST notify/customer` — send to one named customer. */
|
||||
async notifyCustomer(
|
||||
data: { customer_id?: string; title?: string; text?: string; body?: string },
|
||||
actorId: string,
|
||||
) {
|
||||
const customerId = String(data.customer_id ?? '');
|
||||
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
|
||||
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const title = String(data.title ?? '').trim();
|
||||
const body = String(data.body ?? data.text ?? '').trim();
|
||||
if (!title || !body) throw new BadRequestException('عنوان و متن الزامی است');
|
||||
|
||||
return this.prisma.notification.create({
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
audience: MessageAudience.SINGLE_NUMBER,
|
||||
sentByAdminUserId: actorId,
|
||||
recipients: { create: [{ customerId }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET notify/{id}` — one notification with its delivery state. */
|
||||
async findNotification(id: string) {
|
||||
const n = await this.prisma.notification.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
sentByAdminUser: true,
|
||||
recipients: { include: { customer: true } },
|
||||
},
|
||||
});
|
||||
if (!n) throw new NotFoundException('نوتیفیکیشن یافت نشد');
|
||||
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
text: n.body,
|
||||
audience: n.audience,
|
||||
sender: n.sentByAdminUser?.fullName ?? null,
|
||||
caj: n.createdAt,
|
||||
recipients: n.recipients.map((r) => ({
|
||||
id: r.id,
|
||||
customer: { id: r.customer.id, name: r.customer.fullName, mobile: r.customer.mobile },
|
||||
read_at: r.readAt,
|
||||
is_read: Boolean(r.readAt),
|
||||
})),
|
||||
read_count: n.recipients.filter((r) => r.readAt).length,
|
||||
total_count: n.recipients.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST notify/list` — the notification log. */
|
||||
async listNotifications(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title', 'body'],
|
||||
allowed: ['id', 'title', 'body', 'audience', 'createdAt'],
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.notification.findMany({
|
||||
where: where as Prisma.NotificationWhereInput,
|
||||
orderBy: orderBy as Prisma.NotificationOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
sentByAdminUser: true,
|
||||
_count: { select: { recipients: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.notification.count({ where: where as Prisma.NotificationWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
text: n.body,
|
||||
audience: n.audience,
|
||||
sender: n.sentByAdminUser?.fullName ?? null,
|
||||
total_count: n._count.recipients,
|
||||
caj: n.createdAt,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** Legacy `GET notify/read/{id}` — mark one recipient row read. */
|
||||
async markNotificationRead(recipientId: string) {
|
||||
const r = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } });
|
||||
if (!r) throw new NotFoundException('یافت نشد');
|
||||
if (r.readAt) return r;
|
||||
return this.prisma.notificationRecipient.update({
|
||||
where: { id: recipientId },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Newsletter ─────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST ddf` — the newsletter signup list (legacy's own endpoint name). */
|
||||
async listNewsletter(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['email', 'mobile'],
|
||||
allowed: ['id', 'email', 'mobile', 'createdAt'],
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.newsletterSubscriber.findMany({
|
||||
where: where as Prisma.NewsletterSubscriberWhereInput,
|
||||
orderBy: orderBy as Prisma.NewsletterSubscriberOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
this.prisma.newsletterSubscriber.count({
|
||||
where: where as Prisma.NewsletterSubscriberWhereInput,
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((n) => ({ id: n.id, email: n.email, mobile: n.mobile, caj: n.createdAt })),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Activity log ("آمار") ─────────────────────────────────────────
|
||||
|
||||
/** Legacy `GET log/list/customer` — customers that have any activity. */
|
||||
async logCustomers() {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { activityLogs: { some: {} } },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true },
|
||||
});
|
||||
return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile }));
|
||||
}
|
||||
|
||||
/** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */
|
||||
async customerLog(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const rows = await this.prisma.customerActivityLog.findMany({
|
||||
where: { customerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((l) => ({
|
||||
id: l.id,
|
||||
action: l.action,
|
||||
detail: l.detail,
|
||||
caj: l.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Settings ("مدیریت ثابت ها") ───────────────────────────────────
|
||||
|
||||
/** Legacy `GET const/{key}`. */
|
||||
async getConst(key: string) {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/** Legacy `POST const` / `PUT const/{key}`. */
|
||||
async setConst(key: string, value: unknown) {
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: value as Prisma.InputJsonValue },
|
||||
create: { key, value: value as Prisma.InputJsonValue },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
async listConsts() {
|
||||
// The accounting integration's rows (connection, encrypted token, worker
|
||||
// lease) have their own `config.accounting` screen and never appear here
|
||||
// (D27); the single-key routes refuse them in the controller.
|
||||
const rows = await this.prisma.setting.findMany({
|
||||
where: { AND: [{ NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } }, { NOT: { key: { startsWith: PRICING_SETTING_PREFIX } } }] },
|
||||
orderBy: { key: 'asc' },
|
||||
});
|
||||
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
|
||||
}
|
||||
|
||||
async removeConst(key: string) {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException('تنظیم یافت نشد');
|
||||
await this.prisma.setting.delete({ where: { key } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Brands (managed from the same "ثابت ها" screen) ───────────────
|
||||
|
||||
async createBrand(title: string) {
|
||||
if (!title?.trim()) throw new BadRequestException('عنوان برند الزامی است');
|
||||
return this.prisma.brand.create({ data: { title: title.trim() } });
|
||||
}
|
||||
|
||||
async updateBrand(id: string, title: string) {
|
||||
const b = await this.prisma.brand.findUnique({ where: { id } });
|
||||
if (!b) throw new NotFoundException('برند یافت نشد');
|
||||
return this.prisma.brand.update({ where: { id }, data: { title } });
|
||||
}
|
||||
|
||||
async removeBrand(id: string) {
|
||||
const b = await this.prisma.brand.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true } } },
|
||||
});
|
||||
if (!b) throw new NotFoundException('برند یافت نشد');
|
||||
if (b._count.products > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این برند ممکن نیست، چون ${b._count.products} محصول به آن ارجاع دارد.`,
|
||||
);
|
||||
}
|
||||
await this.prisma.brand.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET dashboard/link` — the report shortcuts on views/manage/report. */
|
||||
async dashboardLinks() {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'dashboard.links' } });
|
||||
return row?.value ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AdminMiscService } from './admin-misc.service';
|
||||
import { Public } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Public read of the admin-managed home slider ("اسلایدر موبایل" in
|
||||
* manage/Const.vue), served at GET /api/home/slider.
|
||||
*
|
||||
* The admin write path (const/slider, guarded by the dedicated
|
||||
* `custom.config.statics.slider` permission — see admin-misc.controller.ts)
|
||||
* stores it under the same `Setting` row (key: mobile_main_slider) that this
|
||||
* reads. Split into its own controller/prefix, rather than exposing it under
|
||||
* `admin/misc`, so the shopper-facing site does not call an `/admin/...` path
|
||||
* unauthenticated.
|
||||
*/
|
||||
@Public()
|
||||
@Controller('home')
|
||||
export class PublicSliderController {
|
||||
constructor(private readonly misc: AdminMiscService) {}
|
||||
|
||||
@Get('slider')
|
||||
async getSlider() {
|
||||
const value = await this.misc.getConst('mobile_main_slider');
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { UpdateOrderStatusDto } from './update-status.dto';
|
||||
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (henza_server routes/admin.php):
|
||||
* POST single-order -> the single-sale order grid
|
||||
* GET order/{id} -> order detail + status timeline
|
||||
*
|
||||
* Each route is gated by the same rid the legacy router used in `meta.role`,
|
||||
* so the API enforces what the menu already hides.
|
||||
*/
|
||||
@Controller('admin/orders')
|
||||
export class AdminOrdersController {
|
||||
constructor(private readonly orders: AdminOrdersService) {}
|
||||
|
||||
/** Legacy POST single-order. POST, not GET, because XTable posts its filters. */
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly({ allowBranch: true })
|
||||
@RequirePermission('order.list')
|
||||
list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
/**
|
||||
* Branch scoping, ported from legacy AdminController::getSingleOrders:
|
||||
*
|
||||
* if (!is_null(auth()->user()->branch_id)) {
|
||||
* $records->where('branch_id', auth()->user()->branch_id);
|
||||
* }
|
||||
*
|
||||
* i.e. ANY user carrying a branch_id sees only that branch. Faithful to legacy.
|
||||
*
|
||||
* Consequence worth knowing: the seed gives head-office admins a home branch,
|
||||
* so under this rule they see only that branch's orders. That is legacy's real
|
||||
* behaviour, not a defect introduced here — head office was expected to have a
|
||||
* null branch_id. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
return this.orders.list(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy POST major/order/table — the wholesale grid. */
|
||||
@Post('wholesale/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.omde')
|
||||
listWholesale(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.orders.listWholesale(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy POST order-tamin — orders that reached the sourcing stage. */
|
||||
@Post('tamin/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.tamin')
|
||||
listTamin(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.orders.listTamin(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy POST delete-order. */
|
||||
@Post('delete')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.omde')
|
||||
remove(@Body() body: { id: string }) {
|
||||
return this.orders.remove(body.id);
|
||||
}
|
||||
|
||||
/** Legacy POST /order from the admin order builder (views/order/create.vue). */
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly({ allowBranch: true })
|
||||
@RequirePermission('order.create')
|
||||
createFromAdmin(
|
||||
@Body()
|
||||
body: {
|
||||
customer_id: string;
|
||||
order_items: { vid: string; qty: number; percent?: number; discount?: number }[];
|
||||
sts?: number | string;
|
||||
note?: string;
|
||||
},
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.orders.createFromAdmin(body, admin.id);
|
||||
}
|
||||
|
||||
/** Legacy GET order/type. */
|
||||
@Get('type-options')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('order.create')
|
||||
typeOptions() {
|
||||
return this.orders.typeOptions();
|
||||
}
|
||||
|
||||
// The three routes below also serve the wholesale and sourcing lists, whose
|
||||
// staff hold order.omde / order.tamin rather than the super-admin-only
|
||||
// order.list.
|
||||
@Get('status-options')
|
||||
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||
statusOptions() {
|
||||
return this.orders.statusOptions();
|
||||
}
|
||||
|
||||
/** Legacy POST single/order-sts/edit. */
|
||||
@Post('status')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.orders.setStatus(dto, admin.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.orders.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, OrdersModule, AccountingModule],
|
||||
controllers: [AdminOrdersController],
|
||||
providers: [AdminOrdersService],
|
||||
})
|
||||
export class AdminOrdersModule {}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import type { UpdateOrderStatusDto } from './update-status.dto';
|
||||
|
||||
/**
|
||||
* Farsi status labels, matching legacy's `sts_str` column.
|
||||
*
|
||||
* Legacy stored status as an integer `sts` and the server derived the string via
|
||||
* Order::$SELECT_STS_STR. The new schema uses an enum, so the mapping is explicit.
|
||||
* Debt is recognised at sts>4 (DELIVERED_TO_AGENT) — see the schema notes.
|
||||
*/
|
||||
const STATUS_LABELS: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'معلق (سقف اعتبار)',
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
SOURCING: 'در حال تامین',
|
||||
PREPARING: 'در حال آمادهسازی',
|
||||
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
||||
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
|
||||
/** Order of the status timeline shown in the legacy "وضعیت سفارش" modal. */
|
||||
const STATUS_FLOW: OrderStatus[] = [
|
||||
OrderStatus.PLACED,
|
||||
OrderStatus.ADMIN_APPROVED,
|
||||
OrderStatus.SOURCING,
|
||||
OrderStatus.PREPARING,
|
||||
OrderStatus.DELIVERED_TO_AGENT,
|
||||
OrderStatus.DELIVERED_TO_CUSTOMER,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly orders: OrdersService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
private readonly ledger: LedgerService,
|
||||
) {}
|
||||
|
||||
/** Fields the XTable filter bar and sorter may reference. */
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'orderNumber',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'totalGoldWeightGrams',
|
||||
'flatRialCharge',
|
||||
'isSuspendedOverCreditLimit',
|
||||
'customer.fullName',
|
||||
'customer.mobile',
|
||||
'branch.name',
|
||||
];
|
||||
|
||||
/**
|
||||
* Legacy `POST single-order` — the order list grid.
|
||||
*
|
||||
* `branchId` narrows the result to one branch, which is how the branch-mode
|
||||
* build of the panel scopes its list.
|
||||
*/
|
||||
async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const scoped: Prisma.OrderWhereInput = {
|
||||
AND: [
|
||||
where as Prisma.OrderWhereInput,
|
||||
{ channel },
|
||||
...(branchId ? [{ branchId }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes a row the way the ported list view reads it.
|
||||
*
|
||||
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
|
||||
* so the ported template bindings keep working unchanged.
|
||||
*/
|
||||
private toRow(o: Prisma.OrderGetPayload<{
|
||||
include: {
|
||||
customer: true;
|
||||
branch: true;
|
||||
items: { include: { productVariant: { include: { product: true } } } };
|
||||
};
|
||||
}>) {
|
||||
return {
|
||||
id: o.id,
|
||||
orderNumber: o.orderNumber,
|
||||
status: o.status,
|
||||
sts_str: STATUS_LABELS[o.status],
|
||||
caj: o.createdAt,
|
||||
uaj: o.updatedAt,
|
||||
total_price: o.flatRialCharge,
|
||||
totalGoldWeightGrams: o.totalGoldWeightGrams,
|
||||
wageWeightGrams: o.wageWeightGrams,
|
||||
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
|
||||
deliveredAt: o.deliveredAt,
|
||||
branch_id: o.branchId,
|
||||
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
|
||||
customer: {
|
||||
id: o.customer.id,
|
||||
// Legacy split names into fname/lname; the new schema has one fullName.
|
||||
// Both are exposed so ported templates binding either keep working.
|
||||
fname: o.customer.firstName ?? o.customer.fullName,
|
||||
lname: o.customer.lastName ?? '',
|
||||
fullName: o.customer.fullName,
|
||||
phone: o.customer.mobile,
|
||||
},
|
||||
items: o.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.productVariant.product.title,
|
||||
variant_title: it.productVariant.title,
|
||||
qty: it.quantity,
|
||||
grams: it.computedWeightGrams,
|
||||
rash: it.rashWeightGramsSnapshot,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST major/order/table` — the wholesale (عمده) order grid.
|
||||
*
|
||||
* Legacy kept the two sales channels in SEPARATE tables: `orders_orders`
|
||||
* (SingleCustomerOrders, served by AdminController::getSingleOrders) and
|
||||
* `orders` (Order, served by MajorOrderController::index). Neither controller
|
||||
* applied a type filter because the table itself was the discriminator, so the
|
||||
* two lists never overlapped.
|
||||
*
|
||||
* The redesigned schema has one Order model, so `Order.channel` carries that
|
||||
* distinction instead and each list filters on it.
|
||||
*/
|
||||
async listWholesale(query: TableQueryDto, branchId?: string) {
|
||||
return this.list(query, branchId, OrderChannel.WHOLESALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST order-tamin` — MajorOrderController::getTaminOrders.
|
||||
*
|
||||
* Legacy narrowed to orders whose activity contained Order::$STS[3], i.e. those
|
||||
* that have reached the sourcing stage. The equivalent here is a status event
|
||||
* recording a transition into SOURCING.
|
||||
*/
|
||||
async listTamin(query: TableQueryDto, branchId?: string) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const conditions: Prisma.OrderWhereInput[] = [
|
||||
where as Prisma.OrderWhereInput,
|
||||
{ statusEvents: { some: { toStatus: OrderStatus.SOURCING } } },
|
||||
];
|
||||
if (branchId) conditions.push({ branchId });
|
||||
|
||||
const scoped: Prisma.OrderWhereInput = { AND: conditions };
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy `POST delete-order` — remove a wholesale order. */
|
||||
async remove(id: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: { ledgerEntries: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
|
||||
// Refuses once debt has been posted: deleting the order would orphan the
|
||||
// ledger entries and leave the customer permanently in debt. Legacy had no
|
||||
// such guard. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
if (order.ledgerEntries.length > 0) {
|
||||
throw new BadRequestException(
|
||||
'حذف این سفارش ممکن نیست، چون برای آن سند حسابداری ثبت شده است.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// D27 — a temporary document may already be in the books (the rule can
|
||||
// post it at placement); withdraw it like a cancellation.
|
||||
await this.accounting.orderCancelled(tx, id);
|
||||
await tx.orderStatusEvent.deleteMany({ where: { orderId: id } });
|
||||
await tx.orderItem.deleteMany({ where: { orderId: id } });
|
||||
await tx.order.delete({ where: { id } });
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET order/:id` detail, including the status timeline. */
|
||||
async findOne(id: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
|
||||
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
|
||||
|
||||
return {
|
||||
...this.toRow(order),
|
||||
// legacy `activity`: one entry per step, `proceed` marking those reached
|
||||
activity: STATUS_FLOW.map((s) => {
|
||||
const event = order.statusEvents.find((e) => e.toStatus === s);
|
||||
return {
|
||||
status: s,
|
||||
title: STATUS_LABELS[s],
|
||||
proceed: reached.has(s),
|
||||
uaj: event?.createdAt ?? null,
|
||||
note: event?.note ?? '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Farsi labels for the status dropdowns. */
|
||||
statusOptions() {
|
||||
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET order/type` — the "نوع فروش" dropdown.
|
||||
* Legacy Order::$TYPES = [1 => 'ثبت سفارش توسط مسئول فروش', 2 => 'ثبت مشتری'].
|
||||
*/
|
||||
typeOptions() {
|
||||
return { 1: 'ثبت سفارش توسط مسئول فروش', 2: 'ثبت مشتری' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST /order` from views/order/create.vue — the admin order builder.
|
||||
*
|
||||
* Delegates to OrdersService.createOrder, which owns the weight formula
|
||||
* w = rash * (1 + (wagePercent - discountPercent)/100)
|
||||
* and the credit-limit gate that suspends rather than rejects an over-limit
|
||||
* order. Reimplementing either here would let the two paths drift.
|
||||
*
|
||||
* Legacy also posted total_price / total_weight / gold_price_original computed
|
||||
* in the BROWSER. Those are ignored: the server recomputes them from the
|
||||
* variant snapshots, so a tampered or stale client total cannot alter a debt.
|
||||
*/
|
||||
async createFromAdmin(
|
||||
payload: {
|
||||
customer_id: string;
|
||||
order_items: {
|
||||
vid: string;
|
||||
qty: number;
|
||||
percent?: number;
|
||||
discount?: number;
|
||||
rial_input?: number;
|
||||
}[];
|
||||
sts?: number | string;
|
||||
note?: string;
|
||||
},
|
||||
actorAdminUserId: string,
|
||||
) {
|
||||
if (!payload.customer_id) throw new BadRequestException('مشتری انتخاب نشده است');
|
||||
if (!Array.isArray(payload.order_items) || payload.order_items.length === 0) {
|
||||
throw new BadRequestException('سبد سفارش خالی است');
|
||||
}
|
||||
|
||||
const items = await Promise.all(
|
||||
payload.order_items.map(async (it) => {
|
||||
const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } });
|
||||
if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`);
|
||||
return {
|
||||
productVariantId: it.vid,
|
||||
quantity: Number(it.qty) || 1,
|
||||
// Fall back to the variant's own default wage when the builder did not
|
||||
// negotiate a different one.
|
||||
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
|
||||
discountPercent: Number(it.discount ?? 0),
|
||||
flatRialCharge: Number(it.rial_input ?? 0),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// The customer id is now a separate argument rather than a body field, so
|
||||
// the customer-facing route can take it from the caller's token (SEC-02).
|
||||
// This path is different: an operator legitimately places an order FOR a
|
||||
// customer, so it passes the one the builder selected. The route itself is
|
||||
// admin-guarded, which is what makes that safe.
|
||||
const order = await this.orders.createOrder(
|
||||
{
|
||||
items,
|
||||
// The admin builder serves the omde (major/*) flow, so orders it creates
|
||||
// belong to the wholesale channel — legacy wrote them to the `orders`
|
||||
// table, which MajorOrderController::index listed.
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
},
|
||||
payload.customer_id,
|
||||
);
|
||||
|
||||
// legacy `sts`: advance the new order to the chosen delivery status.
|
||||
const target = this.statusFromLegacyCode(payload.sts);
|
||||
if (target && target !== OrderStatus.PLACED) {
|
||||
await this.orders.updateStatus(order.id, target, actorAdminUserId, payload.note);
|
||||
}
|
||||
|
||||
return this.findOne(order.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy delivery-status codes from create.vue's `sts` map:
|
||||
* 1 ثبت سفارش | 2 تایید مدیر سیستم | 3 در حال تامین
|
||||
* 4 در حال آماده سازی | 5 ارسال | 6 تحویل مشتری
|
||||
*/
|
||||
private statusFromLegacyCode(code?: number | string): OrderStatus | undefined {
|
||||
const map: Record<number, OrderStatus> = {
|
||||
1: OrderStatus.PLACED,
|
||||
2: OrderStatus.ADMIN_APPROVED,
|
||||
3: OrderStatus.SOURCING,
|
||||
4: OrderStatus.PREPARING,
|
||||
5: OrderStatus.DELIVERED_TO_AGENT,
|
||||
6: OrderStatus.DELIVERED_TO_CUSTOMER,
|
||||
};
|
||||
return code === undefined ? undefined : map[Number(code)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST single/order-sts/edit` — advance or remove a status.
|
||||
*
|
||||
* Setting a status delegates to OrdersService.updateStatus rather than writing
|
||||
* the row here, because that method also keeps the order's debt in line with its status
|
||||
* (syncOrderDebt). Duplicating the update would silently skip that.
|
||||
*/
|
||||
async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) {
|
||||
if (dto.remove) {
|
||||
return this.removeStatusEvent(dto.orderId, dto.status);
|
||||
}
|
||||
return this.orders.updateStatus(dto.orderId, dto.status, actorAdminUserId, dto.note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy's `remove: true` branch: drop a recorded status event.
|
||||
*
|
||||
* Any status may be removed, as in legacy (MajorOrderController.php:180-203),
|
||||
* which stepped `sts` back and then RECOMPUTED the debt (updateRemain). The
|
||||
* rollback below does the same through syncOrderDebt, so taking an order
|
||||
* back below «تحویل به نماینده» takes its debt off, and nothing is left
|
||||
* behind. (This used to be refused, on the belief that legacy left the debt
|
||||
* in place; legacy recomputed it.)
|
||||
*/
|
||||
private async removeStatusEvent(orderId: string, status: OrderStatus) {
|
||||
const event = await this.prisma.orderStatusEvent.findFirst({
|
||||
where: { orderId, toStatus: status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
|
||||
|
||||
// One transaction: the event, the status and the debt move together or not at all.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.orderStatusEvent.delete({ where: { id: event.id } });
|
||||
|
||||
// Roll the order back to whatever status is now the latest recorded one.
|
||||
const latest = await tx.orderStatusEvent.findFirst({
|
||||
where: { orderId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED;
|
||||
const updated = await tx.order.update({ where: { id: orderId }, data: { status: rolledBackTo } });
|
||||
// Legacy updateRemain after the step back: the debt follows the status.
|
||||
await this.ledger.syncOrderDebt(orderId, tx);
|
||||
// D27 — rolled back below the document rule: the پیشفاکتور is withdrawn.
|
||||
await this.accounting.orderStatusReached(tx, orderId, rolledBackTo);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Body of legacy `POST single/order-sts/edit`.
|
||||
*
|
||||
* Legacy posted the whole timeline row back, so it carried `status`, an optional
|
||||
* `note`, and `remove: true` when the operator was deleting a status instead of
|
||||
* setting one. The order id travelled on the row as `order_id`/`orderId`.
|
||||
*/
|
||||
export class UpdateOrderStatusDto {
|
||||
@IsString()
|
||||
orderId!: string;
|
||||
|
||||
@IsEnum(OrderStatus)
|
||||
status!: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
/** legacy: row.remove = true -> delete this status event rather than add one */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
remove?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminPreOrdersService } from './admin-preorders.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CustomOrderController):
|
||||
* POST get-custom-orders -> the "سفارش های ساختنی" grid
|
||||
* GET get-custom-order/{id}
|
||||
* PUT custom-order/{id}
|
||||
* DELETE custom-order/{id}
|
||||
*/
|
||||
@Controller('admin/pre-orders')
|
||||
export class AdminPreOrdersController {
|
||||
constructor(private readonly preOrders: AdminPreOrdersService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.makeOrder')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.preOrders.list(query);
|
||||
}
|
||||
|
||||
@Get('status-options')
|
||||
@RequirePermission('order.makeOrder')
|
||||
statusOptions() {
|
||||
return this.preOrders.statusOptions();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('order.makeOrder')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.preOrders.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('order.makeOrder')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.preOrders.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('order.makeOrder')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.preOrders.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminPreOrdersController } from './admin-preorders.controller';
|
||||
import { AdminPreOrdersService } from './admin-preorders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminPreOrdersController],
|
||||
providers: [AdminPreOrdersService],
|
||||
})
|
||||
export class AdminPreOrdersModule {}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PreOrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Custom / made-to-order requests — legacy "سفارش های ساختنی".
|
||||
*
|
||||
* Legacy served these from CustomOrderController (`POST get-custom-orders`,
|
||||
* `PUT custom-order/{id}`, `DELETE custom-order/{id}`). The redesigned schema
|
||||
* models them as PreOrder: a design request that an admin prices and later
|
||||
* converts into a real, debt-bearing Order.
|
||||
*/
|
||||
const PREORDER_STATUS_LABELS: Record<PreOrderStatus, string> = {
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
WORKSHOP_APPROVED: 'تایید کارگاه',
|
||||
READY_TO_SHIP: 'آماده ارسال',
|
||||
DELIVERED: 'تحویل شده',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminPreOrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'colorName',
|
||||
'sizeSpec',
|
||||
'stoneColor',
|
||||
'minaColor',
|
||||
'estimatedWeightGrams',
|
||||
'estimatedPriceRial',
|
||||
'customer.fullName',
|
||||
'customer.mobile',
|
||||
];
|
||||
|
||||
/** Legacy `POST get-custom-orders`. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminPreOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.preOrder.findMany({
|
||||
where: where as Prisma.PreOrderWhereInput,
|
||||
orderBy: orderBy as Prisma.PreOrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, city: true, workshop: true },
|
||||
}),
|
||||
this.prisma.preOrder.count({ where: where as Prisma.PreOrderWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names (color, size, color_mina, color_stone, ...) preserved. */
|
||||
private toRow(
|
||||
p: Prisma.PreOrderGetPayload<{ include: { customer: true; city: true; workshop: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
status: p.status,
|
||||
sts_str_fa: PREORDER_STATUS_LABELS[p.status],
|
||||
color: p.colorName,
|
||||
size: p.sizeSpec,
|
||||
color_mina: p.minaColor,
|
||||
color_stone: p.stoneColor,
|
||||
// legacy 'sum' (quantity); a PreOrder models a single design request
|
||||
sum: 1,
|
||||
description: p.description,
|
||||
designImageUrl: p.designImageUrl,
|
||||
estimatedWeightGrams: p.estimatedWeightGrams,
|
||||
estimatedPriceRial: p.estimatedPriceRial,
|
||||
time_creat_fa: p.createdAt,
|
||||
time_update_fa: p.updatedAt,
|
||||
convertedOrderId: p.convertedOrderId,
|
||||
city: p.city ? { id: p.city.id, title: p.city.name } : null,
|
||||
workshop: p.workshop ? { id: p.workshop.id, name: p.workshop.name } : null,
|
||||
customer: {
|
||||
id: p.customer.id,
|
||||
// D0 — real name parts
|
||||
fname: p.customer.firstName ?? p.customer.fullName,
|
||||
lname: p.customer.lastName ?? '',
|
||||
name: p.customer.fullName,
|
||||
fullName: p.customer.fullName,
|
||||
mobile: p.customer.mobile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const p = await this.prisma.preOrder.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, city: true, workshop: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
|
||||
return this.toRow(p);
|
||||
}
|
||||
|
||||
/** Legacy `PUT custom-order/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.preOrder.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سفارش ساختنی یافت نشد');
|
||||
|
||||
const patch: Prisma.PreOrderUpdateInput = {};
|
||||
if (data.status !== undefined) patch.status = data.status as PreOrderStatus;
|
||||
if (data.color !== undefined) patch.colorName = data.color as string;
|
||||
if (data.size !== undefined) patch.sizeSpec = data.size as string;
|
||||
if (data.color_mina !== undefined) patch.minaColor = data.color_mina as string;
|
||||
if (data.color_stone !== undefined) patch.stoneColor = data.color_stone as string;
|
||||
if (data.description !== undefined) patch.description = data.description as string;
|
||||
if (data.estimatedWeightGrams !== undefined) {
|
||||
patch.estimatedWeightGrams = new Prisma.Decimal(data.estimatedWeightGrams as string);
|
||||
}
|
||||
if (data.estimatedPriceRial !== undefined) {
|
||||
patch.estimatedPriceRial = new Prisma.Decimal(data.estimatedPriceRial as string);
|
||||
}
|
||||
|
||||
return this.prisma.preOrder.update({ where: { id }, data: patch });
|
||||
}
|
||||
|
||||
/** Legacy `DELETE custom-order/{id}`. */
|
||||
async remove(id: string) {
|
||||
const p = await this.prisma.preOrder.findUnique({ where: { id } });
|
||||
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
|
||||
|
||||
// Once converted into a real Order the design request is part of an
|
||||
// accounting trail; deleting it would orphan that link. Legacy had no guard.
|
||||
if (p.convertedOrderId) {
|
||||
throw new BadRequestException(
|
||||
'حذف ممکن نیست: این سفارش ساختنی قبلاً به سفارش قطعی تبدیل شده است.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.preOrderStatusEvent.deleteMany({ where: { preOrderId: id } }),
|
||||
this.prisma.preOrder.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
statusOptions() {
|
||||
return Object.entries(PREORDER_STATUS_LABELS).map(([value, title]) => ({ value, title }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { isSuperAdmin } from '../auth/super-admin';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (ProductController):
|
||||
* POST products -> the product grid
|
||||
* GET product/show/{id}
|
||||
* POST product/create
|
||||
* PUT product/update/{id}
|
||||
* POST delete/product/{id} (a delete expressed as POST — legacy's shape)
|
||||
* POST get/variants (a read expressed as POST — legacy's shape)
|
||||
* GET product/{id}/variant
|
||||
* POST change/percent/count, POST change/all
|
||||
* GET brand, POST category
|
||||
*/
|
||||
@Controller('admin/products')
|
||||
export class AdminProductsController {
|
||||
constructor(private readonly products: AdminProductsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.products.list(query);
|
||||
}
|
||||
|
||||
@Post('variants/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
listVariants(@Body() query: TableQueryDto & { product_id?: string }) {
|
||||
return this.products.listVariants(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST product/variant` — the cart's rehydrate call. Given a list of
|
||||
* product ids it returns each product with `variants_all`, so the basket can
|
||||
* refresh prices/stock and drop anything that went out of stock.
|
||||
*/
|
||||
@Post('by-ids')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
byIds(@Body() body: { products_id: string[] }) {
|
||||
return this.products.byIds(body.products_id ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST add/variant` — creates a bespoke weight variant from the order
|
||||
* builder's inline weight editor.
|
||||
*/
|
||||
@Post('variants/create')
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('order.create')
|
||||
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
|
||||
return this.products.createVariant(body);
|
||||
}
|
||||
|
||||
@Get('show-type-options')
|
||||
@RequirePermission('product.list')
|
||||
showTypeOptions() {
|
||||
return this.products.showTypeOptions();
|
||||
}
|
||||
|
||||
@Get('brands')
|
||||
@RequirePermission('product.list')
|
||||
brands() {
|
||||
return this.products.brands();
|
||||
}
|
||||
|
||||
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
|
||||
@Post('brands-table')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
brandsTable(@Body() query: TableQueryDto) {
|
||||
return this.products.brandsTable(query);
|
||||
}
|
||||
|
||||
|
||||
@Get('collections')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('product.list')
|
||||
collections() {
|
||||
return this.products.collections();
|
||||
}
|
||||
|
||||
@Post('change-percent/count')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.percent')
|
||||
changePercentCount(@Body() body: { brand_id?: string; category_id?: string }) {
|
||||
return this.products.changePercentCount(body);
|
||||
}
|
||||
|
||||
@Post('change-percent/apply')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.percent')
|
||||
changePercent(@Body() body: { percent: number; brand_id?: string; category_id?: string }) {
|
||||
return this.products.changePercent(body);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.create')
|
||||
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
// Trays are super-admin-only; anyone else's product is created without them.
|
||||
if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids;
|
||||
return this.products.create(data);
|
||||
}
|
||||
|
||||
@Post('delete/:id')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.products.remove(id);
|
||||
}
|
||||
|
||||
@Get(':id/variants')
|
||||
@RequirePermission('product.list')
|
||||
variantsOf(@Param('id') id: string) {
|
||||
return this.products.variantsOf(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('product.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.products.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('product.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.products.update(id, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminProductsController } from './admin-products.controller';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminProductsController],
|
||||
providers: [AdminProductsService],
|
||||
})
|
||||
export class AdminProductsModule {}
|
||||
@@ -0,0 +1,733 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
|
||||
/**
|
||||
* Product administration — legacy views/apps/product/* served by
|
||||
* ProductController (`POST products`, `POST product/create`,
|
||||
* `PUT product/update/{id}`, `GET product/show/{id}`,
|
||||
* `POST delete/product/{id}`, `POST get/variants`, `POST change/percent/count`).
|
||||
*
|
||||
* Legacy `show_type` values, preserved as integers:
|
||||
* 1 سایت | 2 گوشی | 3 سایت و گوشی
|
||||
*/
|
||||
const SHOW_TYPE_LABELS: Record<number, string> = {
|
||||
1: 'سایت',
|
||||
2: 'گوشی',
|
||||
3: 'سایت و گوشی',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminProductsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'title',
|
||||
'productCode',
|
||||
'showType',
|
||||
'isActive',
|
||||
'createdAt',
|
||||
'brand.title',
|
||||
'category.title',
|
||||
// legacy filter keys
|
||||
'product_code',
|
||||
'show_type',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
product_code: 'productCode',
|
||||
show_type: 'showType',
|
||||
'category.collection.title': 'category.title',
|
||||
caj: 'createdAt',
|
||||
};
|
||||
|
||||
private applyAliases(query: TableQueryDto): TableQueryDto {
|
||||
const mapField = (f?: string) => (f ? (AdminProductsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
return {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'showType' && mapped.value !== undefined) {
|
||||
mapped.value = Number(mapped.value);
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST products` — the product grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
const aliased = this.applyAliases(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['title', 'productCode'],
|
||||
allowed: AdminProductsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where: where as Prisma.ProductWhereInput,
|
||||
orderBy: orderBy as Prisma.ProductOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
brand: true,
|
||||
category: true,
|
||||
images: { orderBy: { sortOrder: 'asc' } },
|
||||
variants: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where: where as Prisma.ProductWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names preserved so ported table models bind unchanged. */
|
||||
private toRow(
|
||||
p: Prisma.ProductGetPayload<{
|
||||
include: { brand: true; category: true; images: true; variants: true };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
product_code: p.productCode,
|
||||
show_type: p.showType,
|
||||
show_type_str: SHOW_TYPE_LABELS[p.showType] ?? '-',
|
||||
is_active: p.isActive,
|
||||
description: p.description,
|
||||
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
|
||||
// Legacy nested this as category.collection.title in the grid model.
|
||||
category: p.category
|
||||
? { id: p.category.id, title: p.category.title, collection: { title: p.category.title } }
|
||||
: null,
|
||||
// legacy grid slot reads `ProductImage`
|
||||
ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })),
|
||||
// D27 — stock as last read from the accounting software (null = not
|
||||
// linked / never synced).
|
||||
accounting_stock: p.accountingStockAt
|
||||
? { weight: p.accountingStockWeight, count: p.accountingStockCount, at: p.accountingStockAt }
|
||||
: null,
|
||||
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder, role: i.role })),
|
||||
// The order builder's grid reads `thumb.full` and `variants_all` directly
|
||||
// off each row, so both travel with the list as they did in legacy. Legacy's
|
||||
// grid thumbnail was always the featured image (تصویر شاخص) when one was
|
||||
// set — fall back to the first gallery image only if no featured image
|
||||
// was uploaded, rather than picking whichever image happens to sort first.
|
||||
thumb: (() => {
|
||||
const featured = p.images.find((i) => i.role === 'FEATURED') ?? p.images[0];
|
||||
return featured ? { full: featured.url } : null;
|
||||
})(),
|
||||
variants_all: p.variants.map((v) => this.variantRow(v)),
|
||||
variants_count: p.variants.length,
|
||||
// legacy min_gram / max_gram: the product's lightest and heaviest variant,
|
||||
// shown by the storefront and by the branch-allocation picker.
|
||||
min_gram: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
max_gram: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(max, v) => (v.rashWeightGrams.gt(max) ? v.rashWeightGrams : max),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
caj: p.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET product/show/{id}`. */
|
||||
async findOne(id: string) {
|
||||
const p = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
brand: true,
|
||||
category: true,
|
||||
images: { orderBy: { sortOrder: 'asc' } },
|
||||
variants: { include: { image: true } },
|
||||
collections: { include: { collection: true } },
|
||||
},
|
||||
});
|
||||
if (!p) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
// D27 — the form locks title/code of a linked product, and offers the
|
||||
// accounting software's sale making charge as the default for new variants.
|
||||
const link = await this.accounting.productLink(p.id);
|
||||
const snapshot = (link?.snapshot ?? {}) as { saleWagePercent?: string | null };
|
||||
|
||||
return {
|
||||
...this.toRow(p),
|
||||
accounting: link
|
||||
? { locked: true, code: link.externalCode, origin: link.origin, sale_wage_percent: snapshot.saleWagePercent ?? null }
|
||||
: null,
|
||||
body_html: p.description,
|
||||
brand_id: p.brandId,
|
||||
cats: p.categoryId ? [p.categoryId] : [],
|
||||
ciny_ids: p.collections.map((c) => c.collectionId),
|
||||
variants_all: p.variants.map((v) => this.variantRow(v)),
|
||||
// The 4 image slots the product form edits, split back out by role —
|
||||
// mirrors the shape AddProduct.vue posts (indicator_id / mobile_pic /
|
||||
// images / images_mobile).
|
||||
indicator_id: p.images.find((i) => i.role === 'FEATURED')?.url ?? null,
|
||||
mobile_pic: p.images.find((i) => i.role === 'FEATURED_MOBILE')?.url ?? null,
|
||||
images_gallery: p.images.filter((i) => i.role === 'GALLERY').map((i) => i.url),
|
||||
images_mobile: p.images.filter((i) => i.role === 'GALLERY_MOBILE').map((i) => i.url),
|
||||
// The same images again, but carrying their ids — the variant image
|
||||
// picker needs something to select. The four URL-only fields above stay
|
||||
// exactly as they were so the form's existing bindings keep working.
|
||||
gallery: p.images.map((i) => ({
|
||||
id: i.id,
|
||||
url: i.url,
|
||||
role: i.role,
|
||||
sortOrder: i.sortOrder,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private variantRow(v: Prisma.ProductVariantGetPayload<object> & { image?: { id: string; url: string } | null }) {
|
||||
return {
|
||||
id: v.id,
|
||||
product_id_id: v.productId,
|
||||
title: v.title,
|
||||
sku: v.sku,
|
||||
grams: v.rashWeightGrams,
|
||||
percentage_pay: v.defaultWagePercent,
|
||||
qty: v.qty,
|
||||
is_pre_order: v.isPreOrder,
|
||||
is_active: v.isActive,
|
||||
// The gallery image picked for this variant. `image_id` is what the form
|
||||
// posts back; `image` is denormalised so the table can render a thumbnail
|
||||
// without a second lookup. Only populated where the query included it.
|
||||
image_id: v.imageId,
|
||||
image: v.image ? { id: v.image.id, url: v.image.url } : null,
|
||||
// Legacy had a separate rial making-charge column; the schema models wage
|
||||
// as a percentage only. Returned as null rather than faked.
|
||||
rial_pay: null,
|
||||
color_str: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST get/variants` — a POST that reads, per legacy's own routing. */
|
||||
async listVariants(query: TableQueryDto & { product_id?: string }) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title', 'sku'],
|
||||
allowed: ['id', 'title', 'sku', 'qty', 'rashWeightGrams', 'isActive', 'product.title'],
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const scoped: Prisma.ProductVariantWhereInput = query.product_id
|
||||
? { AND: [where as Prisma.ProductVariantWhereInput, { productId: query.product_id }] }
|
||||
: (where as Prisma.ProductVariantWhereInput);
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.productVariant.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.ProductVariantOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { product: true, image: true },
|
||||
}),
|
||||
this.prisma.productVariant.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((v) => ({ ...this.variantRow(v), product: { id: v.product.id, title: v.product.title } })),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST product/variant` — used by the cart to refresh the products it
|
||||
* holds. Returns each product with `variants_all` attached, the shape
|
||||
* stores/cart.ts reads.
|
||||
*/
|
||||
async byIds(ids: string[]) {
|
||||
if (!ids.length) return [];
|
||||
const rows = await this.prisma.product.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
brand: true,
|
||||
category: true,
|
||||
images: { orderBy: { sortOrder: 'asc' } },
|
||||
variants: { include: { image: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((p) => ({
|
||||
...this.toRow(p),
|
||||
variants_all: p.variants.map((v) => this.variantRow(v)),
|
||||
// The cart reads `promotion.id`; there is no Promotion model yet (Batch 9).
|
||||
promotion: null,
|
||||
thumb: p.images[0] ? { full: p.images[0].url } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST add/variant` — the order builder's inline weight editor creates
|
||||
* a bespoke variant rather than mutating the shared catalog one, so other
|
||||
* orders keep the weight they were placed with.
|
||||
*/
|
||||
async createVariant(body: { product_id: string; title: string; grams: number }) {
|
||||
if (!body.product_id) throw new BadRequestException('محصول مشخص نشده است');
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: body.product_id },
|
||||
include: { variants: { take: 1 } },
|
||||
});
|
||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
return this.prisma.productVariant.create({
|
||||
data: {
|
||||
productId: body.product_id,
|
||||
title: body.title,
|
||||
rashWeightGrams: new Prisma.Decimal(String(body.grams ?? 0)),
|
||||
// Inherit the product's existing wage rate so the bespoke weight prices
|
||||
// the same way as its siblings.
|
||||
defaultWagePercent:
|
||||
product.variants[0]?.defaultWagePercent ?? new Prisma.Decimal(0),
|
||||
qty: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET product/{id}/variant`. */
|
||||
async variantsOf(productId: string) {
|
||||
const rows = await this.prisma.productVariant.findMany({
|
||||
where: { productId },
|
||||
include: { image: true },
|
||||
});
|
||||
return rows.map((v) => this.variantRow(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of one inline variant row posted by the product form.
|
||||
*
|
||||
* `image_id` is resolved against `allowedImageIds` — the ids of the images
|
||||
* belonging to *this* product — so a payload can never point a variant at
|
||||
* another product's image. An absent/empty value clears the picture.
|
||||
*/
|
||||
private variantCreateData(v: Record<string, unknown>, allowedImageIds: Set<string>) {
|
||||
const rawImageId = v.image_id ?? v.imageId;
|
||||
const imageId = rawImageId ? String(rawImageId) : null;
|
||||
if (imageId && !allowedImageIds.has(imageId)) {
|
||||
throw new BadRequestException('تصویر انتخابشده متعلق به این محصول نیست');
|
||||
}
|
||||
|
||||
return {
|
||||
title: String(v.title ?? ''),
|
||||
rashWeightGrams: new Prisma.Decimal(String(v.grams ?? 0)),
|
||||
defaultWagePercent: new Prisma.Decimal(String(v.percentage_pay ?? 0)),
|
||||
qty: Number(v.qty ?? 0),
|
||||
isPreOrder: Boolean(v.is_pre_order),
|
||||
isActive: v.is_active !== undefined ? Boolean(v.is_active) : true,
|
||||
imageId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a commercial SKU for a newly created variant.
|
||||
*
|
||||
* Derived from the product's own code (falling back to a slice of its id when
|
||||
* no code was entered) plus a running number, e.g. `RING-001-01`. Only ever
|
||||
* called for variants that have no SKU yet — an existing one is never
|
||||
* rewritten, because it may already be in use in stock/sales systems.
|
||||
*
|
||||
* `taken` carries the SKUs handed out earlier in the same request so two new
|
||||
* variants in one submit cannot collide before either is written.
|
||||
*/
|
||||
private async generateSku(
|
||||
productCode: string | null,
|
||||
productId: string,
|
||||
taken: Set<string>,
|
||||
): Promise<string> {
|
||||
const base = (productCode?.trim() || productId.slice(-6)).toUpperCase().replace(/\s+/g, '-');
|
||||
|
||||
// Start past the highest suffix already stored for this prefix, so deleting
|
||||
// and re-adding variants does not reuse a retired code.
|
||||
const existing = await this.prisma.productVariant.findMany({
|
||||
where: { sku: { startsWith: `${base}-` } },
|
||||
select: { sku: true },
|
||||
});
|
||||
for (const row of existing) if (row.sku) taken.add(row.sku);
|
||||
|
||||
for (let n = 1; n <= 9999; n++) {
|
||||
const candidate = `${base}-${String(n).padStart(2, '0')}`;
|
||||
if (!taken.has(candidate)) {
|
||||
taken.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Practically unreachable; keeps the return type honest.
|
||||
return `${base}-${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy had 4 distinct upload slots (تصویر شاخص / تصویر شاخص موبایل /
|
||||
* گالری تصاویر سایت / گالری تصاویر موبایل — AddProduct.vue's `indicator_id`,
|
||||
* `mobile_pic`, `images`, and a mobile gallery). The form posts one URL each
|
||||
* for the two featured slots and one URL array each for the two galleries;
|
||||
* this flattens all four into the role-tagged rows ProductImage.create wants.
|
||||
*/
|
||||
private imageCreateData(data: Record<string, unknown>): { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] {
|
||||
const rows: { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] = [];
|
||||
|
||||
const single = (v: unknown, role: Prisma.ProductImageCreateInput['role']) => {
|
||||
const url = typeof v === 'string' ? v : String((v as Record<string, unknown>)?.url ?? '');
|
||||
if (url) rows.push({ url, sortOrder: 0, role });
|
||||
};
|
||||
const gallery = (v: unknown, role: Prisma.ProductImageCreateInput['role']) => {
|
||||
if (!Array.isArray(v)) return;
|
||||
(v as unknown[]).forEach((item, i) => {
|
||||
const url = typeof item === 'string' ? item : String((item as Record<string, unknown>)?.url ?? '');
|
||||
if (url) rows.push({ url, sortOrder: i, role });
|
||||
});
|
||||
};
|
||||
|
||||
single(data.indicator_id, 'FEATURED');
|
||||
single(data.mobile_pic, 'FEATURED_MOBILE');
|
||||
gallery(data.images, 'GALLERY');
|
||||
gallery(data.images_mobile, 'GALLERY_MOBILE');
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Legacy `POST product/create`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const title = String(data.title ?? '').trim();
|
||||
if (!title) throw new BadRequestException('عنوان محصول الزامی است');
|
||||
|
||||
const cats = Array.isArray(data.cats) ? (data.cats as string[]) : [];
|
||||
const cinyIds = Array.isArray(data.ciny_ids) ? (data.ciny_ids as string[]) : [];
|
||||
const variants = Array.isArray(data.variants)
|
||||
? (data.variants as Record<string, unknown>[])
|
||||
: [];
|
||||
const images = this.imageCreateData(data);
|
||||
|
||||
const productCode = (data.product_code as string) || null;
|
||||
|
||||
// Images are created with the product so their ids exist before variants
|
||||
// are attached — a variant's `image_id` has to reference a real row.
|
||||
const created = await this.prisma.product.create({
|
||||
data: {
|
||||
title,
|
||||
description: (data.body_html as string) ?? (data.description as string) ?? null,
|
||||
productCode,
|
||||
showType: data.show_type !== undefined ? Number(data.show_type) : 3,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
brandId: (data.brand_id as string) || null,
|
||||
categoryId: cats[0] ?? null,
|
||||
collections: cinyIds.length
|
||||
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
|
||||
: undefined,
|
||||
images: images.length ? { create: images } : undefined,
|
||||
},
|
||||
include: { images: true },
|
||||
});
|
||||
|
||||
// On create the form works from freshly uploaded URLs and has no image ids
|
||||
// to post yet, so in practice `image_id` is absent here — but a client that
|
||||
// does send one is still validated against this product's own images.
|
||||
if (variants.length) {
|
||||
const allowed = new Set(created.images.map((i) => i.id));
|
||||
const takenSkus = new Set<string>();
|
||||
for (const v of variants) {
|
||||
const payload = this.variantCreateData(v, allowed);
|
||||
await this.prisma.productVariant.create({
|
||||
data: {
|
||||
...payload,
|
||||
productId: created.id,
|
||||
sku: await this.generateSku(productCode, created.id, takenSkus),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// D27 — create the matching کارساخته in the accounting software (queued;
|
||||
// only where the software's API can create items).
|
||||
await this.accounting.productCreated(this.prisma, created.id);
|
||||
|
||||
return this.findOne(created.id);
|
||||
}
|
||||
|
||||
/** Legacy `PUT product/update/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
// D27 — title and code of a product linked to the accounting software are
|
||||
// that software's; the form shows them locked. Re-posting the same values
|
||||
// is fine, changing them is not.
|
||||
const link = await this.accounting.productLink(id);
|
||||
if (link) {
|
||||
const titleChanged = data.title !== undefined && String(data.title).trim() !== existing.title;
|
||||
const codeChanged =
|
||||
data.product_code !== undefined && ((data.product_code as string) || null) !== existing.productCode;
|
||||
if (titleChanged || codeChanged) {
|
||||
throw new BadRequestException(
|
||||
'عنوان و کد این محصول از نرمافزار حسابداری میآید؛ برای تغییر، آن را در حسابداری ویرایش کنید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Prisma.ProductUpdateInput = {};
|
||||
if (data.title !== undefined) patch.title = String(data.title);
|
||||
if (data.body_html !== undefined) patch.description = String(data.body_html);
|
||||
if (data.product_code !== undefined) patch.productCode = (data.product_code as string) || null;
|
||||
if (data.show_type !== undefined) patch.showType = Number(data.show_type);
|
||||
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
|
||||
if (data.brand_id !== undefined) {
|
||||
patch.brand = data.brand_id ? { connect: { id: String(data.brand_id) } } : { disconnect: true };
|
||||
}
|
||||
if (Array.isArray(data.cats)) {
|
||||
const catId = (data.cats as string[])[0];
|
||||
patch.category = catId ? { connect: { id: catId } } : { disconnect: true };
|
||||
}
|
||||
|
||||
await this.prisma.product.update({ where: { id }, data: patch });
|
||||
|
||||
// Images are reconciled BEFORE variants, because a variant's `image_id`
|
||||
// must resolve against the image rows this submit leaves behind.
|
||||
await this.syncImages(id, data);
|
||||
|
||||
// Variants are edited inline in the product form, so the posted array is the
|
||||
// desired end state: update the ones that still carry an id, create the new
|
||||
// ones, and deactivate (never delete) the ones that disappeared — deleting
|
||||
// would break the OrderItem snapshots that reference them.
|
||||
if (Array.isArray(data.variants)) {
|
||||
const posted = data.variants as Record<string, unknown>[];
|
||||
// Collect ids as we go: a NEWLY created variant must count as kept, or the
|
||||
// sweep below would deactivate the row we just added.
|
||||
const keptIds: string[] = [];
|
||||
|
||||
const currentImages = await this.prisma.productImage.findMany({
|
||||
where: { productId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
const allowed = new Set(currentImages.map((i) => i.id));
|
||||
const takenSkus = new Set<string>();
|
||||
const productCode =
|
||||
data.product_code !== undefined
|
||||
? (data.product_code as string) || null
|
||||
: existing.productCode;
|
||||
|
||||
for (const v of posted) {
|
||||
const payload = this.variantCreateData(v, allowed);
|
||||
if (v.id) {
|
||||
await this.prisma.productVariant.update({
|
||||
where: { id: String(v.id) },
|
||||
data: payload,
|
||||
});
|
||||
keptIds.push(String(v.id));
|
||||
} else {
|
||||
const created = await this.prisma.productVariant.create({
|
||||
data: {
|
||||
...payload,
|
||||
productId: id,
|
||||
sku: await this.generateSku(productCode, id, takenSkus),
|
||||
},
|
||||
});
|
||||
keptIds.push(created.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Anything no longer posted is deactivated, never deleted — OrderItem
|
||||
// snapshots reference these rows.
|
||||
await this.prisma.productVariant.updateMany({
|
||||
where: { productId: id, id: { notIn: keptIds.length ? keptIds : ['__none__'] } },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the 4 posted image slots to a product WITHOUT churning row ids.
|
||||
*
|
||||
* This used to be a delete-everything-and-recreate, which was fine while
|
||||
* nothing referenced an image. ProductVariant.imageId now does, so a rewrite
|
||||
* would silently blank every variant's picture on each save (the FK is
|
||||
* ON DELETE SET NULL). Instead each posted (url, role) pair is matched
|
||||
* against the rows already stored: survivors keep their id and only have
|
||||
* their sortOrder refreshed, genuinely new URLs are inserted, and rows the
|
||||
* form dropped are deleted.
|
||||
*
|
||||
* The form always posts all 4 image fields together (even as empty/absent),
|
||||
* so any one of them being present means this is a real submit rather than a
|
||||
* partial PATCH that happens to omit the others.
|
||||
*/
|
||||
private async syncImages(productId: string, data: Record<string, unknown>) {
|
||||
if (!['indicator_id', 'mobile_pic', 'images', 'images_mobile'].some((k) => k in data)) return;
|
||||
|
||||
const desired = this.imageCreateData(data);
|
||||
const current = await this.prisma.productImage.findMany({ where: { productId } });
|
||||
|
||||
// (url, role) identifies an image slot. Duplicates of the same pair are
|
||||
// consumed one at a time so a gallery listing the same URL twice keeps two
|
||||
// distinct rows instead of collapsing them.
|
||||
const pools = new Map<string, typeof current>();
|
||||
for (const row of current) {
|
||||
const key = `${row.role}\u0000${row.url}`;
|
||||
const pool = pools.get(key);
|
||||
if (pool) pool.push(row);
|
||||
else pools.set(key, [row]);
|
||||
}
|
||||
|
||||
const survivingIds = new Set<string>();
|
||||
|
||||
for (const img of desired) {
|
||||
const key = `${img.role}\u0000${img.url}`;
|
||||
const match = pools.get(key)?.shift();
|
||||
if (match) {
|
||||
survivingIds.add(match.id);
|
||||
if (match.sortOrder !== img.sortOrder) {
|
||||
await this.prisma.productImage.update({
|
||||
where: { id: match.id },
|
||||
data: { sortOrder: img.sortOrder },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.productImage.create({ data: { ...img, productId } });
|
||||
}
|
||||
}
|
||||
|
||||
const removedIds = current.filter((row) => !survivingIds.has(row.id)).map((row) => row.id);
|
||||
if (removedIds.length) {
|
||||
// Any variant pointing at a removed image is cleared first. The FK would
|
||||
// do this anyway, but doing it explicitly keeps the behaviour identical
|
||||
// on databases where the constraint is not enforced the same way.
|
||||
await this.prisma.productVariant.updateMany({
|
||||
where: { imageId: { in: removedIds } },
|
||||
data: { imageId: null },
|
||||
});
|
||||
await this.prisma.productImage.deleteMany({ where: { id: { in: removedIds } } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST delete/product/{id}` — a DELETE expressed as POST.
|
||||
*
|
||||
* Refuses when the product has ever been ordered: its variants are referenced
|
||||
* by OrderItem snapshots, and removing them would break order history.
|
||||
* Legacy had no such guard. Deactivation is offered instead.
|
||||
*/
|
||||
async remove(id: string) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { variants: { include: { orderItems: { take: 1 } } } },
|
||||
});
|
||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
const hasOrders = product.variants.some((v) => v.orderItems.length > 0);
|
||||
if (hasOrders) {
|
||||
throw new BadRequestException(
|
||||
'حذف این محصول ممکن نیست، چون در سفارشهای ثبتشده استفاده شده است. میتوانید آن را غیرفعال کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.collectionProduct.deleteMany({ where: { productId: id } }),
|
||||
this.prisma.comment.deleteMany({ where: { productId: id } }),
|
||||
// Variants reference images (ProductVariant.imageId), so they go first —
|
||||
// the FK is ON DELETE SET NULL, but deleting in dependency order keeps
|
||||
// this correct regardless of how the constraint is enforced.
|
||||
this.prisma.productVariant.deleteMany({ where: { productId: id } }),
|
||||
this.prisma.productImage.deleteMany({ where: { productId: id } }),
|
||||
this.prisma.product.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST change/percent/count` — how many variants a bulk wage-percent
|
||||
* change would affect, used by ChangePercent.vue before applying.
|
||||
*/
|
||||
async changePercentCount(filter: { brand_id?: string; category_id?: string }) {
|
||||
const where: Prisma.ProductVariantWhereInput = {
|
||||
product: {
|
||||
...(filter.brand_id ? { brandId: filter.brand_id } : {}),
|
||||
...(filter.category_id ? { categoryId: filter.category_id } : {}),
|
||||
},
|
||||
};
|
||||
return { count: await this.prisma.productVariant.count({ where }) };
|
||||
}
|
||||
|
||||
/** Legacy `POST change/all` — apply a bulk wage-percent change. */
|
||||
async changePercent(data: { percent: number; brand_id?: string; category_id?: string }) {
|
||||
if (data.percent === undefined || Number.isNaN(Number(data.percent))) {
|
||||
throw new BadRequestException('درصد نامعتبر است');
|
||||
}
|
||||
const where: Prisma.ProductVariantWhereInput = {
|
||||
product: {
|
||||
...(data.brand_id ? { brandId: data.brand_id } : {}),
|
||||
...(data.category_id ? { categoryId: data.category_id } : {}),
|
||||
},
|
||||
};
|
||||
const result = await this.prisma.productVariant.updateMany({
|
||||
where,
|
||||
data: { defaultWagePercent: new Prisma.Decimal(String(data.percent)) },
|
||||
});
|
||||
return { updated: result.count };
|
||||
}
|
||||
|
||||
/** Legacy `GET brand` and `POST category` — pickers for the product form. */
|
||||
async brands() {
|
||||
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
|
||||
}
|
||||
|
||||
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
|
||||
async brandsTable(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title'],
|
||||
allowed: ['id', 'title'],
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.brand.findMany({
|
||||
where: where as Prisma.BrandWhereInput,
|
||||
orderBy: orderBy as Prisma.BrandOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
this.prisma.brand.count({ where: where as Prisma.BrandWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows, total, query, page, limit);
|
||||
}
|
||||
|
||||
|
||||
async collections() {
|
||||
return this.prisma.collection.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { title: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
showTypeOptions() {
|
||||
return Object.entries(SHOW_TYPE_LABELS).map(([value, title]) => ({
|
||||
value: Number(value),
|
||||
title,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminPromotionsService } from './admin-promotions.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints:
|
||||
* GET/POST/PUT/DELETE product/variant/promotion -> PromotionController
|
||||
* GET /autopromotion -> the auto-discount screen
|
||||
* GET/POST/DELETE /ignore -> views/offersIgnore
|
||||
*/
|
||||
@Controller('admin/promotions')
|
||||
export class AdminPromotionsController {
|
||||
constructor(private readonly promotions: AdminPromotionsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.promotions.list(query);
|
||||
}
|
||||
|
||||
@Get('scope-options')
|
||||
@RequirePermission('offers.list')
|
||||
scopeOptions() {
|
||||
return this.promotions.scopeOptions();
|
||||
}
|
||||
|
||||
@Get('usage-options')
|
||||
@RequirePermission('offers.list')
|
||||
usageOptions() {
|
||||
return this.promotions.usageOptions();
|
||||
}
|
||||
|
||||
@Get('auto')
|
||||
@RequirePermission('offers.autopromotion')
|
||||
getAutoPromotion() {
|
||||
return this.promotions.getAutoPromotion();
|
||||
}
|
||||
|
||||
@Post('auto')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.autopromotion')
|
||||
setAutoPromotion(@Body() body: { state?: boolean; discount_percent?: number | string }) {
|
||||
return this.promotions.setAutoPromotion(body);
|
||||
}
|
||||
|
||||
// ── exclusions ────────────────────────────────────────────────────
|
||||
@Get('exclusions')
|
||||
@RequirePermission('offersIgnore')
|
||||
listExclusions() {
|
||||
return this.promotions.listExclusions();
|
||||
}
|
||||
|
||||
@Post('exclusions')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offersIgnore')
|
||||
createExclusion(@Body() body: { type: number; item_id?: string[] }) {
|
||||
return this.promotions.createExclusion(body);
|
||||
}
|
||||
|
||||
@Delete('exclusions/:id')
|
||||
@RequirePermission('offersIgnore')
|
||||
removeExclusion(@Param('id') id: string) {
|
||||
return this.promotions.removeExclusion(id);
|
||||
}
|
||||
|
||||
// ── promotions ────────────────────────────────────────────────────
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.promotions.create(data);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('offers.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.promotions.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('offers.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.promotions.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('offers.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.promotions.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminPromotionsController } from './admin-promotions.controller';
|
||||
import { AdminPromotionsService } from './admin-promotions.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminPromotionsController],
|
||||
providers: [AdminPromotionsService],
|
||||
})
|
||||
export class AdminPromotionsModule {}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, PromotionScope, PromotionUsage } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Promotions — legacy views/promotion/*, served by PromotionController
|
||||
* (`GET/POST/PUT/DELETE product/variant/promotion`) plus the exclusion list
|
||||
* (`/ignore`) behind views/offersIgnore.
|
||||
*
|
||||
* Legacy `type` (scope): 1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند
|
||||
* Legacy `use_only` (usage): 1 فقط یکبار | 2 تا تاریخ پایان
|
||||
*/
|
||||
const SCOPE_BY_CODE: Record<number, PromotionScope> = {
|
||||
1: PromotionScope.PRODUCT,
|
||||
2: PromotionScope.CATEGORY,
|
||||
3: PromotionScope.CUSTOMER,
|
||||
4: PromotionScope.BRAND,
|
||||
};
|
||||
|
||||
const CODE_BY_SCOPE: Record<PromotionScope, number> = {
|
||||
PRODUCT: 1,
|
||||
CATEGORY: 2,
|
||||
CUSTOMER: 3,
|
||||
BRAND: 4,
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<PromotionScope, string> = {
|
||||
PRODUCT: 'محصولات',
|
||||
CATEGORY: 'دسته بندی',
|
||||
CUSTOMER: 'مشتری',
|
||||
BRAND: 'برند',
|
||||
};
|
||||
|
||||
const USAGE_BY_CODE: Record<number, PromotionUsage> = {
|
||||
1: PromotionUsage.ONCE,
|
||||
2: PromotionUsage.UNTIL_END_DATE,
|
||||
};
|
||||
|
||||
const CODE_BY_USAGE: Record<PromotionUsage, number> = {
|
||||
ONCE: 1,
|
||||
UNTIL_END_DATE: 2,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminPromotionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'title',
|
||||
'scope',
|
||||
'usage',
|
||||
'discountPercent',
|
||||
'isActive',
|
||||
'platform',
|
||||
'startsAt',
|
||||
'endsAt',
|
||||
'createdAt',
|
||||
// legacy filter keys
|
||||
'promotion_type_str',
|
||||
'status',
|
||||
'start',
|
||||
'end',
|
||||
'discount_percent',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
promotion_type_str: 'scope',
|
||||
type: 'scope',
|
||||
use_only: 'usage',
|
||||
status: 'isActive',
|
||||
start: 'startsAt',
|
||||
end: 'endsAt',
|
||||
discount_percent: 'discountPercent',
|
||||
caj: 'createdAt',
|
||||
};
|
||||
|
||||
/** Legacy `GET product/variant/promotion` — the promotions grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminPromotionsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'scope' && mapped.value !== undefined) {
|
||||
mapped.value = SCOPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
if (mapped.field === 'isActive' && mapped.value !== undefined) {
|
||||
mapped.value = String(mapped.value) === '1';
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['title', 'description'],
|
||||
allowed: AdminPromotionsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.promotion.findMany({
|
||||
where: where as Prisma.PromotionWhereInput,
|
||||
orderBy: orderBy as Prisma.PromotionOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { product: true, category: true, customer: true, brand: true },
|
||||
}),
|
||||
this.prisma.promotion.count({ where: where as Prisma.PromotionWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
p: Prisma.PromotionGetPayload<{
|
||||
include: { product: true; category: true; customer: true; brand: true };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
type: CODE_BY_SCOPE[p.scope],
|
||||
promotion_type_str: SCOPE_LABELS[p.scope],
|
||||
use_only: CODE_BY_USAGE[p.usage],
|
||||
discount_percent: p.discountPercent,
|
||||
// legacy grid renders `status` through a slot
|
||||
status: p.isActive ? 1 : 0,
|
||||
is_active: p.isActive,
|
||||
/**
|
||||
* Legacy `type_customer`: 1 = تک (retail), 2 = عمده (wholesale). The grid
|
||||
* uses it BOTH to pick which permission gates the row's edit/delete icons
|
||||
* AND to choose which edit route opens. A customer-scoped promotion is the
|
||||
* retail ("tak") kind; everything else is the wholesale kind.
|
||||
*/
|
||||
type_customer: p.scope === PromotionScope.CUSTOMER ? 1 : 2,
|
||||
platform: p.platform,
|
||||
start: p.startsAt,
|
||||
end: p.endsAt,
|
||||
product_id: p.productId,
|
||||
category_id: p.categoryId,
|
||||
customer_id: p.customerId,
|
||||
brand_id: p.brandId,
|
||||
product: p.product ? { id: p.product.id, title: p.product.title } : null,
|
||||
category: p.category ? { id: p.category.id, title: p.category.title } : null,
|
||||
customer: p.customer ? { id: p.customer.id, name: p.customer.fullName } : null,
|
||||
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
|
||||
caj: p.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const p = await this.prisma.promotion.findUnique({
|
||||
where: { id },
|
||||
include: { product: true, category: true, customer: true, brand: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('تخفیف یافت نشد');
|
||||
return this.toRow(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the legacy form payload onto the model, attaching the ONE target that
|
||||
* matches the chosen scope and clearing the others — so a promotion can never
|
||||
* carry, say, both a product and a customer.
|
||||
*/
|
||||
private buildData(data: Record<string, unknown>) {
|
||||
const scope = SCOPE_BY_CODE[Number(data.type ?? 1)];
|
||||
if (!scope) throw new BadRequestException('نوع تخفیف نامعتبر است');
|
||||
|
||||
const usage = USAGE_BY_CODE[Number(data.use_only ?? 1)] ?? PromotionUsage.ONCE;
|
||||
const percent = new Prisma.Decimal(String(data.discount_percent ?? 0));
|
||||
if (percent.lt(0) || percent.gt(100)) {
|
||||
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
|
||||
}
|
||||
|
||||
const startsAt = data.start ? new Date(String(data.start)) : null;
|
||||
const endsAt = data.end ? new Date(String(data.end)) : null;
|
||||
if (startsAt && endsAt && endsAt < startsAt) {
|
||||
throw new BadRequestException('تاریخ پایان نمیتواند قبل از تاریخ شروع باشد');
|
||||
}
|
||||
|
||||
return {
|
||||
title: String(data.title ?? '').trim(),
|
||||
description: (data.description as string) || null,
|
||||
scope,
|
||||
usage,
|
||||
discountPercent: percent,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
platform: data.platform !== undefined ? Number(data.platform) : 3,
|
||||
startsAt,
|
||||
endsAt,
|
||||
productId: scope === PromotionScope.PRODUCT ? ((data.product_id as string) ?? null) : null,
|
||||
categoryId: scope === PromotionScope.CATEGORY ? ((data.category_id as string) ?? null) : null,
|
||||
customerId: scope === PromotionScope.CUSTOMER ? ((data.customer_id as string) ?? null) : null,
|
||||
brandId: scope === PromotionScope.BRAND ? ((data.brand_id as string) ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST product/variant/promotion`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const payload = this.buildData(data);
|
||||
if (!payload.title) throw new BadRequestException('عنوان تخفیف الزامی است');
|
||||
return this.prisma.promotion.create({ data: payload });
|
||||
}
|
||||
|
||||
/** Legacy `PUT product/variant/promotion/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.promotion.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('تخفیف یافت نشد');
|
||||
// legacy disabled the type selector on edit (`:disabled="this.id"`), so the
|
||||
// scope is not re-read here either.
|
||||
const payload = this.buildData({ ...data, type: CODE_BY_SCOPE[existing.scope] });
|
||||
return this.prisma.promotion.update({ where: { id }, data: payload });
|
||||
}
|
||||
|
||||
/** Legacy `DELETE product/variant/promotion/{id}`. */
|
||||
async remove(id: string) {
|
||||
const p = await this.prisma.promotion.findUnique({ where: { id } });
|
||||
if (!p) throw new NotFoundException('تخفیف یافت نشد');
|
||||
await this.prisma.promotion.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
scopeOptions() {
|
||||
return Object.entries(SCOPE_BY_CODE).map(([code, scope]) => ({
|
||||
value: Number(code),
|
||||
title: SCOPE_LABELS[scope],
|
||||
}));
|
||||
}
|
||||
|
||||
usageOptions() {
|
||||
return [
|
||||
{ value: 1, title: ' فقط یکبار' },
|
||||
{ value: 2, title: 'تا تاریخ پایان' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET /autopromotion` and its POST counterpart — the settings screen
|
||||
* for "تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید": a one-shot welcome
|
||||
* discount for new customers, configured as an on/off state plus a percentage.
|
||||
*
|
||||
* Stored in the Setting key/value table, like the other config screens.
|
||||
*/
|
||||
async getAutoPromotion() {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'promotion.auto' } });
|
||||
return row?.value ?? { state: false, discount_percent: null };
|
||||
}
|
||||
|
||||
async setAutoPromotion(data: { state?: boolean; discount_percent?: number | string }) {
|
||||
const percent = Number(data.discount_percent ?? 0);
|
||||
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
|
||||
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
|
||||
}
|
||||
const value = { state: Boolean(data.state), discount_percent: percent };
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key: 'promotion.auto' },
|
||||
update: { value },
|
||||
create: { key: 'promotion.auto', value },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
// ── Exclusions (views/offersIgnore) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Legacy `GET /ignore` — the full exclusion list.
|
||||
*
|
||||
* The view reads it as a flat array and filters client-side:
|
||||
* items_id(type) { return this.ignoreList.filter(i => i.type == type).map(x => x.item_id) }
|
||||
* so the flat shape with `item_id` is preserved.
|
||||
*/
|
||||
async listExclusions() {
|
||||
const rows = await this.prisma.promotionExclusion.findMany({
|
||||
orderBy: [{ type: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((e) => ({ id: e.id, type: e.type, item_id: e.itemId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST /ignore` with `{ type, item_id: [...] }`.
|
||||
*
|
||||
* The posted array is the COMPLETE selection for that type — the view binds it
|
||||
* to a checkbox table — so this replaces that type's rows rather than adding
|
||||
* to them. Anything unchecked is therefore removed.
|
||||
*/
|
||||
async createExclusion(data: { type: number; item_id?: string[] }) {
|
||||
const type = Number(data.type);
|
||||
if (!type) throw new BadRequestException('نوع استثناء الزامی است');
|
||||
|
||||
const itemIds = Array.isArray(data.item_id)
|
||||
? data.item_id.map((x) => String(x)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.promotionExclusion.deleteMany({ where: { type } }),
|
||||
this.prisma.promotionExclusion.createMany({
|
||||
data: itemIds.map((itemId) => ({ type, itemId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.listExclusions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE /ignore/{id}` — the view's "حذف نوع استثناء" card removes a
|
||||
* whole TYPE, so a numeric id is treated as a type and a cuid as one row.
|
||||
*/
|
||||
async removeExclusion(id: string) {
|
||||
if (/^\d+$/.test(id)) {
|
||||
const type = Number(id);
|
||||
const { count } = await this.prisma.promotionExclusion.deleteMany({ where: { type } });
|
||||
if (count === 0) throw new NotFoundException('استثنایی برای این نوع یافت نشد');
|
||||
return { success: true, removed: count };
|
||||
}
|
||||
|
||||
const e = await this.prisma.promotionExclusion.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('استثناء یافت نشد');
|
||||
await this.prisma.promotionExclusion.delete({ where: { id } });
|
||||
return { success: true, removed: 1 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AdminReportService } from './admin-report.service';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy `GET report/get-order-item-with-details` — a combined order-item +
|
||||
* financial-document Excel export with 32 Persian-header columns, date-filtered.
|
||||
*/
|
||||
@Controller('admin/report')
|
||||
export class AdminReportController {
|
||||
constructor(private readonly report: AdminReportService) {}
|
||||
|
||||
@Get('order-items')
|
||||
@RequirePermission('report.order.item')
|
||||
getOrderItemReport(
|
||||
@Query('start_date') startDate?: string,
|
||||
@Query('end_date') endDate?: string,
|
||||
) {
|
||||
return this.report.getOrderItemReport(startDate, endDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminReportController } from './admin-report.controller';
|
||||
import { AdminReportService } from './admin-report.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminReportController],
|
||||
providers: [AdminReportService],
|
||||
})
|
||||
export class AdminReportModule {}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { BadRequestException, Injectable, StreamableFile } from '@nestjs/common';
|
||||
import { LedgerSourceType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { toJalali, formatJalali } from '../accounting-sync/jalali';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
const TYPE_LABELS: Partial<Record<LedgerSourceType, string>> = {
|
||||
REPAYMENT_MELTED_GOLD: 'آب شده',
|
||||
REPAYMENT_TRANSFER: 'حواله',
|
||||
REPAYMENT_CHEQUE: 'چک',
|
||||
REPAYMENT_CASH: 'پولی',
|
||||
};
|
||||
|
||||
const REPAYMENT_TYPES: LedgerSourceType[] = [
|
||||
LedgerSourceType.REPAYMENT_MELTED_GOLD,
|
||||
LedgerSourceType.REPAYMENT_TRANSFER,
|
||||
LedgerSourceType.REPAYMENT_CHEQUE,
|
||||
LedgerSourceType.REPAYMENT_CASH,
|
||||
];
|
||||
|
||||
const HEADERS = [
|
||||
'کد آیتم',
|
||||
'نام محصول',
|
||||
'کد محصول',
|
||||
'عنوان تنوع',
|
||||
'وزن خام تحویل مشتری',
|
||||
'وزن نهایی با درصد',
|
||||
'تعداد',
|
||||
'درصد',
|
||||
'اجرت ریالی',
|
||||
'توضیحات',
|
||||
'شماره سفارش',
|
||||
'تاریخ ثبت',
|
||||
'تاریخ',
|
||||
'سال',
|
||||
'ماه',
|
||||
'روز',
|
||||
'ساعت',
|
||||
'شماره سند',
|
||||
'نوع سند',
|
||||
'میزان پول',
|
||||
'میزان گرم',
|
||||
'وضعیت سند',
|
||||
'نام مشتری',
|
||||
'نام خانوادگی مشتری',
|
||||
'نام و نام خانوادگی مشتری',
|
||||
'کارشناس فروش',
|
||||
'شماره موبایل مشتری',
|
||||
'آدرس',
|
||||
'تلفن مغازه',
|
||||
'بدهکار',
|
||||
'بستانکار',
|
||||
'بدهی ریالی',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AdminReportService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOrderItemReport(startDate?: string, endDate?: string): Promise<StreamableFile> {
|
||||
const dateFilter: Prisma.DateTimeFilter = {};
|
||||
if (startDate) dateFilter.gte = parseDate(startDate, 'از تاریخ');
|
||||
if (endDate) {
|
||||
// The whole end day, as the on-screen report (admin-dashboard dateWhere).
|
||||
const end = parseDate(endDate, 'تا تاریخ');
|
||||
end.setHours(23, 59, 59, 999);
|
||||
dateFilter.lte = end;
|
||||
}
|
||||
const hasDateFilter = startDate || endDate;
|
||||
|
||||
const orderItems = await this.prisma.orderItem.findMany({
|
||||
where: hasDateFilter ? { order: { createdAt: dateFilter } } : undefined,
|
||||
orderBy: { id: 'desc' },
|
||||
include: {
|
||||
order: {
|
||||
include: {
|
||||
customer: true,
|
||||
salesAgent: true,
|
||||
},
|
||||
},
|
||||
productVariant: { include: { product: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const ledgerEntries = await this.prisma.ledgerEntry.findMany({
|
||||
where: {
|
||||
sourceType: { in: REPAYMENT_TYPES },
|
||||
...(hasDateFilter ? { createdAt: dateFilter } : {}),
|
||||
},
|
||||
include: { customer: true },
|
||||
});
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('گزارش', {
|
||||
views: [{ rightToLeft: true }],
|
||||
});
|
||||
|
||||
ws.columns = HEADERS.map((h) => ({ header: h, width: 18 }));
|
||||
|
||||
for (const item of orderItems) {
|
||||
const order = item.order;
|
||||
const customer = order.customer;
|
||||
const jalali = toJalali(order.createdAt);
|
||||
const jalaliStr = formatJalali(jalali, '/');
|
||||
const hour = new Date(order.createdAt.getTime() + 3.5 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(11, 16);
|
||||
|
||||
// computedWeightGrams is ONE unit's weight (OrdersService.createOrder
|
||||
// multiplies it by quantity for the order total), as legacy's order_items.w
|
||||
// is. Legacy's export divided it by qty again, so every multi-unit line
|
||||
// under-reported its weight; not repeated here.
|
||||
const perUnitWeight = Number(item.computedWeightGrams);
|
||||
// Legacy's «درصد» is the final percent: percent_pay − discount.
|
||||
const finalPercent = Number(item.wagePercentSnapshot.sub(item.discountPercentSnapshot));
|
||||
|
||||
for (let i = 0; i < item.quantity; i++) {
|
||||
ws.addRow([
|
||||
item.id,
|
||||
item.productVariant?.product?.title ?? '',
|
||||
item.productVariant?.product?.productCode ?? '',
|
||||
item.productVariant?.title ?? '',
|
||||
Number(item.rashWeightGramsSnapshot),
|
||||
round4(perUnitWeight),
|
||||
1,
|
||||
finalPercent,
|
||||
Number(item.flatRialCharge),
|
||||
'',
|
||||
order.orderNumber,
|
||||
jalaliStr,
|
||||
jalaliStr,
|
||||
jalali.jy,
|
||||
jalali.jm,
|
||||
jalali.jd,
|
||||
hour,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
customer.firstName ?? '',
|
||||
customer.lastName ?? '',
|
||||
customer.fullName,
|
||||
order.salesAgent?.fullName ?? '',
|
||||
customer.mobile,
|
||||
customer.address ?? '',
|
||||
customer.phone ?? '',
|
||||
round4(perUnitWeight),
|
||||
0,
|
||||
Number(item.flatRialCharge),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of ledgerEntries) {
|
||||
const customer = entry.customer;
|
||||
const jalali = toJalali(entry.createdAt);
|
||||
const jalaliStr = formatJalali(jalali, '/');
|
||||
const hour = new Date(entry.createdAt.getTime() + 3.5 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(11, 16);
|
||||
|
||||
const isCashOrCheque =
|
||||
entry.sourceType === LedgerSourceType.REPAYMENT_CASH ||
|
||||
entry.sourceType === LedgerSourceType.REPAYMENT_CHEQUE;
|
||||
const rialAmount = isCashOrCheque ? Number(entry.priceRial ?? 0) : 0;
|
||||
const gramAmount = !isCashOrCheque ? Number(entry.amount) : 0;
|
||||
|
||||
ws.addRow([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
entry.orderId ?? '',
|
||||
jalaliStr,
|
||||
jalaliStr,
|
||||
jalali.jy,
|
||||
jalali.jm,
|
||||
jalali.jd,
|
||||
hour,
|
||||
entry.id,
|
||||
TYPE_LABELS[entry.sourceType] ?? '',
|
||||
rialAmount,
|
||||
gramAmount,
|
||||
entry.confirmed ? 'تایید شده' : 'تایید نشده',
|
||||
customer.firstName ?? '',
|
||||
customer.lastName ?? '',
|
||||
customer.fullName,
|
||||
null,
|
||||
customer.mobile,
|
||||
customer.address ?? '',
|
||||
customer.phone ?? '',
|
||||
0,
|
||||
gramAmount,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
return new StreamableFile(Buffer.from(buffer), {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="report.xlsx"',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseDate(v: string, field: string): Date {
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) throw new BadRequestException(`${field}: تاریخ معتبر نیست`);
|
||||
return d;
|
||||
}
|
||||
|
||||
function round4(n: number): number {
|
||||
return Math.round(n * 10000) / 10000;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TableService } from './table.service';
|
||||
|
||||
/**
|
||||
* Global so every admin grid module can inject TableService without re-importing.
|
||||
* It is stateless — pure request-shape translation.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [TableService],
|
||||
exports: [TableService],
|
||||
})
|
||||
export class AdminTableModule {}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* One row of the XTable filter bar.
|
||||
*
|
||||
* Mirrors the object legacy's XFilterRow pushed into `query.filters`:
|
||||
* `{ id, field, op, value, value2? }`. `id` is a client-side react key
|
||||
* (a random float) and is accepted but ignored.
|
||||
*/
|
||||
export class TableFilterDto {
|
||||
@IsOptional()
|
||||
id?: number | string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
field?: string;
|
||||
|
||||
/**
|
||||
* Legacy operators, kept verbatim. `between` uses value + value2 and is what
|
||||
* the date-range filter emits.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'between', 'null', 'notnull'])
|
||||
op?: string;
|
||||
|
||||
@IsOptional()
|
||||
value?: unknown;
|
||||
|
||||
@IsOptional()
|
||||
value2?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request body XTable POSTs, ported from its requestData():
|
||||
*
|
||||
* { ...query, select, titles, report_title, op? }
|
||||
*
|
||||
* where query = { search, filters, total, limit, page }.
|
||||
*/
|
||||
export class TableQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TableFilterDto)
|
||||
filters?: TableFilterDto[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
limit?: number;
|
||||
|
||||
/** Sort spec: '+field' ascending, '-field' descending (legacy `order`). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
order?: string;
|
||||
|
||||
/** Columns currently visible; drives the export payload. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
select?: string[];
|
||||
|
||||
/** key -> Farsi column heading, used for export headers. */
|
||||
@IsOptional()
|
||||
titles?: Record<string, string>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
report_title?: string;
|
||||
|
||||
/**
|
||||
* Export mode. Legacy expected `{ url }` back and did `window.open(data.url)`.
|
||||
* Exports are not implemented yet; the controller rejects these explicitly
|
||||
* rather than returning a broken url.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(['excel', 'json', 'print'])
|
||||
op?: 'excel' | 'json' | 'print';
|
||||
|
||||
/** Echoed back inside `query`; the client owns this value. */
|
||||
@IsOptional()
|
||||
total?: number;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { TableFilterDto, TableQueryDto } from './table-query.dto';
|
||||
|
||||
/**
|
||||
* Translates the legacy XTable request contract into Prisma query arguments.
|
||||
*
|
||||
* The response shape is dictated by legacy's getDataUsage() (x-table mixin):
|
||||
*
|
||||
* laravel mode -> { rows, query } <- what the admin panel used
|
||||
* other mode -> { rows: [[...]], count, page }
|
||||
*
|
||||
* The panel's XTbl models all ran in 'laravel' mode, so that is the shape
|
||||
* produced here: `{ rows, query }` with query carrying total/page/limit.
|
||||
*/
|
||||
|
||||
export interface TableResult<T> {
|
||||
rows: T[];
|
||||
query: {
|
||||
search?: string;
|
||||
filters: TableFilterDto[];
|
||||
total: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildOptions {
|
||||
/** Columns the free-text `search` box scans (legacy initSearch([...])). */
|
||||
searchable?: string[];
|
||||
/** Whitelist of filterable/sortable fields; anything else is rejected. */
|
||||
allowed?: string[];
|
||||
/** Sort applied when the request specifies none. */
|
||||
defaultOrderBy?: Record<string, 'asc' | 'desc'>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TableService {
|
||||
/**
|
||||
* Turns a dotted path into a nested Prisma object:
|
||||
* 'customer.fullName', 'x' -> { customer: { fullName: 'x' } }
|
||||
*/
|
||||
private nest(path: string, leaf: unknown): Record<string, unknown> {
|
||||
const parts = path.split('.');
|
||||
return parts.reduceRight<unknown>((acc, key, i) => {
|
||||
return i === parts.length - 1 ? { [key]: leaf } : { [key]: acc };
|
||||
}, leaf) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private assertAllowed(field: string, allowed?: string[]) {
|
||||
if (allowed && !allowed.includes(field)) {
|
||||
throw new BadRequestException(`Field not filterable: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps one legacy filter row to a Prisma condition. */
|
||||
private toCondition(f: TableFilterDto, allowed?: string[]): Record<string, unknown> | null {
|
||||
if (!f.field || !f.op) return null;
|
||||
this.assertAllowed(f.field, allowed);
|
||||
|
||||
const v = f.value;
|
||||
switch (f.op) {
|
||||
case '=':
|
||||
return this.nest(f.field, { equals: v });
|
||||
case '!=':
|
||||
return this.nest(f.field, { not: v });
|
||||
case '>':
|
||||
return this.nest(f.field, { gt: v });
|
||||
case '>=':
|
||||
return this.nest(f.field, { gte: v });
|
||||
case '<':
|
||||
return this.nest(f.field, { lt: v });
|
||||
case '<=':
|
||||
return this.nest(f.field, { lte: v });
|
||||
case 'like':
|
||||
// Legacy sent '%value%' to SQL LIKE; `contains` is the Prisma equivalent.
|
||||
return this.nest(f.field, {
|
||||
contains: String(v ?? '').replace(/^%|%$/g, ''),
|
||||
mode: 'insensitive',
|
||||
});
|
||||
case 'in':
|
||||
return this.nest(f.field, { in: Array.isArray(v) ? v : [v] });
|
||||
case 'between':
|
||||
return this.nest(f.field, { gte: v, lte: f.value2 });
|
||||
case 'null':
|
||||
return this.nest(f.field, { equals: null });
|
||||
case 'notnull':
|
||||
return this.nest(f.field, { not: null });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds `where`, `orderBy`, `skip` and `take` from an XTable request. */
|
||||
build(query: TableQueryDto, opts: BuildOptions = {}) {
|
||||
const page = query.page && query.page > 0 ? query.page : 1;
|
||||
const limit = query.limit && query.limit > 0 ? query.limit : 10;
|
||||
|
||||
const and: Record<string, unknown>[] = [];
|
||||
|
||||
for (const f of query.filters ?? []) {
|
||||
const cond = this.toCondition(f, opts.allowed);
|
||||
if (cond) and.push(cond);
|
||||
}
|
||||
|
||||
// Free-text search across the declared columns (legacy: OR over initSearch).
|
||||
const search = query.search?.trim();
|
||||
if (search && opts.searchable?.length) {
|
||||
and.push({
|
||||
OR: opts.searchable.map((field) =>
|
||||
this.nest(field, { contains: search, mode: 'insensitive' }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// legacy `order`: '+field' asc, '-field' desc
|
||||
let orderBy = opts.defaultOrderBy ?? { createdAt: 'desc' as const };
|
||||
if (query.order) {
|
||||
const dir = query.order.startsWith('-') ? 'desc' : 'asc';
|
||||
const field = query.order.replace(/^[+-]/, '');
|
||||
if (field) {
|
||||
this.assertAllowed(field, opts.allowed);
|
||||
orderBy = this.nest(field, dir) as Record<string, 'asc' | 'desc'>;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
where: and.length ? { AND: and } : {},
|
||||
orderBy,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps rows + count into the exact envelope XTable expects. */
|
||||
respond<T>(rows: T[], total: number, query: TableQueryDto, page: number, limit: number): TableResult<T> {
|
||||
return {
|
||||
rows,
|
||||
query: {
|
||||
search: query.search,
|
||||
filters: query.filters ?? [],
|
||||
total,
|
||||
limit,
|
||||
page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Exports are not implemented; fail loudly instead of returning a dead url. */
|
||||
assertNotExport(query: TableQueryDto) {
|
||||
if (query.op) {
|
||||
throw new BadRequestException(
|
||||
`Export ('${query.op}') is not implemented yet in the new backend.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { AdminUploadsService } from './admin-uploads.service';
|
||||
import { CurrentAdmin } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { Body } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* The subset of multer's file object this controller uses. Declared locally
|
||||
* rather than pulling in @types/multer for one interface — multer itself ships
|
||||
* with @nestjs/platform-express, but its types do not.
|
||||
*/
|
||||
interface UploadedMulterFile {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy endpoints:
|
||||
* POST file/create -> storeWithoutData
|
||||
* PUT file/update/{id} -> updateBody (metadata only; not ported — nothing
|
||||
* in henza_admin calls it)
|
||||
*
|
||||
* The multipart field name `file` matches what every legacy uploader posts
|
||||
* (MyUpload.vue, SingleUploadInput.vue, UploadInput.vue, PodcastUploadInput.vue).
|
||||
*
|
||||
* PERMISSIONS. Legacy gated this on nothing at all: `file/create` sat outside
|
||||
* any middleware, and henza_server's `auth:api` group is itself commented out
|
||||
* (routes/admin.php:12), so the endpoint accepted anonymous uploads. That is a
|
||||
* security bug, not a feature, so it is NOT reproduced — these routes require a
|
||||
* signed-in admin. No specific rid is demanded, since legacy's permission
|
||||
* vocabulary has no media/file entry to port and inventing one would let it
|
||||
* drift from the 144 real rids.
|
||||
*/
|
||||
@Controller('admin/files')
|
||||
export class AdminUploadsController {
|
||||
constructor(private readonly uploads: AdminUploadsService) {}
|
||||
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
create(
|
||||
@UploadedFile() file: UploadedMulterFile | undefined,
|
||||
@Body('purpose') purpose: string | undefined,
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.uploads.store(file, purpose, admin.id ?? null);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.uploads.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminUploadsController } from './admin-uploads.controller';
|
||||
import { PublicUploadsController } from './public-uploads.controller';
|
||||
import { AdminUploadsService } from './admin-uploads.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminUploadsController, PublicUploadsController],
|
||||
providers: [AdminUploadsService],
|
||||
exports: [AdminUploadsService],
|
||||
})
|
||||
export class AdminUploadsModule {}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { getUploadDir } from './upload-path';
|
||||
|
||||
/**
|
||||
* File uploads — ports legacy `POST file/create`
|
||||
* (henza_server Adm/FileController::storeWithoutData).
|
||||
*
|
||||
* Legacy behaviour, reproduced here:
|
||||
* - the multipart field is named `file`
|
||||
* - the stored name is md5(time + original name) + the original extension
|
||||
* - files land in one flat public directory (legacy: public/storage)
|
||||
* - the JSON response carries an `id`, which the ant-design Upload wrappers
|
||||
* (SingleUploadInput.vue:87, MyUpload.vue) require before emitting `ok`
|
||||
*
|
||||
* Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking
|
||||
* and thumbnailing. That code is dead in legacy too — every branch of it is
|
||||
* inside a `/* ... */` block, leaving only the plain `$file->move(...)` live.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminUploadsService {
|
||||
/** Matches legacy's flat public/storage directory. */
|
||||
private readonly dir = getUploadDir();
|
||||
|
||||
/** Mirrors legacy's `type` field: 1 = image, 2 = any other document. */
|
||||
private static readonly IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
|
||||
private static readonly ALLOWED = /^(image\/(jpeg|png|gif|webp|svg\+xml)|application\/pdf)$/;
|
||||
|
||||
/** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */
|
||||
private static readonly MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async store(
|
||||
file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined,
|
||||
purpose: string | undefined,
|
||||
actorAdminUserId: string | null,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('فایلی ارسال نشده است');
|
||||
if (file.size > AdminUploadsService.MAX_BYTES) {
|
||||
throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)');
|
||||
}
|
||||
if (!AdminUploadsService.ALLOWED.test(file.mimetype)) {
|
||||
throw new BadRequestException('نوع فایل مجاز نیست');
|
||||
}
|
||||
|
||||
// legacy: md5(time() . original_name) . '.' . ext
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
const stored =
|
||||
createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') +
|
||||
ext;
|
||||
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await writeFile(join(this.dir, stored), file.buffer);
|
||||
|
||||
return this.prisma.uploadedFile.create({
|
||||
data: {
|
||||
url: `/uploads/${stored}`,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
sizeBytes: file.size,
|
||||
purpose: purpose ?? null,
|
||||
uploadedByAdminUserId: actorAdminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const f = await this.prisma.uploadedFile.findUnique({ where: { id } });
|
||||
if (!f) throw new NotFoundException('فایل یافت نشد');
|
||||
return f;
|
||||
}
|
||||
|
||||
/** True when the stored file is an image, for callers that care. */
|
||||
static isImage(mime: string) {
|
||||
return AdminUploadsService.IMAGE_MIME.test(mime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Header, NotFoundException, Param, StreamableFile } from '@nestjs/common';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { Public } from '../auth/guards';
|
||||
import { mimeForUpload, resolveStoredUpload } from './upload-path';
|
||||
|
||||
/**
|
||||
* Public file serving at GET /api/uploads/:filename.
|
||||
*
|
||||
* ProductImage.url is stored as `/uploads/<file>`, which nginx proxies. Shoppers
|
||||
* and operators also hit `/api/uploads/<file>` (user-app axios base is `/api`,
|
||||
* and the API Dokploy domain sits behind the same prefix). useStaticAssets only
|
||||
* mounts `/uploads/`, so those requests 404'd with Nest's "Cannot GET /api/uploads/...".
|
||||
*/
|
||||
@Public()
|
||||
@Controller('uploads')
|
||||
export class PublicUploadsController {
|
||||
@Get(':filename')
|
||||
@Header('Cache-Control', 'public, max-age=86400')
|
||||
stream(@Param('filename') filename: string): StreamableFile {
|
||||
const file = resolveStoredUpload(filename);
|
||||
if (!file) throw new NotFoundException('فایل یافت نشد');
|
||||
|
||||
return new StreamableFile(createReadStream(file), {
|
||||
type: mimeForUpload(file) ?? 'application/octet-stream',
|
||||
disposition: 'inline',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { envOr } from '../../common/env';
|
||||
|
||||
/**
|
||||
* Directory files are written to and served from.
|
||||
*
|
||||
* Default matches docker-compose's `api_uploads:/app/storage/uploads` volume.
|
||||
* A nested leftover (`storage/uploads/uploads/`) is also checked when serving,
|
||||
* because copying a folder named `uploads` into the volume creates that extra
|
||||
* directory and the previous static root then 404'd every file.
|
||||
*/
|
||||
export function getUploadDir(): string {
|
||||
return resolve(envOr('UPLOAD_DIR', join(process.cwd(), 'storage', 'uploads')));
|
||||
}
|
||||
|
||||
const UPLOAD_MIME: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
export function mimeForUpload(filePath: string): string | undefined {
|
||||
const dot = filePath.lastIndexOf('.');
|
||||
const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
|
||||
return UPLOAD_MIME[ext];
|
||||
}
|
||||
|
||||
/** Resolve a public filename to an on-disk file, or null if it is missing/unsafe. */
|
||||
export function resolveStoredUpload(rawName: string): string | null {
|
||||
const safe = basename(rawName);
|
||||
if (!safe || safe === '.' || safe === '..') return null;
|
||||
|
||||
const dir = getUploadDir();
|
||||
const candidates = [join(dir, safe), join(dir, 'uploads', safe)];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const resolved = resolve(candidate);
|
||||
const rel = relative(dir, resolved);
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
|
||||
if (existsSync(resolved) && statSync(resolved).isFile()) return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (UserController + RoleController):
|
||||
* GET/POST/PUT/DELETE users
|
||||
* GET const/__list__ut -> access-level picker
|
||||
* POST const -> create an access level
|
||||
* GET roles/{ut} -> rids of one level
|
||||
* POST roles/create -> set rids
|
||||
* DELETE roles/{ut}/{rid} -> revoke one rid
|
||||
*/
|
||||
@Controller('admin/users')
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly users: AdminUsersService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.users.list(query);
|
||||
}
|
||||
|
||||
@Get('role-options')
|
||||
@RequirePermission('user.list')
|
||||
roleOptions() {
|
||||
return this.users.roleOptions();
|
||||
}
|
||||
|
||||
/** Legacy GET const/__list__ut. */
|
||||
@Get('types')
|
||||
@RequirePermission('user.list')
|
||||
listUserTypes() {
|
||||
return this.users.listUserTypes();
|
||||
}
|
||||
|
||||
/** Legacy POST const {key:"__list__ut", val:"<title>"}. */
|
||||
@Post('types')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.permission.create')
|
||||
createUserType(@Body() body: { val?: string; title?: string }) {
|
||||
return this.users.createUserType(body.val ?? body.title ?? '');
|
||||
}
|
||||
|
||||
/** Legacy GET roles/{ut}. */
|
||||
@Get('types/:ut/roles')
|
||||
@RequirePermission('user.permission')
|
||||
getRoles(@Param('ut') ut: string) {
|
||||
return this.users.getRoles(Number(ut));
|
||||
}
|
||||
|
||||
/** Legacy POST roles/create {ut, roles}. */
|
||||
@Post('roles')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.permission.edit')
|
||||
setRoles(@Body() body: { ut: number | string; roles: string[] }) {
|
||||
return this.users.setRoles(Number(body.ut), body.roles ?? []);
|
||||
}
|
||||
|
||||
/** Legacy DELETE roles/{ut}/{rid}. */
|
||||
@Delete('types/:ut/roles/:rid')
|
||||
@RequirePermission('user.permission.edit')
|
||||
revokeRole(@Param('ut') ut: string, @Param('rid') rid: string) {
|
||||
return this.users.revokeRole(Number(ut), rid);
|
||||
}
|
||||
|
||||
/** The "اطلاعات من" screen. */
|
||||
@Get('me')
|
||||
myData(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.users.myData(admin.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.users.create(data);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('user.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.users.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('user.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.users.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('user.list.remove')
|
||||
remove(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.users.remove(id, admin.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminUsersController],
|
||||
providers: [AdminUsersService],
|
||||
})
|
||||
export class AdminUsersModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
/** SEC-07: a role's saved permissions are limited to rids the server knows. */
|
||||
describe('AdminUsersService.setRoles', () => {
|
||||
let createMany: jest.Mock;
|
||||
let service: AdminUsersService;
|
||||
|
||||
beforeEach(() => {
|
||||
createMany = jest.fn();
|
||||
const prisma = {
|
||||
userType: { findUnique: jest.fn(async () => ({ id: 90, permissions: [] })) },
|
||||
permission: { deleteMany: jest.fn(), createMany },
|
||||
$transaction: jest.fn(async () => []),
|
||||
};
|
||||
service = new AdminUsersService(prisma as never, {} as never, {} as never);
|
||||
});
|
||||
|
||||
it('keeps catalogue rids and their tree parents, drops anything else', async () => {
|
||||
await service.setRoles(90, [
|
||||
'order.list',
|
||||
'order', // tree parent of order.list
|
||||
'persons', // tree parent of persons.*
|
||||
'notify.create.single.customer', // legacy tree leaf no route checks
|
||||
'made.up.permission',
|
||||
'order.list', // duplicate
|
||||
'',
|
||||
]);
|
||||
const rows = createMany.mock.calls[0][0].data.map((d: { rid: string }) => d.rid);
|
||||
expect(rows.sort()).toEqual(['order', 'order.list', 'persons']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { AdminRole, Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { ALL_RIDS } from '../auth/acl.constants';
|
||||
import { SUPER_ADMIN_MOBILE, isSuperAdmin } from '../auth/super-admin';
|
||||
|
||||
/**
|
||||
* SEC-07: the rids a role may hold — every rid in the catalogue and each of
|
||||
* their tree parents ("persons", "order.omde", …), which the permission tree
|
||||
* posts when a whole branch is ticked.
|
||||
*/
|
||||
const STORABLE_RIDS: ReadonlySet<string> = new Set(
|
||||
(ALL_RIDS as readonly string[]).flatMap((rid) =>
|
||||
rid.split('.').map((_, i, parts) => parts.slice(0, i + 1).join('.')),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Admin users and the permission matrix — legacy views/user/*, served by
|
||||
* UserController and RoleController:
|
||||
* POST/GET/PUT/DELETE users -> the admin user list and form
|
||||
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
|
||||
* POST const -> create a new user type
|
||||
* GET roles/{ut} -> the rids granted to a user type
|
||||
* POST roles/create -> grant rids
|
||||
* DELETE roles/{ut}/{rid} -> revoke one rid
|
||||
*
|
||||
* The permission matrix writes the `permission(rid, userTypeId)` rows that
|
||||
* Phase 1 modelled, so this screen is what actually administers the ACL.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
private readonly logger = new Logger(AdminUsersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'fullName',
|
||||
'mobile',
|
||||
'role',
|
||||
'isActive',
|
||||
'createdAt',
|
||||
'userTypeId',
|
||||
'userType.title',
|
||||
'branch.name',
|
||||
// legacy filter keys
|
||||
'name',
|
||||
'ut',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
name: 'fullName',
|
||||
ut: 'userTypeId',
|
||||
caj: 'createdAt',
|
||||
active: 'isActive',
|
||||
};
|
||||
|
||||
/** Legacy `GET users` — the admin user grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminUsersService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'isActive' && mapped.value !== undefined) {
|
||||
mapped.value = String(mapped.value) === '1';
|
||||
}
|
||||
if (mapped.field === 'userTypeId' && mapped.value !== undefined) {
|
||||
mapped.value = Number(mapped.value);
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['fullName', 'mobile'],
|
||||
allowed: AdminUsersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The support super admin is not listed: to everyone else it does not exist.
|
||||
const visible: Prisma.AdminUserWhereInput = {
|
||||
AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.adminUser.findMany({
|
||||
where: visible,
|
||||
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { userType: true, branch: true, avatarFile: true },
|
||||
}),
|
||||
this.prisma.adminUser.count({ where: visible }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names preserved; the password hash is never exposed. */
|
||||
private toRow(
|
||||
u: Prisma.AdminUserGetPayload<{
|
||||
include: { userType: true; branch: true; avatarFile: true };
|
||||
}> & {
|
||||
// D17 — present on the detail read; absent from the list query, hence optional.
|
||||
salesExpertProfile?: {
|
||||
cityId: string | null;
|
||||
gramLevel: Prisma.Decimal;
|
||||
phone: string | null;
|
||||
nationalCode: string | null;
|
||||
city?: { id: string; name: string } | null;
|
||||
} | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.fullName,
|
||||
// D0 requirement 7 — the real parts. The fallback only matters for a row
|
||||
// written before the 20260922180000 migration backfilled them.
|
||||
fname: u.firstName ?? u.fullName,
|
||||
lname: u.lastName ?? '',
|
||||
mobile: u.mobile,
|
||||
role: u.role,
|
||||
ut: u.userTypeId,
|
||||
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
|
||||
branch_id: u.branchId,
|
||||
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
|
||||
percent: u.wagePercent,
|
||||
active: u.isActive ? 1 : -1,
|
||||
is_active: u.isActive,
|
||||
caj: u.createdAt,
|
||||
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
|
||||
// null for every other admin. Same shapes admin-customers.service.ts used
|
||||
// when this data still lived on Customer, so the form binds unchanged.
|
||||
address: u.address,
|
||||
store_name: u.storeName,
|
||||
pasazh_name: u.pasazhName,
|
||||
mcode: u.memberCode,
|
||||
image_id: u.avatarFileId,
|
||||
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
|
||||
// D17 — from SalesExpertProfile. Null for a non-expert, and null-safe for
|
||||
// an expert whose profile row is somehow missing.
|
||||
city_id: u.salesExpertProfile?.cityId ?? null,
|
||||
city: u.salesExpertProfile?.city
|
||||
? { id: u.salesExpertProfile.city.id, title: u.salesExpertProfile.city.name }
|
||||
: null,
|
||||
gram_level: u.salesExpertProfile?.gramLevel ?? null,
|
||||
phone: u.salesExpertProfile?.phone ?? null,
|
||||
ncode: u.salesExpertProfile?.nationalCode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const u = await this.prisma.adminUser.findUnique({
|
||||
where: { id },
|
||||
// D17 — the edit form needs the sales-expert fields too.
|
||||
include: {
|
||||
userType: true,
|
||||
branch: true,
|
||||
avatarFile: true,
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
return this.toRow(u);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST users`.
|
||||
*
|
||||
* `role` is REQUIRED — it used to default to SALES_EXPERT whenever the
|
||||
* caller omitted it, which silently turned every admin created without an
|
||||
* explicit role into a sales agent regardless of the access level ("نوع
|
||||
* کاربری") actually chosen. UserCreate.vue now always sends one.
|
||||
*/
|
||||
async create(data: Record<string, unknown>) {
|
||||
// D0 requirement 7 — store the real name parts, as Customer does. `name`
|
||||
// is still accepted for callers that only send a whole name.
|
||||
const firstName = String(data.fname ?? data.name ?? '').trim();
|
||||
const lastName = String(data.lname ?? '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
// D19 gap 2 — normalise Persian digits, as the reference does with
|
||||
// Utility::faTOen (AdminController.php:333,364). Done BEFORE the
|
||||
// uniqueness lookup below: otherwise ۰۹۱۲… and 0912… are different strings
|
||||
// and the same number could be registered twice.
|
||||
const mobile = faToEn(String(data.mobile ?? '').trim());
|
||||
const password = String(data.password ?? '');
|
||||
const role = data.role as AdminRole | undefined;
|
||||
|
||||
if (!fullName) throw new BadRequestException('نام الزامی است');
|
||||
if (!mobile) throw new BadRequestException('موبایل الزامی است');
|
||||
if (!password) throw new BadRequestException('رمز عبور الزامی است');
|
||||
if (!role) throw new BadRequestException('نقش کاربر الزامی است');
|
||||
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
|
||||
return this.prisma.adminUser.create({
|
||||
data: {
|
||||
fullName,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
role,
|
||||
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
|
||||
branchId: (data.branch_id as string) || null,
|
||||
wagePercent:
|
||||
data.percent !== undefined && data.percent !== null
|
||||
? new Prisma.Decimal(String(data.percent))
|
||||
: null,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
// Sales-agent-only fields (ReagentCustomer.vue); undefined for every
|
||||
// other role, which Prisma leaves at column default (null).
|
||||
address: (data.address as string) || null,
|
||||
storeName: (data.store_name as string) || null,
|
||||
pasazhName: (data.pasazh_name as string) || null,
|
||||
memberCode: (data.mcode as string) || null,
|
||||
avatarFileId: (data.image_id as string) || null,
|
||||
// D17 — a sales expert always gets its profile row, so the grid's city
|
||||
// and credit-limit columns have somewhere to read from immediately.
|
||||
...(role === 'SALES_EXPERT'
|
||||
? { salesExpertProfile: { create: this.salesExpertProfileData(data) } }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
userType: true,
|
||||
branch: true,
|
||||
avatarFile: true,
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The SalesExpertProfile columns as posted by ReagentCustomer.vue (D17).
|
||||
*
|
||||
* `phone` and `nationalCode` are stored exactly as the reference does:
|
||||
* optional, not unique, no format rule — the reference validates only
|
||||
* `mobile` and `percent` on this form (AdminController.php:354).
|
||||
*/
|
||||
private salesExpertProfileData(data: Record<string, unknown>) {
|
||||
return {
|
||||
cityId: (data.city_id as string) || null,
|
||||
gramLevel:
|
||||
data.gram_level !== undefined && data.gram_level !== null && data.gram_level !== ''
|
||||
? new Prisma.Decimal(String(data.gram_level))
|
||||
: new Prisma.Decimal(0),
|
||||
phone: (data.phone as string) || null,
|
||||
nationalCode: (data.ncode as string) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `PUT users/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
|
||||
if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
const patch: Prisma.AdminUserUpdateInput = {};
|
||||
|
||||
// D0 requirement 7 — write the parts and let the
|
||||
// admin_user_sync_full_name trigger rebuild `fullName`. When a caller sends
|
||||
// both, the parts win (see the migration header).
|
||||
if (data.fname !== undefined || data.lname !== undefined) {
|
||||
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
|
||||
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
|
||||
patch.firstName = firstName || null;
|
||||
patch.lastName = lastName || null;
|
||||
} else if (data.name !== undefined) {
|
||||
// Whole-name-only callers: the trigger derives the parts.
|
||||
patch.fullName = String(data.name);
|
||||
}
|
||||
|
||||
if (data.mobile !== undefined) {
|
||||
// Normalised before the clash check, for the same reason as create().
|
||||
const mobile = faToEn(String(data.mobile));
|
||||
if (mobile !== existing.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
patch.mobile = mobile;
|
||||
}
|
||||
// A blank password field means "leave it unchanged" — legacy did the same.
|
||||
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||
if (data.role !== undefined) patch.role = data.role as AdminRole;
|
||||
if (data.ut !== undefined) {
|
||||
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||
}
|
||||
if (data.branch_id !== undefined) {
|
||||
patch.branch = data.branch_id
|
||||
? { connect: { id: String(data.branch_id) } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (data.percent !== undefined) {
|
||||
patch.wagePercent =
|
||||
data.percent === null ? null : new Prisma.Decimal(String(data.percent));
|
||||
}
|
||||
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
|
||||
if (data.address !== undefined) patch.address = (data.address as string) || null;
|
||||
if (data.store_name !== undefined) patch.storeName = (data.store_name as string) || null;
|
||||
if (data.pasazh_name !== undefined) patch.pasazhName = (data.pasazh_name as string) || null;
|
||||
if (data.mcode !== undefined) patch.memberCode = (data.mcode as string) || null;
|
||||
if (data.image_id !== undefined) {
|
||||
// legacy sent '' to mean "avatar removed"
|
||||
patch.avatarFile = data.image_id
|
||||
? { connect: { id: String(data.image_id) } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
// D17 — the profile follows the role.
|
||||
//
|
||||
// `upsert` covers all three cases in one statement: a user becoming a sales
|
||||
// expert gets a profile; an existing expert's profile is updated; and an
|
||||
// expert whose profile went missing gets one back rather than erroring.
|
||||
// A profile is never deleted when the role changes away — that would
|
||||
// silently discard the operator's city and credit limit on a mistaken role
|
||||
// switch, and it costs nothing to keep.
|
||||
const effectiveRole = (patch.role as AdminRole | undefined) ?? existing.role;
|
||||
const touchesProfile =
|
||||
data.city_id !== undefined ||
|
||||
data.gram_level !== undefined ||
|
||||
data.phone !== undefined ||
|
||||
data.ncode !== undefined;
|
||||
|
||||
if (effectiveRole === 'SALES_EXPERT' && (touchesProfile || patch.role !== undefined)) {
|
||||
const profile = this.salesExpertProfileData(data);
|
||||
patch.salesExpertProfile = {
|
||||
upsert: {
|
||||
create: profile,
|
||||
// Only the keys the caller actually sent, so an untouched field is
|
||||
// not overwritten with a default on a partial (diff-send) save.
|
||||
update: {
|
||||
...(data.city_id !== undefined ? { cityId: profile.cityId } : {}),
|
||||
...(data.gram_level !== undefined ? { gramLevel: profile.gramLevel } : {}),
|
||||
...(data.phone !== undefined ? { phone: profile.phone } : {}),
|
||||
...(data.ncode !== undefined ? { nationalCode: profile.nationalCode } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await this.prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: patch,
|
||||
include: {
|
||||
userType: true,
|
||||
branch: true,
|
||||
avatarFile: true,
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Disabling an account or changing its password used to leave every already
|
||||
// issued token working for up to 12h. With session tracking on, those tokens
|
||||
// end here; with it off, nothing happens and the behaviour is as before.
|
||||
if (patch.isActive === false || patch.passwordHash !== undefined) {
|
||||
await this.sessions.revokeAllForAdmin(
|
||||
id,
|
||||
patch.isActive === false ? 'account_disabled' : 'password_changed',
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE users/{id}`.
|
||||
*
|
||||
* Refuses to delete an admin who has posted ledger entries or changed order
|
||||
* statuses — those rows reference them, and losing the actor would erase the
|
||||
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
|
||||
*/
|
||||
async remove(id: string, actorId: string) {
|
||||
const u = await this.prisma.adminUser.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
|
||||
},
|
||||
});
|
||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
if (id === actorId) {
|
||||
throw new BadRequestException('نمیتوانید حساب کاربری خودتان را حذف کنید');
|
||||
}
|
||||
|
||||
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
|
||||
if (refs > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. میتوانید غیرفعالش کنید.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.adminUser.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── User types + the permission matrix ─────────────────────────────
|
||||
|
||||
/** Legacy `GET const/__list__ut` — the access-level picker. */
|
||||
async listUserTypes() {
|
||||
const rows = await this.prisma.userType.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
include: { _count: { select: { adminUsers: true, permissions: true } } },
|
||||
});
|
||||
// legacy returned {id, title} pairs; the counts are additive, not breaking.
|
||||
return rows.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
users_count: t._count.adminUsers,
|
||||
permissions_count: t._count.permissions,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST const` with `{key: "__list__ut", val: "<title>"}` — creates a
|
||||
* new access level. Legacy's `config_static` used an auto-increment id; here
|
||||
* the next free integer is taken explicitly since UserType.id is not generated.
|
||||
*/
|
||||
async createUserType(title: string) {
|
||||
if (!title?.trim()) throw new BadRequestException('نام سطح دسترسی الزامی است');
|
||||
|
||||
const last = await this.prisma.userType.findFirst({ orderBy: { id: 'desc' } });
|
||||
const nextId = (last?.id ?? 84) + 1; // 85 is legacy's first, per Permission.vue
|
||||
|
||||
return this.prisma.userType.create({
|
||||
data: { id: nextId, title: title.trim() },
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET roles/{ut}` — the rids granted to one access level. */
|
||||
async getRoles(userTypeId: number) {
|
||||
const type = await this.prisma.userType.findUnique({
|
||||
where: { id: userTypeId },
|
||||
include: { permissions: true },
|
||||
});
|
||||
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
|
||||
// legacy's tree binds to a flat array of rid strings
|
||||
return type.permissions.map((p) => p.rid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST roles/create` with `{ut, roles: [...]}`.
|
||||
*
|
||||
* The posted array is the complete tick-state of the permission tree, so it
|
||||
* REPLACES that type's rows — which is what legacy's live code does too: it
|
||||
* posts the whole `this.rid` array and nothing else.
|
||||
*
|
||||
* (Legacy also carries a COMMENTED-OUT block that diffed the old and new sets
|
||||
* and fired one DELETE per removed rid on a 500ms setTimeout. That path is
|
||||
* dead in legacy and is not revived here; the transaction below achieves the
|
||||
* same end atomically.)
|
||||
*/
|
||||
async setRoles(userTypeId: number, rids: string[]) {
|
||||
const type = await this.prisma.userType.findUnique({ where: { id: userTypeId } });
|
||||
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
|
||||
|
||||
const posted = [...new Set((rids ?? []).map((r) => String(r).trim()).filter(Boolean))];
|
||||
// SEC-07: only rids the server knows are stored. The rest grant nothing —
|
||||
// most are legacy tree leaves no route checks (e.g. notify.create.single.*)
|
||||
// — so they are dropped rather than failing the whole save.
|
||||
const clean = posted.filter((r) => STORABLE_RIDS.has(r));
|
||||
const dropped = posted.filter((r) => !STORABLE_RIDS.has(r));
|
||||
if (dropped.length) {
|
||||
this.logger.warn(`setRoles(${userTypeId}): ignored ${dropped.length} unknown rid(s): ${dropped.slice(0, 20).join(', ')}`);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.permission.deleteMany({ where: { userTypeId } }),
|
||||
this.prisma.permission.createMany({
|
||||
data: clean.map((rid) => ({ rid, userTypeId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.getRoles(userTypeId);
|
||||
}
|
||||
|
||||
/** Legacy `DELETE roles/{ut}/{rid}` — revoke a single rid. */
|
||||
async revokeRole(userTypeId: number, rid: string) {
|
||||
const existing = await this.prisma.permission.findUnique({
|
||||
where: { rid_userTypeId: { rid, userTypeId } },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('این دسترسی وجود ندارد');
|
||||
|
||||
await this.prisma.permission.delete({
|
||||
where: { rid_userTypeId: { rid, userTypeId } },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
|
||||
async myData(adminId: string) {
|
||||
return this.findOne(adminId);
|
||||
}
|
||||
|
||||
/** The AdminRole picker for the user form. */
|
||||
roleOptions() {
|
||||
return [
|
||||
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
|
||||
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
|
||||
{ value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' },
|
||||
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
|
||||
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Workshops — legacy views/workshop/*, served by WorkshopController:
|
||||
* POST workshop/table -> the grid (also the picker the PreOrder detail uses)
|
||||
* POST workshop -> create (workshop + its supervisor, together)
|
||||
* GET/PUT workshop/{id}
|
||||
* GET workshop/active/{id} -> toggle
|
||||
*
|
||||
* The list route predates the rest (Batch 3 added it for the made-to-order
|
||||
* screen's workshop picker); the CRUD arrived with Batch 11. Both live here so
|
||||
* there is one owner of /admin/workshops, and the logic sits in
|
||||
* AdminBranchesService alongside the branch code it mirrors.
|
||||
*
|
||||
* Managing workshops is super-admin-only; the list stays open because the
|
||||
* made-to-order screen picks a workshop from it.
|
||||
*/
|
||||
@Controller('admin/workshops')
|
||||
export class AdminWorkshopsController {
|
||||
constructor(private readonly branches: AdminBranchesService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.branches.listWorkshops(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('workshop.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.branches.createWorkshop(data);
|
||||
}
|
||||
|
||||
@Get('active/:id')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('workshop.list')
|
||||
toggleActive(@Param('id') id: string) {
|
||||
return this.branches.toggleWorkshopActive(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('workshop.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.branches.findWorkshop(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('workshop.list')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.branches.updateWorkshop(id, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminBranchesModule } from '../admin-branches/admin-branches.module';
|
||||
import { AdminWorkshopsController } from './admin-workshops.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AdminBranchesModule],
|
||||
controllers: [AdminWorkshopsController],
|
||||
})
|
||||
export class AdminWorkshopsModule {}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Every permission id (`rid`) the admin panel actually enforces.
|
||||
*
|
||||
* Regenerated mechanically from legacy (see scratchpad/convert_tree.js) as the
|
||||
* union of two enforcement points:
|
||||
* - 58 gated by a route's `meta.role` (router/admin.js, router/branch.js)
|
||||
* - 100 gated inside views via `$hasPermission('...')`
|
||||
* = 144 distinct rids (the two sets overlap)
|
||||
*
|
||||
* An earlier revision of this file listed only the 58 route-gated ones, which
|
||||
* left the seeded super-admin unable to use the in-view actions. Corrected.
|
||||
*
|
||||
* These are LEAVES as written. Ancestors ("order" from "order.list", "persons"
|
||||
* from "persons.list.client.omde", ...) are NOT stored — they are derived at
|
||||
* runtime by expandWithParents(), exactly as legacy's checkParent() did.
|
||||
*/
|
||||
export const ALL_RIDS = [
|
||||
'branch.create',
|
||||
'branch.list',
|
||||
'buy.order.list',
|
||||
'cities.create',
|
||||
'cities.list',
|
||||
'cities.list.remove',
|
||||
'cities.list.view',
|
||||
'cities.list.view.edit',
|
||||
'collection.create',
|
||||
'collection.list',
|
||||
'collection.list.edit',
|
||||
'collection.list.remove',
|
||||
'comment',
|
||||
'comment.remove',
|
||||
'comment.state',
|
||||
'comment.view',
|
||||
'comment.view.edit',
|
||||
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
|
||||
'config.accounting',
|
||||
'config.wage',
|
||||
'config.report',
|
||||
'config.statics',
|
||||
'config.statics.brand',
|
||||
'config.statics.brand.create',
|
||||
'config.statics.brand.edit',
|
||||
'config.statics.brand.remove',
|
||||
'config.statics.tags',
|
||||
'config.statics.tags.create',
|
||||
'config.statics.tags.edit',
|
||||
'config.statics.tags.remove',
|
||||
'config.wallet',
|
||||
'custom.config.statics.slider',
|
||||
'custom.config.statics.tags.banner.ids',
|
||||
'dashboard.view',
|
||||
'document.create',
|
||||
'document.list',
|
||||
'document.list.confirm',
|
||||
'document.list.deny',
|
||||
'document.list.edit',
|
||||
'document.list.get',
|
||||
'document.list.remove',
|
||||
'invoice.create',
|
||||
'invoice.list',
|
||||
'newsletters',
|
||||
'notify.create',
|
||||
'notify.me',
|
||||
'offers.autopromotion',
|
||||
'offers.create',
|
||||
'offers.create.tak',
|
||||
'offers.list',
|
||||
'offers.list.edit',
|
||||
'offers.list.remove',
|
||||
'offers.list.tak.edit',
|
||||
'offers.list.tak.remove',
|
||||
'offersIgnore',
|
||||
'order.create',
|
||||
'order.list',
|
||||
'order.list.pdf',
|
||||
'order.list.sts',
|
||||
'order.list.sts.remove',
|
||||
'order.list.sts.submit',
|
||||
'order.list.view',
|
||||
'order.list.view.edit',
|
||||
'order.makeOrder',
|
||||
'order.makeOrder.get',
|
||||
'order.makeOrder.remove',
|
||||
'order.makeOrder.state',
|
||||
'order.makeOrder.state.remove',
|
||||
'order.makeOrder.state.submit',
|
||||
'order.makeOrder.view',
|
||||
'order.makeOrder.view.edit',
|
||||
'order.omde',
|
||||
'order.omde.get',
|
||||
'order.omde.remove',
|
||||
'order.omde.state',
|
||||
'order.omde.state.remove',
|
||||
'order.omde.state.submit',
|
||||
'order.omde.view',
|
||||
'order.omde.view.edit',
|
||||
'order.omde.view.remove',
|
||||
'order.tamin',
|
||||
'order.tamin.state',
|
||||
'order.tamin.state.remove',
|
||||
'order.tamin.state.submit',
|
||||
'order.tamin.view',
|
||||
'order.tamin.view.edit',
|
||||
'persons.create',
|
||||
'persons.create.client',
|
||||
'persons.create.customer.tak',
|
||||
'persons.create.seller',
|
||||
'persons.list.client.omde',
|
||||
'persons.list.client.omde.able',
|
||||
'persons.list.client.omde.able.order',
|
||||
'persons.list.client.omde.edit',
|
||||
'persons.list.client.omde.unable',
|
||||
'persons.list.client.omde.unable.order',
|
||||
'persons.list.client.omde.view',
|
||||
'persons.list.client.omde.view.documents',
|
||||
'persons.list.client.omde.view.orders',
|
||||
'persons.list.client.single',
|
||||
'persons.list.client.single.edit',
|
||||
'persons.list.client.single.view',
|
||||
'persons.list.sellers',
|
||||
'persons.list.sellers.able',
|
||||
'persons.list.sellers.able.order',
|
||||
'persons.list.sellers.edit',
|
||||
'persons.list.sellers.unable',
|
||||
'persons.list.sellers.unable.order',
|
||||
'persons.list.sellers.view',
|
||||
'persons.list.sellers.view.documents',
|
||||
'persons.list.sellers.view.orders',
|
||||
'product.create',
|
||||
'product.list',
|
||||
'product.list.edit',
|
||||
'product.list.group.edit',
|
||||
'product.list.remove',
|
||||
'product.list.variants',
|
||||
'product.list.variants.add',
|
||||
'product.list.variants.edit',
|
||||
'product.list.variants.remove',
|
||||
'product.list.view',
|
||||
'product.percent',
|
||||
'report.order.item',
|
||||
'report.product',
|
||||
'sms.create',
|
||||
'sms.create.all',
|
||||
'sms.create.multiple',
|
||||
'sms.create.omde',
|
||||
'sms.create.reagent',
|
||||
'sms.create.single',
|
||||
'sms.list',
|
||||
'statics',
|
||||
'user.create',
|
||||
'user.list',
|
||||
'user.list.create',
|
||||
'user.list.edit',
|
||||
'user.list.remove',
|
||||
'user.permission',
|
||||
'user.permission.create',
|
||||
'user.permission.edit',
|
||||
'wallet',
|
||||
'wallet.charge',
|
||||
'wallet.list',
|
||||
'wallet.percent',
|
||||
'workshop.create',
|
||||
'workshop.list',
|
||||
] as const;
|
||||
|
||||
export type Rid = (typeof ALL_RIDS)[number];
|
||||
|
||||
/**
|
||||
* Port of henza_admin/src/store/users/index.js getParents() — returns every
|
||||
* ancestor of a dotted rid, longest first.
|
||||
*
|
||||
* getParents(['a','b','c']) -> ['a.b', 'a']
|
||||
*/
|
||||
function getParents(keys: string[]): string[] {
|
||||
const work = [...keys];
|
||||
const parents: string[] = [];
|
||||
while (work.length > 1) {
|
||||
work.pop();
|
||||
parents.push(work.join('.'));
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of henza_admin/src/store/users/index.js checkParent().
|
||||
*
|
||||
* Granting "persons.list.client.omde" implicitly grants "persons.list.client",
|
||||
* "persons.list" and "persons", so parent menu nodes stay visible. The legacy
|
||||
* version mutated a module-scoped `rid` variable; this one is pure, which changes
|
||||
* nothing observable but makes it safe to call concurrently on the server.
|
||||
*
|
||||
* Note this only walks UP. A rid never implies its children — which is why the
|
||||
* legacy nav item guarded by "wallet.list" stays hidden for a user granted only
|
||||
* "wallet". That mismatch is a legacy bug, recorded in docs/ADMIN-PORT-PLAN.md,
|
||||
* and is preserved here rather than silently repaired.
|
||||
*/
|
||||
export function expandWithParents(values: string[]): string[] {
|
||||
let rid: string[] = [...values];
|
||||
for (const x of values) {
|
||||
const keys = x.split('.');
|
||||
if (keys.length > 1) {
|
||||
rid = [...new Set([...rid, ...getParents(keys)])];
|
||||
}
|
||||
}
|
||||
return rid;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { CurrentAdmin, Public } from './guards';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (henza_server routes/admin.php):
|
||||
* POST admin-login -> AdminController::login
|
||||
* GET users/show -> UserController::showData
|
||||
*
|
||||
* Exposed here under the clean /api/admin/auth prefix; the admin panel's mapping
|
||||
* layer translates the legacy paths onto these.
|
||||
*/
|
||||
@Controller('admin/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
login(@Body() dto: LoginDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string) {
|
||||
// The IP is forwarded to the identity service (when delegation is on) so its
|
||||
// per-IP rate limit applies to the real admin, not to this API server; both
|
||||
// are also recorded on the session row when session tracking is on.
|
||||
return this.authService.login(dto, ip, userAgent);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/start')
|
||||
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
const started = this.authService.beginBrowserSso(redirectUri);
|
||||
if (!started) return { enabled: false, authorize_url: null };
|
||||
res.setHeader('Set-Cookie', started.cookie);
|
||||
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('sso/callback')
|
||||
@HttpCode(200)
|
||||
ssoCallback(
|
||||
@Body() body: { code?: string; state?: string },
|
||||
@Headers('cookie') cookieHeader: string | undefined,
|
||||
@Ip() ip: string,
|
||||
@Headers('user-agent') userAgent: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
res.setHeader('Set-Cookie', 'gb_admin_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
return this.authService.completeBrowserSso(
|
||||
readCookie(cookieHeader, 'gb_admin_iam_sso'),
|
||||
body.code ?? '',
|
||||
body.state ?? '',
|
||||
ip,
|
||||
userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/logout-url')
|
||||
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||
return { logout_url: this.authService.centralLogoutUrl(returnUrl) };
|
||||
}
|
||||
|
||||
/** Legacy `users/show` — the router guard's permission refresh. */
|
||||
@Get('me')
|
||||
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.authService.showData(admin.id);
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key === name) return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { envOr } from '../../common/env';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { JwtAuthGuard, PermissionsGuard } from './guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PassportModule,
|
||||
// registerAsync: read when the app is created, not when this file is
|
||||
// imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used.
|
||||
JwtModule.registerAsync({
|
||||
useFactory: () => ({
|
||||
// envOr, not `??`: docker-compose passes every optional variable as
|
||||
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
|
||||
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
|
||||
// admin login in Docker only. See DEPLOY-02.
|
||||
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||
// `string` from process.env does not satisfy; the cast is the documented escape.
|
||||
signOptions: {
|
||||
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
// Global: authenticate first, then check the rid. Routes opt out with @Public().
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* `@nestjs/jwt` v12 ships as ESM and this repo's jest does not transform
|
||||
* node_modules, so importing AuthService would fail to load. The real class is
|
||||
* never exercised here (the test injects its own stub), so replacing the module
|
||||
* keeps the suite runnable without touching the shared jest config.
|
||||
*/
|
||||
jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} }));
|
||||
|
||||
/**
|
||||
* The real bcryptjs, with `compare` wrapped so a test can assert it was never
|
||||
* reached. It calls straight through, so every other test still runs against
|
||||
* the real implementation. (A namespace import's properties cannot be
|
||||
* re-defined, so jest.spyOn is not an option here.)
|
||||
*/
|
||||
jest.mock('bcryptjs', () => {
|
||||
const actual = jest.requireActual('bcryptjs');
|
||||
return { ...actual, compare: jest.fn(actual.compare) };
|
||||
});
|
||||
|
||||
import { AuthService, compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './auth.service';
|
||||
import type { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import {
|
||||
IamInvalidCredentialsError,
|
||||
IamMfaRequiredError,
|
||||
IamService,
|
||||
IamUnavailableError,
|
||||
} from '../iam/iam.service';
|
||||
|
||||
/**
|
||||
* Admin login with the identity service in the loop.
|
||||
*
|
||||
* The contract being locked down: with the feature off, login behaves exactly
|
||||
* as before (one bcrypt compare, no network). With it on, an IAM problem must
|
||||
* never block an admin who still has a valid local password.
|
||||
*/
|
||||
describe('AuthService login', () => {
|
||||
const PASSWORD = 'Secret123!';
|
||||
let passwordHash: string;
|
||||
|
||||
const adminRow = () => ({
|
||||
id: 'admin-1',
|
||||
mobile: '09120000000',
|
||||
fullName: 'مدیر سامانه',
|
||||
role: 'admin',
|
||||
userTypeId: 'ut-1',
|
||||
branchId: 'br-1',
|
||||
isActive: true,
|
||||
passwordHash,
|
||||
userType: { permissions: [{ rid: 10 }, { rid: 20 }] },
|
||||
});
|
||||
|
||||
/** Session tracking off — the shape of a deployment that does not use it. */
|
||||
const untrackedSessions = (): Partial<AdminSessionService> => ({
|
||||
newTokenId: jest.fn().mockReturnValue(null),
|
||||
record: jest.fn(),
|
||||
});
|
||||
|
||||
const build = (
|
||||
iam: Partial<IamService>,
|
||||
user: unknown = adminRow(),
|
||||
sessions: Partial<AdminSessionService> = untrackedSessions(),
|
||||
) => {
|
||||
const prisma = {
|
||||
adminUser: { findUnique: jest.fn().mockResolvedValue(user) },
|
||||
};
|
||||
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
|
||||
|
||||
return {
|
||||
service: new AuthService(
|
||||
prisma as never,
|
||||
jwt as never,
|
||||
{ browserBootstrapUrl: () => null, ...iam } as IamService,
|
||||
sessions as AdminSessionService,
|
||||
),
|
||||
prisma,
|
||||
jwt,
|
||||
sessions,
|
||||
};
|
||||
};
|
||||
|
||||
const disabledIam = (): Partial<IamService> => ({
|
||||
enabled: false,
|
||||
allowsLocalFallback: true,
|
||||
verifyCredentials: jest.fn(),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
passwordHash = await bcrypt.hash(PASSWORD, 8);
|
||||
});
|
||||
|
||||
/* ─────────────── feature off: unchanged behaviour ─────────────── */
|
||||
|
||||
it('logs in with the local password and never calls the identity service', async () => {
|
||||
const iam = disabledIam();
|
||||
const { service, jwt } = build(iam);
|
||||
|
||||
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
expect(result.roles).toEqual([{ rid: 10 }, { rid: 20 }]);
|
||||
expect(jwt.signAsync).toHaveBeenCalledTimes(1);
|
||||
expect(iam.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still rejects a wrong local password', async () => {
|
||||
const { service } = build(disabledIam());
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: 'wrong' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('still rejects an inactive admin', async () => {
|
||||
const { service } = build(disabledIam(), { ...adminRow(), isActive: false });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('still rejects an unknown phone without leaking that it is unknown', async () => {
|
||||
const { service } = build(disabledIam(), null);
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09129999999', password: PASSWORD }),
|
||||
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
|
||||
});
|
||||
|
||||
/* ─────────────── feature on ─────────────── */
|
||||
|
||||
it('accepts a password the identity service approves', async () => {
|
||||
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
// Deliberately not the local password: the IAM is the authority here
|
||||
const result = await service.login({ phone: '09120000000', password: 'iam-side-password' });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'iam-side-password', undefined);
|
||||
});
|
||||
|
||||
it('forwards the end-user IP to the identity service', async () => {
|
||||
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5');
|
||||
|
||||
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'x', '203.0.113.5');
|
||||
});
|
||||
|
||||
/* ─────────────── the safety net ─────────────── */
|
||||
|
||||
it('falls back to the local password when the account is unknown there', async () => {
|
||||
const verifyCredentials = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
});
|
||||
|
||||
it('falls back to the local password when the identity service is down', async () => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
// The single most important guarantee: an IAM outage cannot lock admins out
|
||||
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
});
|
||||
|
||||
it('rejects a wrong password even when falling back', async () => {
|
||||
const verifyCredentials = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: 'wrong' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
/* ─────────────── exclusive mode ─────────────── */
|
||||
|
||||
it('refuses the local password in exclusive mode', async () => {
|
||||
const verifyCredentials = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('fails loudly in exclusive mode when the identity service is down', async () => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toThrow('سرویس هویت در دسترس نیست');
|
||||
});
|
||||
|
||||
/* ─────────────── revocable sessions ─────────────── */
|
||||
|
||||
it('signs the same token as before while session tracking is off', async () => {
|
||||
const { service, jwt, sessions } = build(disabledIam());
|
||||
|
||||
await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
// No jti, no row: the previous behaviour, byte for byte
|
||||
expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000' });
|
||||
expect(sessions.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records the issued token with the IAM session id when tracking is on', async () => {
|
||||
const tracked: Partial<AdminSessionService> = {
|
||||
newTokenId: jest.fn().mockReturnValue('jti-1'),
|
||||
record: jest.fn(),
|
||||
};
|
||||
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
|
||||
const { service, jwt } = build(
|
||||
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||||
adminRow(),
|
||||
tracked,
|
||||
);
|
||||
|
||||
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5', 'Firefox');
|
||||
|
||||
expect(jwt.signAsync).toHaveBeenCalledWith({
|
||||
sub: 'admin-1',
|
||||
mobile: '09120000000',
|
||||
jti: 'jti-1',
|
||||
});
|
||||
// Without the IAM session id, a back-channel logout could not find this session
|
||||
expect(tracked.record).toHaveBeenCalledWith({
|
||||
jti: 'jti-1',
|
||||
adminUserId: 'admin-1',
|
||||
iamSessionId: 'sid-1',
|
||||
iamSub: 'sub-1',
|
||||
ipAddress: '203.0.113.5',
|
||||
userAgent: 'Firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('still records a session when the login fell back to the local password', async () => {
|
||||
const tracked: Partial<AdminSessionService> = {
|
||||
newTokenId: jest.fn().mockReturnValue('jti-2'),
|
||||
record: jest.fn(),
|
||||
};
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build(
|
||||
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||||
adminRow(),
|
||||
tracked,
|
||||
);
|
||||
|
||||
await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
// No IAM identity to attach, but the session must still be revocable locally
|
||||
expect(tracked.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jti: 'jti-2', iamSessionId: null, iamSub: null }),
|
||||
);
|
||||
});
|
||||
|
||||
/* ─────────────── accounts with no usable password ─────────────── */
|
||||
|
||||
describe('accounts with no usable password', () => {
|
||||
const noPassword = ['', OTP_ONLY_PASSWORD_HASH];
|
||||
|
||||
it.each(noPassword)('refuses login for hash %j, whatever is typed', async (hash) => {
|
||||
const { service } = build(disabledIam(), { ...adminRow(), passwordHash: hash });
|
||||
|
||||
// Including the empty string and the sentinel itself: neither may open it
|
||||
for (const typed of ['', 'x', OTP_ONLY_PASSWORD_HASH]) {
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: typed }),
|
||||
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(noPassword)('refuses hash %j on the IAM fallback path too', async (hash) => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build(
|
||||
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||||
{ ...adminRow(), passwordHash: hash },
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: '' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('never hands a malformed hash to bcrypt', async () => {
|
||||
const compare = bcrypt.compare as unknown as jest.Mock;
|
||||
compare.mockClear();
|
||||
|
||||
expect(await compareLocalHash('x', OTP_ONLY_PASSWORD_HASH)).toBe(false);
|
||||
expect(await compareLocalHash('x', '')).toBe(false);
|
||||
expect(await compareLocalHash('x', null)).toBe(false);
|
||||
expect(compare).not.toHaveBeenCalled();
|
||||
|
||||
// …while a real hash still goes through it
|
||||
await compareLocalHash(PASSWORD, passwordHash);
|
||||
expect(compare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still accepts the legacy server's PHP $2y$ hashes", async () => {
|
||||
// PHP's $2y$ is the same algorithm as $2b$; the legacy passwords
|
||||
// (customers and the sales experts moving to AdminUser) use it.
|
||||
const legacy = '$2y$' + (await bcrypt.hash(PASSWORD, 4)).slice(4);
|
||||
|
||||
expect(await compareLocalHash(PASSWORD, legacy)).toBe(true);
|
||||
expect(await compareLocalHash('wrong', legacy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ─────────────── two-factor ─────────────── */
|
||||
|
||||
it('explains that a two-factor account cannot log in through this path', async () => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamMfaRequiredError('mfa'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toThrow('دو مرحلهای');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
IamInvalidCredentialsError,
|
||||
IamMfaRequiredError,
|
||||
IamService,
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import { compareLocalHash } from './password-hash';
|
||||
import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin';
|
||||
|
||||
export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash';
|
||||
|
||||
// TEMPORARY hardcoded super-admin login (username = password). Remove before going live.
|
||||
const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly iam: IamService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Port of legacy AdminController::login (henza_server).
|
||||
*
|
||||
* Legacy issued a random 60-char string as `api_token` and persisted it on the
|
||||
* user row; this issues a signed JWT instead. The response keeps the legacy
|
||||
* shape the admin panel's auth store reads:
|
||||
* - `api_token` -> stored in localStorage under token/branch_token
|
||||
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
|
||||
* through checkParent()
|
||||
*
|
||||
* Deliberate deviation from legacy: a bad phone and a bad password both return
|
||||
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
|
||||
* a wrong password, which let anyone enumerate valid admin phone numbers.
|
||||
*/
|
||||
async login(dto: LoginDto, clientIp?: string, userAgent?: string) {
|
||||
const isStaticAdmin =
|
||||
dto.phone?.trim() === STATIC_ADMIN_MOBILE && dto.password === STATIC_ADMIN_MOBILE;
|
||||
if (isStaticAdmin) {
|
||||
await this.ensureStaticAdmin();
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
const verified = isStaticAdmin
|
||||
? { ok: true as const, identity: undefined }
|
||||
: await this.verifyPassword(dto, user.passwordHash, clientIp);
|
||||
if (!verified.ok) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
// While session tracking is off this is null and the token is signed exactly
|
||||
// as before — same payload, same claims.
|
||||
const jti = this.sessions.newTokenId();
|
||||
const apiToken = await this.jwt.signAsync({
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
...(jti ? { jti } : {}),
|
||||
});
|
||||
|
||||
if (jti) {
|
||||
// Recorded so a central sign-out (or an admin revoking the session) can end
|
||||
// this token before it expires — see AdminSessionService.
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
iamSessionId: verified.identity?.sessionId ?? null,
|
||||
iamSub: verified.identity?.sub ?? null,
|
||||
ipAddress: clientIp ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
is_super_admin: isSuperAdmin(user.mobile),
|
||||
api_token: apiToken,
|
||||
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
|
||||
(rid) => ({ rid }),
|
||||
),
|
||||
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirectUri = pickExactUri(requestedRedirect, [
|
||||
envOrNull('IAM_ADMIN_REDIRECT_URI'),
|
||||
...envList('IAM_ADMIN_REDIRECT_URIS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirectUri || !clientId) return null;
|
||||
|
||||
const state = randomBytes(24).toString('base64url');
|
||||
const nonce = randomBytes(24).toString('base64url');
|
||||
const verifier = randomBytes(48).toString('base64url');
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
|
||||
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
const secure = redirectUri.startsWith('https:');
|
||||
const cookie =
|
||||
`gb_admin_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
|
||||
(secure ? '; Secure' : '');
|
||||
|
||||
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('nonce', nonce);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('prompt', 'none');
|
||||
return { authorizeUrl: url.toString(), cookie };
|
||||
}
|
||||
|
||||
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip?: string, userAgent?: string) {
|
||||
const pending = this.readSsoCookie(cookieValue);
|
||||
if (!pending || pending.state !== state) {
|
||||
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
|
||||
}
|
||||
|
||||
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
|
||||
if (!claims.phoneNumber) {
|
||||
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findFirst({
|
||||
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست');
|
||||
}
|
||||
|
||||
const identity: IamIdentity = {
|
||||
sub: claims.sub,
|
||||
email: null,
|
||||
phoneNumber: claims.phoneNumber,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
sessionId: claims.sessionId,
|
||||
browserTicket: null,
|
||||
};
|
||||
return this.issueAdminToken(user, identity, ip, userAgent);
|
||||
}
|
||||
|
||||
centralLogoutUrl(requestedReturn?: string): string | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirect = pickExactUri(requestedReturn, [
|
||||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'),
|
||||
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'),
|
||||
...envList('IAM_ADMIN_ALLOWED_RETURN_URLS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirect || !clientId) return null;
|
||||
const url = new URL(`${this.iam.issuer}/oauth/logout`);
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('post_logout_redirect_uri', redirect);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async issueAdminToken(
|
||||
user: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
userTypeId: number | null;
|
||||
branchId: string | null;
|
||||
userType: { permissions: { rid: string }[] } | null;
|
||||
},
|
||||
identity: IamIdentity | undefined,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const jti = this.sessions.newTokenId();
|
||||
const apiToken = await this.jwt.signAsync({
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
...(jti ? { jti } : {}),
|
||||
});
|
||||
if (jti) {
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
iamSessionId: identity?.sessionId ?? null,
|
||||
iamSub: identity?.sub ?? null,
|
||||
ipAddress: ip ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
is_super_admin: isSuperAdmin(user.mobile),
|
||||
api_token: apiToken,
|
||||
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
|
||||
(rid) => ({ rid }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureStaticAdmin() {
|
||||
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
|
||||
const userTypeId = fullAccess ? 85 : null;
|
||||
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
|
||||
await this.prisma.adminUser.upsert({
|
||||
where: { mobile: STATIC_ADMIN_MOBILE },
|
||||
update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
|
||||
create: {
|
||||
mobile: STATIC_ADMIN_MOBILE,
|
||||
fullName: 'مدیر موقت',
|
||||
passwordHash,
|
||||
role: AdminRole.SUPER_ADMIN,
|
||||
userTypeId,
|
||||
branchId: null,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private ssoSecret(): string {
|
||||
return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
|
||||
}
|
||||
|
||||
private readSsoCookie(value: string | null): { state: string; verifier: string; redirectUri: string } | null {
|
||||
if (!value || !value.includes('.')) return null;
|
||||
const split = value.lastIndexOf('.');
|
||||
const payload = value.slice(0, split);
|
||||
const sig = value.slice(split + 1);
|
||||
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
if (expected.length !== sig.length) return null;
|
||||
let same = 0;
|
||||
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
||||
if (same !== 0) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password check, optionally delegated to the central identity service.
|
||||
*
|
||||
* With `IAM_ENABLED=false` (the default) this is exactly the previous
|
||||
* behaviour: a single bcrypt compare. With it on, the identity service is
|
||||
* asked first and the local hash is only consulted when that check could not
|
||||
* give a definite answer — so an IAM outage never blocks admin login.
|
||||
*/
|
||||
private async verifyPassword(
|
||||
dto: LoginDto,
|
||||
passwordHash: string,
|
||||
clientIp?: string,
|
||||
): Promise<{ ok: boolean; identity?: IamIdentity }> {
|
||||
if (!this.iam.enabled) {
|
||||
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.iam.verifyCredentials(dto.phone, dto.password, clientIp);
|
||||
|
||||
// The IAM session id travels with the recorded session so a back-channel
|
||||
// logout can find and end exactly this session.
|
||||
return { ok: true, identity };
|
||||
} catch (error) {
|
||||
if (error instanceof IamMfaRequiredError) {
|
||||
// Nowhere to collect the code on this UI-less path; say so plainly
|
||||
// instead of silently falling back to a password the IAM may have retired.
|
||||
throw new UnauthorizedException(
|
||||
'برای این حساب احراز هویت دو مرحلهای فعال است و از این مسیر قابل تکمیل نیست',
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof IamInvalidCredentialsError && !this.iam.allowsLocalFallback) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!(error instanceof IamInvalidCredentialsError)) {
|
||||
this.logger.warn(
|
||||
`falling back to the local password check: ${(error as Error).message}`,
|
||||
);
|
||||
if (!this.iam.allowsLocalFallback) {
|
||||
throw new UnauthorizedException('ورود در حال حاضر ممکن نیست؛ سرویس هویت در دسترس نیست');
|
||||
}
|
||||
}
|
||||
|
||||
// fallback mode: the account may not have been migrated to the IAM yet
|
||||
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of legacy UserController::showData, which the router guard calls on every
|
||||
* cold load to refresh the permission list:
|
||||
*
|
||||
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
|
||||
*
|
||||
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
|
||||
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
||||
*/
|
||||
async showData(adminId: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: adminId },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
|
||||
is_super_admin: isSuperAdmin(user.mobile),
|
||||
permission: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mobileVariants(raw: string): string[] {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const variants = new Set<string>([raw.trim(), digits]);
|
||||
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
|
||||
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
|
||||
return [...variants].filter((value) => value !== '');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy contract in AdminController::login:
|
||||
* $this->validate($request, ['phone' => 'required', 'password' => 'required']);
|
||||
*
|
||||
* The field is named `phone` (not `mobile`) even though it is matched against
|
||||
* `users.mobile` — kept as-is so the ported login form posts an unchanged body.
|
||||
*/
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PermissionsGuard, RequirePermission, SuperAdminOnly } from './guards';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
import { SUPER_ADMIN_MOBILE, withSuperAdminRids } from './super-admin';
|
||||
|
||||
class Routes {
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('config.wallet')
|
||||
superOnly() {}
|
||||
|
||||
@SuperAdminOnly({ allowBranch: true })
|
||||
@RequirePermission('order.list')
|
||||
superOrBranch() {}
|
||||
|
||||
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||
anyOf() {}
|
||||
|
||||
open() {}
|
||||
}
|
||||
|
||||
function admin(over: Partial<AuthenticatedAdmin> = {}): AuthenticatedAdmin {
|
||||
return {
|
||||
id: 'a1',
|
||||
fullName: 'x',
|
||||
mobile: '09120000000',
|
||||
role: 'SUPER_ADMIN',
|
||||
userTypeId: 85,
|
||||
branchId: null,
|
||||
permissions: ['config.wallet', 'order.list', 'order.omde'],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function run(handler: keyof Routes, user: AuthenticatedAdmin) {
|
||||
const ctx = {
|
||||
getHandler: () => Routes.prototype[handler],
|
||||
getClass: () => Routes,
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
return new PermissionsGuard(new Reflector()).canActivate(ctx);
|
||||
}
|
||||
|
||||
describe('PermissionsGuard — super-admin-only routes', () => {
|
||||
it('admits the super admin', () => {
|
||||
expect(run('superOnly', admin({ mobile: SUPER_ADMIN_MOBILE }))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses any other admin, even one holding the rid and the SUPER_ADMIN role', () => {
|
||||
expect(() => run('superOnly', admin())).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('refuses a branch admin unless the route allows branches', () => {
|
||||
expect(() => run('superOnly', admin({ branchId: 'b1' }))).toThrow(ForbiddenException);
|
||||
expect(run('superOrBranch', admin({ branchId: 'b1' }))).toBe(true);
|
||||
expect(() => run('superOrBranch', admin())).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('still requires the rid from a branch admin', () => {
|
||||
expect(() => run('superOrBranch', admin({ branchId: 'b1', permissions: [] }))).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves routes without the decorator alone', () => {
|
||||
expect(run('open', admin({ permissions: [] }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionsGuard — several rids', () => {
|
||||
it('admits a holder of any one of them', () => {
|
||||
expect(run('anyOf', admin({ permissions: ['order.tamin'] }))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a holder of none', () => {
|
||||
expect(() => run('anyOf', admin({ permissions: ['product.list'] }))).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSuperAdminRids', () => {
|
||||
it('adds the super-admin rids for the super admin only', () => {
|
||||
expect(withSuperAdminRids(SUPER_ADMIN_MOBILE, [])).toContain('config.wallet');
|
||||
expect(withSuperAdminRids('09120000000', ['product.list'])).toEqual(['product.list']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
import { isSuperAdmin } from './super-admin';
|
||||
|
||||
/** Marks a route as reachable without a token (login, health checks). */
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/**
|
||||
* Requires the caller to hold this rid (ancestors count — see expandWithParents).
|
||||
* With several rids, holding any one of them is enough.
|
||||
*/
|
||||
export const REQUIRED_RID_KEY = 'requiredRid';
|
||||
export const RequirePermission = (...rids: [string, ...string[]]) =>
|
||||
SetMetadata(REQUIRED_RID_KEY, rids);
|
||||
|
||||
/**
|
||||
* Restricts a route (or a whole controller) to the super admin — see
|
||||
* super-admin.ts. `allowBranch` also admits an admin bound to a branch, because
|
||||
* the branch panel serves its staff from the same retail endpoints.
|
||||
*/
|
||||
export const SUPER_ADMIN_ONLY_KEY = 'superAdminOnly';
|
||||
export interface SuperAdminOnlyOptions {
|
||||
allowBranch?: boolean;
|
||||
}
|
||||
export const SuperAdminOnly = (options: SuperAdminOnlyOptions = {}) =>
|
||||
SetMetadata(SUPER_ADMIN_ONLY_KEY, options);
|
||||
|
||||
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
|
||||
export const CurrentAdmin = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthenticatedAdmin =>
|
||||
ctx.switchToHttp().getRequest().user,
|
||||
);
|
||||
|
||||
/**
|
||||
* JWT guard that honours @Public().
|
||||
*
|
||||
* Registered globally in AuthModule, so every route is authenticated by default
|
||||
* and opting out is explicit. The legacy app had the inverse default — its router
|
||||
* guard skipped a hardcoded `noneAuthUrl` list — which meant any route added
|
||||
* without a `meta.role` was silently unprotected.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforces @SuperAdminOnly() and @RequirePermission(rid) against the expanded permission list. */
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
const superAdminOnly = this.reflector.getAllAndOverride<SuperAdminOnlyOptions | undefined>(
|
||||
SUPER_ADMIN_ONLY_KEY,
|
||||
targets,
|
||||
);
|
||||
const requiredRids = this.reflector.getAllAndOverride<string[] | undefined>(REQUIRED_RID_KEY, targets);
|
||||
if (!superAdminOnly && !requiredRids) return true;
|
||||
|
||||
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
|
||||
if (!user) throw new ForbiddenException();
|
||||
|
||||
if (superAdminOnly && !isSuperAdmin(user.mobile)) {
|
||||
if (!(superAdminOnly.allowBranch && user.branchId)) {
|
||||
throw new ForbiddenException('این بخش فقط در دسترس مدیر ارشد سامانه است');
|
||||
}
|
||||
}
|
||||
if (!requiredRids) return true;
|
||||
|
||||
if (!requiredRids.some((rid) => user.permissions.includes(rid))) {
|
||||
throw new ForbiddenException(`Missing permission: ${requiredRids.join(' | ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { expandWithParents } from './acl.constants';
|
||||
import { withSuperAdminRids } from './super-admin';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
mobile: string;
|
||||
/**
|
||||
* Present only on tokens issued while session tracking is on. It identifies the
|
||||
* AdminSession row, which is what makes revoking a token before it expires
|
||||
* possible. A token without it predates tracking and stays valid until it
|
||||
* expires, so switching the feature on does not sign everyone out.
|
||||
*/
|
||||
jti?: string;
|
||||
/**
|
||||
* Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel
|
||||
* token has no `typ`. Checked in validate() below — see the note there.
|
||||
*/
|
||||
typ?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* App token types, which must NEVER authenticate an admin-panel route.
|
||||
*
|
||||
* This matters because a sales expert's app token carries their AdminUser id
|
||||
* in `sub` (D21). The two token families are signed with different secrets, so
|
||||
* normally the signature alone stops it — but `JWT_CUSTOMER_SECRET` falls back
|
||||
* to `JWT_SECRET` when unset, and in that configuration an expert's app token
|
||||
* WOULD verify here and be handed full admin-panel permissions. This check
|
||||
* closes that regardless of how the secrets are configured.
|
||||
*/
|
||||
const APP_TOKEN_TYPES = new Set(['customer', 'sales-expert']);
|
||||
|
||||
/**
|
||||
* The authenticated principal attached to `req.user`.
|
||||
*
|
||||
* `permissions` is already expanded with ancestors, so guards can test a rid with
|
||||
* a plain `includes()` — the same shape the legacy frontend held in its Vuex store
|
||||
* after checkParent().
|
||||
*/
|
||||
export interface AuthenticatedAdmin {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
userTypeId: number | null;
|
||||
branchId: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> {
|
||||
// An app token is never an admin-panel token, whatever it is signed with.
|
||||
if (payload?.typ && APP_TOKEN_TYPES.has(payload.typ)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Revoked centrally (IAM back-channel logout) or locally? While tracking is
|
||||
// off this returns true without touching the database, so the request costs
|
||||
// exactly what it did before.
|
||||
if (!(await this.sessions.isActive(payload.jti))) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Permissions are re-read on every request rather than baked into the token,
|
||||
// so a permission change takes effect immediately instead of at next login.
|
||||
const rids = withSuperAdminRids(user.mobile, user.userType?.permissions.map((p) => p.rid) ?? []);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
userTypeId: user.userTypeId,
|
||||
branchId: user.branchId,
|
||||
permissions: expandWithParents(rids),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* Value written into `passwordHash` for accounts that must never log in with a
|
||||
* password — the ~1,590 legacy customers who only ever used OTP
|
||||
* (docs/DATA-MIGRATION-MAP.md §10.4). The `!` prefix is the Django/Rails
|
||||
* "unusable password" convention: readable by anyone looking at the row, and
|
||||
* matched by no password.
|
||||
*
|
||||
* Kept in this dependency-free module (bcryptjs only) so the data-import
|
||||
* script can use it without loading NestJS.
|
||||
*/
|
||||
export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN';
|
||||
|
||||
/**
|
||||
* Same idea for an admin account created before anyone has issued it a
|
||||
* password (e.g. a workshop supervisor added from the branch form): it cannot
|
||||
* sign in until a password is set through the users screen.
|
||||
*/
|
||||
export const NO_PASSWORD_YET_HASH = '!NO_PASSWORD_SET_YET';
|
||||
|
||||
/**
|
||||
* A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from
|
||||
* the legacy server — all three verify with bcryptjs). Anything else is not a
|
||||
* password: '' on accounts created without one, or OTP_ONLY_PASSWORD_HASH.
|
||||
*
|
||||
* Such hashes are refused here, BEFORE bcrypt is called, so the outcome never
|
||||
* depends on how a particular bcrypt build treats a malformed hash. bcryptjs 3
|
||||
* happens to return false today; a future version or a native binding might
|
||||
* throw and turn a wrong password into a 500 (owner ruling 2026-10-07).
|
||||
*/
|
||||
export async function compareLocalHash(password: string, passwordHash: string | null | undefined) {
|
||||
if (typeof passwordHash !== 'string' || !passwordHash.startsWith('$2')) return false;
|
||||
return bcrypt.compare(password, passwordHash);
|
||||
}
|
||||
|
||||
/** True when a stored legacy hash can be carried over as a working password. */
|
||||
export function isPasswordHash(hash: unknown): hash is string {
|
||||
return typeof hash === 'string' && hash.startsWith('$2');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The one account allowed to see the super-admin-only features (retail
|
||||
* customers and orders, bankers, walk-in shopkeepers, branches and workshops,
|
||||
* retail promotions, wallet management, trays). Matched on mobile, not on
|
||||
* AdminRole.SUPER_ADMIN: that role can be assigned to any admin from the
|
||||
* users form, this account cannot.
|
||||
*/
|
||||
export const SUPER_ADMIN_MOBILE = '09128081392';
|
||||
|
||||
/**
|
||||
* Rids of those features. The admin panel no longer offers them on the
|
||||
* permission screen (except to the super admin), so the super admin is granted
|
||||
* them here whatever its user type holds.
|
||||
*/
|
||||
export const SUPER_ADMIN_RIDS = [
|
||||
'persons.create.single',
|
||||
'persons.list.client.single',
|
||||
'persons.list.client.single.view',
|
||||
'persons.list.client.single.edit',
|
||||
'branch',
|
||||
'branch.create',
|
||||
'branch.list',
|
||||
'workshop',
|
||||
'workshop.create',
|
||||
'workshop.list',
|
||||
'order.create',
|
||||
'order.list',
|
||||
'order.list.view',
|
||||
'order.list.sts',
|
||||
'order.list.sts.view',
|
||||
'order.list.sts.remove',
|
||||
'order.list.sts.submit',
|
||||
'order.list.view.edit',
|
||||
'order.list.pdf',
|
||||
'offers.create.tak',
|
||||
'offers.list.tak.edit',
|
||||
'offers.list.tak.remove',
|
||||
'config.wallet',
|
||||
] as const;
|
||||
|
||||
export function isSuperAdmin(mobile: string | null | undefined): boolean {
|
||||
return mobile?.trim() === SUPER_ADMIN_MOBILE;
|
||||
}
|
||||
|
||||
/** The raw rids an admin holds, plus SUPER_ADMIN_RIDS for the super admin. */
|
||||
export function withSuperAdminRids(mobile: string | null | undefined, rids: string[]): string[] {
|
||||
return isSuperAdmin(mobile) ? [...new Set([...rids, ...SUPER_ADMIN_RIDS])] : rids;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CatalogCacheService } from './catalog-cache.service';
|
||||
|
||||
/**
|
||||
* Global so the accounting module can invalidate the storefront catalogue
|
||||
* without importing CatalogModule (which itself depends on the accounting
|
||||
* module).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CatalogCacheService],
|
||||
exports: [CatalogCacheService],
|
||||
})
|
||||
export class CatalogCacheModule {}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CATALOG_CACHE_MS, CatalogCacheService } from './catalog-cache.service';
|
||||
|
||||
type Middleware = (params: Record<string, unknown>, next: (p: unknown) => Promise<unknown>) => Promise<unknown>;
|
||||
|
||||
function setup() {
|
||||
let middleware: Middleware | undefined;
|
||||
const prisma = { $use: jest.fn((fn: Middleware) => (middleware = fn)) };
|
||||
const cache = new CatalogCacheService(prisma as never);
|
||||
cache.onModuleInit();
|
||||
const runQuery = (params: Record<string, unknown>) => middleware!(params, async () => 'ok');
|
||||
return { cache, runQuery };
|
||||
}
|
||||
|
||||
describe('CatalogCacheService', () => {
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('serves a fresh copy without rebuilding', async () => {
|
||||
const { cache } = setup();
|
||||
const build = jest.fn().mockResolvedValue(['a']);
|
||||
expect(await cache.get(build)).toEqual(['a']);
|
||||
expect(await cache.get(build)).toEqual(['a']);
|
||||
expect(build).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rebuilds once the TTL has passed', async () => {
|
||||
jest.useFakeTimers({ now: 1_000_000 });
|
||||
const { cache } = setup();
|
||||
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
|
||||
await cache.get(build);
|
||||
jest.setSystemTime(1_000_000 + CATALOG_CACHE_MS);
|
||||
expect(await cache.get(build)).toEqual(['new']);
|
||||
expect(build).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares one build between concurrent callers', async () => {
|
||||
const { cache } = setup();
|
||||
let resolve!: (v: string[]) => void;
|
||||
const build = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
|
||||
const a = cache.get(build);
|
||||
const b = cache.get(build);
|
||||
resolve(['x']);
|
||||
expect(await a).toEqual(['x']);
|
||||
expect(await b).toEqual(['x']);
|
||||
expect(build).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidate() forces the next read to rebuild', async () => {
|
||||
const { cache } = setup();
|
||||
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
|
||||
await cache.get(build);
|
||||
cache.invalidate();
|
||||
expect(await cache.get(build)).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('does not keep a build that started before an invalidation', async () => {
|
||||
const { cache } = setup();
|
||||
let resolve!: (v: string[]) => void;
|
||||
const slow = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
|
||||
const pending = cache.get(slow);
|
||||
cache.invalidate();
|
||||
resolve(['stale']);
|
||||
await pending;
|
||||
const fresh = jest.fn().mockResolvedValue(['fresh']);
|
||||
expect(await cache.get(fresh)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('invalidates on a write to a catalogue model', async () => {
|
||||
const { cache, runQuery } = setup();
|
||||
const build = jest.fn().mockResolvedValue(['x']);
|
||||
await cache.get(build);
|
||||
await runQuery({ model: 'ProductVariant', action: 'updateMany', runInTransaction: false });
|
||||
await cache.get(build);
|
||||
expect(build).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores reads and writes to other models', async () => {
|
||||
const { cache, runQuery } = setup();
|
||||
const build = jest.fn().mockResolvedValue(['x']);
|
||||
await cache.get(build);
|
||||
await runQuery({ model: 'Product', action: 'findMany', runInTransaction: false });
|
||||
await runQuery({ model: 'Setting', action: 'upsert', runInTransaction: false });
|
||||
await cache.get(build);
|
||||
expect(build).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidates again after a transactional write has had time to commit', async () => {
|
||||
jest.useFakeTimers();
|
||||
const { cache, runQuery } = setup();
|
||||
await runQuery({ model: 'Product', action: 'update', runInTransaction: true });
|
||||
// Rebuilt from the pre-commit state before the transaction finished.
|
||||
const build = jest.fn().mockResolvedValue(['x']);
|
||||
await cache.get(build);
|
||||
jest.advanceTimersByTime(2_000);
|
||||
await cache.get(build);
|
||||
expect(build).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/** How long a built storefront catalogue is served before it is rebuilt. */
|
||||
export const CATALOG_CACHE_MS = 30_000;
|
||||
|
||||
/** Models whose rows appear in GET /catalog/products. */
|
||||
const CATALOG_MODELS = new Set(['Product', 'ProductVariant', 'ProductImage', 'Category', 'Brand', 'Collection', 'CollectionProduct']);
|
||||
|
||||
const WRITE_ACTIONS = new Set([
|
||||
'create',
|
||||
'createMany',
|
||||
'createManyAndReturn',
|
||||
'update',
|
||||
'updateMany',
|
||||
'upsert',
|
||||
'delete',
|
||||
'deleteMany',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A write inside an interactive transaction is not visible until the commit,
|
||||
* which the middleware cannot see. A second invalidation this long after the
|
||||
* write drops anything rebuilt from the pre-commit state in between.
|
||||
*/
|
||||
const TX_SETTLE_MS = 2_000;
|
||||
|
||||
/**
|
||||
* In-memory copy of the storefront catalogue (GET /catalog/products).
|
||||
*
|
||||
* Building it is one wide findMany plus a stock check per product, and every
|
||||
* app screen used to ask for it. Any Prisma write to a catalogue model — admin
|
||||
* product/category/brand/collection screens, the accounting product and stock
|
||||
* pull, wage changes — invalidates it through the middleware below, wherever
|
||||
* the write comes from. Accounting settings (which decide the stock rule) are
|
||||
* not a catalogue model; AccountingConfigService invalidates on save.
|
||||
*
|
||||
* Per process: correct while the API runs as a single instance. With several
|
||||
* instances this moves to a shared store (Redis) behind the same two methods.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CatalogCacheService implements OnModuleInit {
|
||||
private value: { at: number; data: unknown } | null = null;
|
||||
private inflight: Promise<unknown> | null = null;
|
||||
/** Bumped by invalidate(), so a build started before it is never stored. */
|
||||
private generation = 0;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.prisma.$use(async (params, next) => {
|
||||
const result = await next(params);
|
||||
if (params.model && CATALOG_MODELS.has(params.model) && WRITE_ACTIONS.has(params.action)) {
|
||||
this.invalidate();
|
||||
if (params.runInTransaction) setTimeout(() => this.invalidate(), TX_SETTLE_MS).unref?.();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async get<T>(build: () => Promise<T>): Promise<T> {
|
||||
if (this.value && Date.now() - this.value.at < CATALOG_CACHE_MS) return this.value.data as T;
|
||||
if (this.inflight) return this.inflight as Promise<T>;
|
||||
|
||||
const gen = this.generation;
|
||||
const p = build()
|
||||
.then((data) => {
|
||||
if (gen === this.generation) this.value = { at: Date.now(), data };
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.inflight === p) this.inflight = null;
|
||||
});
|
||||
this.inflight = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.generation++;
|
||||
this.value = null;
|
||||
this.inflight = null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user