Files
CRM_Montzar/apps/api/src/integrations/tahesab/product-link.service.ts
T

326 lines
10 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabConnectionsService } from './connections.service';
import {
ImportBulkProductsFromTahesabDto,
ImportProductFromTahesabDto,
SearchTahesabProductsDto,
} from './dto';
/** دستهٔ پیش‌فرض برای نام‌های DoListNameKarsakhte (در طلا و جواهر: کارساخته) */
export const TAHSAB_KARSAKHTE_CATEGORY = 'کارساخته';
function asNameList(raw: unknown): Array<Record<string, unknown>> {
if (!raw) return [];
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
if (typeof raw === 'object') {
const vals = Object.values(raw as Record<string, unknown>);
if (vals.every((v) => v && typeof v === 'object')) {
return vals as Array<Record<string, unknown>>;
}
}
return [];
}
function pickKarName(row: Record<string, unknown>): string {
return String(row.Name ?? row.name ?? row.Name_Kar ?? '').trim();
}
function pickAyar(row: Record<string, unknown>): number | null {
const n = Number(row.Ayar ?? row.ayar);
return Number.isFinite(n) ? Math.round(n) : null;
}
function pickVaznSabet(row: Record<string, unknown>): number | null {
const n = Number(row.VaznSabet ?? row.vaznSabet);
return Number.isFinite(n) ? n : null;
}
function pickLastSanad(row: Record<string, unknown>): string {
return String(row.LastSanad ?? row.lastSanad ?? '').trim();
}
function normalizeQuery(q: string): string {
return q.trim().toLowerCase();
}
@Injectable()
export class TahesabProductLinkService {
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
) {}
private async getProduct(organizationId: string, productId: string) {
const product = await this.prisma.product.findFirst({
where: { id: productId, organizationId },
});
if (!product) throw new NotFoundException('محصول یافت نشد.');
return product;
}
private async listKarsakhteRows(organizationId: string, connectionId: string) {
const { api } = await this.connections.getApi(organizationId, connectionId);
const raw = await api.doListNameKarsakhte();
const mapped = asNameList(raw)
.map((row) => {
const name = pickKarName(row);
if (!name) return null;
return {
name,
ayar: pickAyar(row),
vaznSabet: pickVaznSabet(row),
lastSanad: pickLastSanad(row),
};
})
.filter((r): r is NonNullable<typeof r> => Boolean(r));
// یکتا بر اساس نام (آخرین ردیف برنده)
const byName = new Map<string, (typeof mapped)[number]>();
for (const row of mapped) byName.set(row.name, row);
return [...byName.values()].sort((a, b) =>
a.name.localeCompare(b.name, 'fa', { sensitivity: 'base', numeric: true }),
);
}
async search(organizationId: string, dto: SearchTahesabProductsDto) {
const rows = await this.listKarsakhteRows(organizationId, dto.connectionId);
const q = dto.q ? normalizeQuery(dto.q) : '';
const filtered = q
? rows.filter((r) => {
if (r.name.toLowerCase().includes(q)) return true;
if (r.ayar != null && String(r.ayar).includes(q)) return true;
return false;
})
: rows;
const names = filtered.map((r) => r.name);
const existing =
names.length === 0
? []
: await this.prisma.productAccountingLink.findMany({
where: {
connectionId: dto.connectionId,
externalName: { in: names },
},
select: {
externalName: true,
productId: true,
product: { select: { name: true } },
},
});
const byName = new Map(existing.map((e) => [e.externalName, e]));
const results = filtered.map((row) => {
const link = byName.get(row.name);
return {
...row,
alreadyImported: Boolean(link),
productId: link?.productId ?? null,
productName: link?.product.name ?? null,
};
});
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'search_products',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { count: results.length } as Prisma.InputJsonValue,
},
});
return { results, categoryLabel: TAHSAB_KARSAKHTE_CATEGORY };
}
async importOne(organizationId: string, dto: ImportProductFromTahesabDto) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام کارساخته الزامی است.');
const clash = await this.prisma.productAccountingLink.findFirst({
where: { connectionId: dto.connectionId, externalName: name },
include: { product: true },
});
if (clash && (!dto.productId || clash.productId !== dto.productId)) {
throw new ConflictException(
`این نام ته‌حساب قبلاً به محصول «${clash.product.name}» لینک شده است.`,
);
}
const rows = await this.listKarsakhteRows(organizationId, dto.connectionId);
const row = rows.find((r) => r.name === name);
if (!row) {
throw new NotFoundException(`کارساخته «${name}» در ته‌حساب پیدا نشد.`);
}
return this.importRow(organizationId, dto.connectionId, row, dto.productId, true);
}
private async importRow(
organizationId: string,
connectionId: string,
row: {
name: string;
ayar: number | null;
vaznSabet: number | null;
lastSanad: string;
},
productId: string | undefined,
logJob: boolean,
) {
let resolvedProductId = productId;
if (resolvedProductId) {
await this.getProduct(organizationId, resolvedProductId);
} else {
const tags: string[] = ['ته‌حساب', TAHSAB_KARSAKHTE_CATEGORY];
if (row.ayar != null) tags.push(`عیار:${row.ayar}`);
const descParts = [
'واردشده از ته‌حساب (کارساخته)',
row.ayar != null ? `عیار ${row.ayar}` : null,
row.vaznSabet != null ? `وزن ثابت ${row.vaznSabet}` : null,
row.lastSanad ? `آخرین سند ${row.lastSanad}` : null,
].filter(Boolean);
const created = await this.prisma.product.create({
data: {
organizationId,
kind: 'product',
name: row.name,
category: TAHSAB_KARSAKHTE_CATEGORY,
unit: row.vaznSabet != null || row.ayar != null ? 'گرم' : 'عدد',
description: descParts.join(' · '),
tags,
isActive: true,
},
});
resolvedProductId = created.id;
}
const link = await this.prisma.productAccountingLink.upsert({
where: {
productId_connectionId: {
productId: resolvedProductId,
connectionId,
},
},
create: {
productId: resolvedProductId,
connectionId,
externalName: row.name,
ayar: row.ayar,
vaznSabet: row.vaznSabet,
matchMethod: 'import',
},
update: {
externalName: row.name,
ayar: row.ayar,
vaznSabet: row.vaznSabet,
matchMethod: 'import',
linkedAt: new Date(),
},
});
if (logJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId,
kind: 'import_products',
status: 'success',
request: { connectionId, name: row.name } as Prisma.InputJsonValue,
result: {
productId: resolvedProductId,
linkId: link.id,
} as Prisma.InputJsonValue,
},
});
}
return { productId: resolvedProductId, linkId: link.id };
}
async importBulk(organizationId: string, dto: ImportBulkProductsFromTahesabDto) {
const allRows = await this.listKarsakhteRows(organizationId, dto.connectionId);
const wanted = (dto.names ?? []).map((n) => n.trim()).filter(Boolean);
const rows =
wanted.length > 0
? allRows.filter((r) => wanted.includes(r.name))
: allRows;
if (!rows.length) {
throw new BadRequestException('نام کارساخته‌ای برای وارد کردن مشخص نشده است.');
}
if (rows.length > 1000) {
throw new BadRequestException(
`حداکثر ۱۰۰۰ کارساخته در هر بار وارد کنید (الان ${rows.length}).`,
);
}
const items: Array<{
name: string;
ok: boolean;
productId?: string;
skipped?: boolean;
error?: string;
}> = [];
for (const row of rows) {
try {
const existing = await this.prisma.productAccountingLink.findFirst({
where: { connectionId: dto.connectionId, externalName: row.name },
});
if (existing) {
items.push({
name: row.name,
ok: true,
skipped: true,
productId: existing.productId,
});
continue;
}
const r = await this.importRow(
organizationId,
dto.connectionId,
row,
undefined,
false,
);
items.push({ name: row.name, ok: true, productId: r.productId });
} catch (e) {
const message = e instanceof Error ? e.message : 'وارد کردن ناموفق بود';
items.push({ name: row.name, ok: false, error: message });
}
}
const imported = items.filter((i) => i.ok && !i.skipped).length;
const skipped = items.filter((i) => i.skipped).length;
const failed = items.filter((i) => !i.ok).length;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import_products',
status: failed && !imported ? 'error' : 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: {
imported,
skipped,
failed,
count: items.length,
} as Prisma.InputJsonValue,
},
});
return { imported, skipped, failed, items };
}
}