326 lines
10 KiB
TypeScript
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 };
|
|
}
|
|
}
|