Adding a task generated from the account balance to products
This commit is contained in:
@@ -197,6 +197,43 @@ export class SearchTahesabDto {
|
||||
listAll?: boolean;
|
||||
}
|
||||
|
||||
export class SearchTahesabProductsDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
/** فیلتر اختیاری روی نام کارساخته */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export class ImportProductFromTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
/** اگر داده شود به این محصول لینک میشود؛ وگرنه محصول جدید ساخته میشود */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export class ImportBulkProductsFromTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
/** اگر خالی باشد همهٔ نامهای کارساخته وارد میشوند */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
names?: string[];
|
||||
}
|
||||
|
||||
export class ListAsnadQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,11 @@ export class TahesabApi {
|
||||
return this.call('DoListNameSekeh', 'DoListNameSekeh', []);
|
||||
}
|
||||
|
||||
/** لیست نام کارهای ساخته دارای سند */
|
||||
doListNameKarsakhte() {
|
||||
return this.call('DoListNameKarsakhte', 'DoListNameKarsakhte', []);
|
||||
}
|
||||
|
||||
doListNameArz() {
|
||||
return this.call('DoListNameARZ', 'DoListNameARZ', []);
|
||||
}
|
||||
|
||||
@@ -15,17 +15,21 @@ import { Roles } from '../../auth/roles.decorator';
|
||||
import { RolesGuard } from '../../auth/roles.guard';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
import { TahesabProductLinkService } from './product-link.service';
|
||||
import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import {
|
||||
CreateInTahesabDto,
|
||||
CreateTahesabConnectionDto,
|
||||
ImportBulkFromTahesabDto,
|
||||
ImportBulkProductsFromTahesabDto,
|
||||
ImportFromTahesabDto,
|
||||
ImportProductFromTahesabDto,
|
||||
ManualLinkDto,
|
||||
MatchPersonDto,
|
||||
PushSanadDto,
|
||||
SearchTahesabDto,
|
||||
SearchTahesabProductsDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
} from './dto';
|
||||
|
||||
@@ -35,6 +39,7 @@ export class TahesabController {
|
||||
constructor(
|
||||
private readonly connections: TahesabConnectionsService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
private readonly products: TahesabProductLinkService,
|
||||
private readonly balance: TahesabBalanceService,
|
||||
private readonly documents: TahesabDocumentsService,
|
||||
) {}
|
||||
@@ -210,4 +215,31 @@ export class TahesabController {
|
||||
) {
|
||||
return this.links.importBulk(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Post('sync/products/search')
|
||||
@Roles('owner', 'admin', 'sales')
|
||||
searchProducts(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: SearchTahesabProductsDto,
|
||||
) {
|
||||
return this.products.search(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Post('sync/products/import')
|
||||
@Roles('owner', 'admin', 'sales')
|
||||
importProduct(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ImportProductFromTahesabDto,
|
||||
) {
|
||||
return this.products.importOne(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Post('sync/products/import-bulk')
|
||||
@Roles('owner', 'admin')
|
||||
importProductsBulk(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ImportBulkProductsFromTahesabDto,
|
||||
) {
|
||||
return this.products.importBulk(user.organizationId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TahesabCryptoService } from './tahesab-crypto.service';
|
||||
import { TahesabHttpClient } from './tahesab-http.client';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
import { TahesabProductLinkService } from './product-link.service';
|
||||
import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
@@ -16,12 +17,14 @@ import { TahesabController } from './tahesab.controller';
|
||||
TahesabHttpClient,
|
||||
TahesabConnectionsService,
|
||||
TahesabPersonLinkService,
|
||||
TahesabProductLinkService,
|
||||
TahesabBalanceService,
|
||||
TahesabDocumentsService,
|
||||
],
|
||||
exports: [
|
||||
TahesabConnectionsService,
|
||||
TahesabPersonLinkService,
|
||||
TahesabProductLinkService,
|
||||
TahesabBalanceService,
|
||||
TahesabDocumentsService,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user