Adding a task generated from the account balance to products

This commit is contained in:
2026-09-21 21:25:34 +03:30
parent be97a067f9
commit 17fb33b3e7
10 changed files with 700 additions and 0 deletions
@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "ProductAccountingLink" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalName" TEXT NOT NULL,
"ayar" INTEGER,
"vaznSabet" DOUBLE PRECISION,
"matchMethod" TEXT NOT NULL DEFAULT 'import',
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductAccountingLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProductAccountingLink_productId_idx" ON "ProductAccountingLink"("productId");
-- CreateIndex
CREATE INDEX "ProductAccountingLink_connectionId_idx" ON "ProductAccountingLink"("connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "ProductAccountingLink_productId_connectionId_key" ON "ProductAccountingLink"("productId", "connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "ProductAccountingLink_connectionId_externalName_key" ON "ProductAccountingLink"("connectionId", "externalName");
-- AddForeignKey
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+25
View File
@@ -647,6 +647,7 @@ model Product {
quoteLineItems QuoteLineItem[]
invoiceLineItems InvoiceLineItem[]
stockMovements StockMovement[]
accountingLinks ProductAccountingLink[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1316,6 +1317,7 @@ model AccountingConnection {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[]
productLinks ProductAccountingLink[]
documentLogs AccountingDocumentLog[]
balanceCaches AccountingBalanceCache[]
@@ -1346,6 +1348,29 @@ model PersonAccountingLink {
@@index([connectionId])
}
/// لینک محصول CRM ↔ نام کارساخته (یا معادل) در ته‌حساب
model ProductAccountingLink {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
/// نام کارساخته در ته‌حساب (DoListNameKarsakhte.Name)
externalName String
ayar Int?
vaznSabet Float?
/// import | manual
matchMethod String @default("import")
linkedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, connectionId])
@@unique([connectionId, externalName])
@@index([productId])
@@index([connectionId])
}
model AccountingDocumentLog {
id String @id @default(cuid())
connectionId String
+37
View File
@@ -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,
],