Adding a task generated from the account balance to products
This commit is contained in:
@@ -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;
|
||||||
@@ -647,6 +647,7 @@ model Product {
|
|||||||
quoteLineItems QuoteLineItem[]
|
quoteLineItems QuoteLineItem[]
|
||||||
invoiceLineItems InvoiceLineItem[]
|
invoiceLineItems InvoiceLineItem[]
|
||||||
stockMovements StockMovement[]
|
stockMovements StockMovement[]
|
||||||
|
accountingLinks ProductAccountingLink[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1316,6 +1317,7 @@ model AccountingConnection {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
personLinks PersonAccountingLink[]
|
personLinks PersonAccountingLink[]
|
||||||
|
productLinks ProductAccountingLink[]
|
||||||
documentLogs AccountingDocumentLog[]
|
documentLogs AccountingDocumentLog[]
|
||||||
balanceCaches AccountingBalanceCache[]
|
balanceCaches AccountingBalanceCache[]
|
||||||
|
|
||||||
@@ -1346,6 +1348,29 @@ model PersonAccountingLink {
|
|||||||
@@index([connectionId])
|
@@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 {
|
model AccountingDocumentLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
connectionId String
|
connectionId String
|
||||||
|
|||||||
@@ -197,6 +197,43 @@ export class SearchTahesabDto {
|
|||||||
listAll?: boolean;
|
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 {
|
export class ListAsnadQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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', []);
|
return this.call('DoListNameSekeh', 'DoListNameSekeh', []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** لیست نام کارهای ساخته دارای سند */
|
||||||
|
doListNameKarsakhte() {
|
||||||
|
return this.call('DoListNameKarsakhte', 'DoListNameKarsakhte', []);
|
||||||
|
}
|
||||||
|
|
||||||
doListNameArz() {
|
doListNameArz() {
|
||||||
return this.call('DoListNameARZ', 'DoListNameARZ', []);
|
return this.call('DoListNameARZ', 'DoListNameARZ', []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,17 +15,21 @@ import { Roles } from '../../auth/roles.decorator';
|
|||||||
import { RolesGuard } from '../../auth/roles.guard';
|
import { RolesGuard } from '../../auth/roles.guard';
|
||||||
import { TahesabConnectionsService } from './connections.service';
|
import { TahesabConnectionsService } from './connections.service';
|
||||||
import { TahesabPersonLinkService } from './person-link.service';
|
import { TahesabPersonLinkService } from './person-link.service';
|
||||||
|
import { TahesabProductLinkService } from './product-link.service';
|
||||||
import { TahesabBalanceService } from './balance.service';
|
import { TahesabBalanceService } from './balance.service';
|
||||||
import { TahesabDocumentsService } from './documents.service';
|
import { TahesabDocumentsService } from './documents.service';
|
||||||
import {
|
import {
|
||||||
CreateInTahesabDto,
|
CreateInTahesabDto,
|
||||||
CreateTahesabConnectionDto,
|
CreateTahesabConnectionDto,
|
||||||
ImportBulkFromTahesabDto,
|
ImportBulkFromTahesabDto,
|
||||||
|
ImportBulkProductsFromTahesabDto,
|
||||||
ImportFromTahesabDto,
|
ImportFromTahesabDto,
|
||||||
|
ImportProductFromTahesabDto,
|
||||||
ManualLinkDto,
|
ManualLinkDto,
|
||||||
MatchPersonDto,
|
MatchPersonDto,
|
||||||
PushSanadDto,
|
PushSanadDto,
|
||||||
SearchTahesabDto,
|
SearchTahesabDto,
|
||||||
|
SearchTahesabProductsDto,
|
||||||
UpdateTahesabConnectionDto,
|
UpdateTahesabConnectionDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
@@ -35,6 +39,7 @@ export class TahesabController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly connections: TahesabConnectionsService,
|
private readonly connections: TahesabConnectionsService,
|
||||||
private readonly links: TahesabPersonLinkService,
|
private readonly links: TahesabPersonLinkService,
|
||||||
|
private readonly products: TahesabProductLinkService,
|
||||||
private readonly balance: TahesabBalanceService,
|
private readonly balance: TahesabBalanceService,
|
||||||
private readonly documents: TahesabDocumentsService,
|
private readonly documents: TahesabDocumentsService,
|
||||||
) {}
|
) {}
|
||||||
@@ -210,4 +215,31 @@ export class TahesabController {
|
|||||||
) {
|
) {
|
||||||
return this.links.importBulk(user.organizationId, dto);
|
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 { TahesabHttpClient } from './tahesab-http.client';
|
||||||
import { TahesabConnectionsService } from './connections.service';
|
import { TahesabConnectionsService } from './connections.service';
|
||||||
import { TahesabPersonLinkService } from './person-link.service';
|
import { TahesabPersonLinkService } from './person-link.service';
|
||||||
|
import { TahesabProductLinkService } from './product-link.service';
|
||||||
import { TahesabBalanceService } from './balance.service';
|
import { TahesabBalanceService } from './balance.service';
|
||||||
import { TahesabDocumentsService } from './documents.service';
|
import { TahesabDocumentsService } from './documents.service';
|
||||||
import { TahesabController } from './tahesab.controller';
|
import { TahesabController } from './tahesab.controller';
|
||||||
@@ -16,12 +17,14 @@ import { TahesabController } from './tahesab.controller';
|
|||||||
TahesabHttpClient,
|
TahesabHttpClient,
|
||||||
TahesabConnectionsService,
|
TahesabConnectionsService,
|
||||||
TahesabPersonLinkService,
|
TahesabPersonLinkService,
|
||||||
|
TahesabProductLinkService,
|
||||||
TahesabBalanceService,
|
TahesabBalanceService,
|
||||||
TahesabDocumentsService,
|
TahesabDocumentsService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TahesabConnectionsService,
|
TahesabConnectionsService,
|
||||||
TahesabPersonLinkService,
|
TahesabPersonLinkService,
|
||||||
|
TahesabProductLinkService,
|
||||||
TahesabBalanceService,
|
TahesabBalanceService,
|
||||||
TahesabDocumentsService,
|
TahesabDocumentsService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
|
'کارساخته',
|
||||||
'طلا و جواهر',
|
'طلا و جواهر',
|
||||||
'سکه',
|
'سکه',
|
||||||
'خدمات',
|
'خدمات',
|
||||||
|
|||||||
@@ -56,12 +56,35 @@ type SyncResult = {
|
|||||||
};
|
};
|
||||||
const syncResults = ref<SyncResult[]>([]);
|
const syncResults = ref<SyncResult[]>([]);
|
||||||
|
|
||||||
|
const productSync = reactive({
|
||||||
|
connectionId: '',
|
||||||
|
q: '',
|
||||||
|
});
|
||||||
|
const productSyncBusy = ref(false);
|
||||||
|
type ProductSyncResult = {
|
||||||
|
name: string;
|
||||||
|
ayar: number | null;
|
||||||
|
vaznSabet: number | null;
|
||||||
|
lastSanad: string;
|
||||||
|
alreadyImported?: boolean;
|
||||||
|
productId?: string | null;
|
||||||
|
productName?: string | null;
|
||||||
|
};
|
||||||
|
const productSyncResults = ref<ProductSyncResult[]>([]);
|
||||||
|
const productCategoryLabel = ref('کارساخته');
|
||||||
|
|
||||||
function pendingImportCodes() {
|
function pendingImportCodes() {
|
||||||
return syncResults.value
|
return syncResults.value
|
||||||
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
|
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
|
||||||
.map((r) => r.moshtariCode as number);
|
.map((r) => r.moshtariCode as number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pendingProductNames() {
|
||||||
|
return productSyncResults.value
|
||||||
|
.filter((r) => !r.alreadyImported)
|
||||||
|
.map((r) => r.name);
|
||||||
|
}
|
||||||
|
|
||||||
function apiErr(e: unknown, fallback: string) {
|
function apiErr(e: unknown, fallback: string) {
|
||||||
if (!axios.isAxiosError(e)) return fallback;
|
if (!axios.isAxiosError(e)) return fallback;
|
||||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||||
@@ -117,6 +140,7 @@ async function load() {
|
|||||||
items.value = data;
|
items.value = data;
|
||||||
if (!editingId.value) form.isDefault = data.length === 0;
|
if (!editingId.value) form.isDefault = data.length === 0;
|
||||||
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
|
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
|
||||||
|
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = axios.isAxiosError(e)
|
error.value = axios.isAxiosError(e)
|
||||||
? String(
|
? String(
|
||||||
@@ -381,6 +405,107 @@ async function importBulk(mode: 'results' | 'range') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchProducts() {
|
||||||
|
error.value = '';
|
||||||
|
ok.value = '';
|
||||||
|
if (!productSync.connectionId) {
|
||||||
|
error.value = 'ابتدا اتصال را برای کارساخته انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
productSyncBusy.value = true;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
connectionId: productSync.connectionId,
|
||||||
|
};
|
||||||
|
if (productSync.q.trim()) body.q = productSync.q.trim();
|
||||||
|
const { data } = await api.post<{
|
||||||
|
results: ProductSyncResult[];
|
||||||
|
categoryLabel?: string;
|
||||||
|
}>('/integrations/tahesab/sync/products/search', body, { timeout: 60_000 });
|
||||||
|
productSyncResults.value = data.results ?? [];
|
||||||
|
if (data.categoryLabel) productCategoryLabel.value = data.categoryLabel;
|
||||||
|
if (!productSyncResults.value.length) {
|
||||||
|
ok.value = 'کارساختهای یافت نشد.';
|
||||||
|
} else {
|
||||||
|
const linked = productSyncResults.value.filter((r) => r.alreadyImported).length;
|
||||||
|
ok.value =
|
||||||
|
`${productSyncResults.value.length} ${productCategoryLabel.value} پیدا شد` +
|
||||||
|
(linked ? ` (${linked} قبلاً وارد شده)` : '');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiErr(e, 'دریافت لیست کارساخته ناموفق');
|
||||||
|
} finally {
|
||||||
|
productSyncBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importProductOne(name: string) {
|
||||||
|
error.value = '';
|
||||||
|
ok.value = '';
|
||||||
|
try {
|
||||||
|
productSyncBusy.value = true;
|
||||||
|
const { data } = await api.post<{ productId: string }>(
|
||||||
|
'/integrations/tahesab/sync/products/import',
|
||||||
|
{ connectionId: productSync.connectionId, name },
|
||||||
|
);
|
||||||
|
ok.value = `وارد شد — در محصولات با شناسه ${data.productId}`;
|
||||||
|
const row = productSyncResults.value.find((r) => r.name === name);
|
||||||
|
if (row) {
|
||||||
|
row.alreadyImported = true;
|
||||||
|
row.productId = data.productId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiErr(e, 'وارد کردن محصول ناموفق');
|
||||||
|
} finally {
|
||||||
|
productSyncBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importProductsBulk(mode: 'pending' | 'all') {
|
||||||
|
error.value = '';
|
||||||
|
ok.value = '';
|
||||||
|
if (!productSync.connectionId) {
|
||||||
|
error.value = 'ابتدا اتصال را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: Record<string, unknown> = { connectionId: productSync.connectionId };
|
||||||
|
if (mode === 'pending') {
|
||||||
|
const names = pendingProductNames();
|
||||||
|
if (!names.length) {
|
||||||
|
error.value = `${productCategoryLabel.value} جدیدی برای وارد کردن نیست.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`${names.length} ${productCategoryLabel.value} بهعنوان محصول در ${brand.nameFa} ساخته میشود. ادامه؟`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.names = names;
|
||||||
|
} else if (
|
||||||
|
!confirm(
|
||||||
|
`همهٔ نامهای ${productCategoryLabel.value} از تهحساب وارد محصولات ${brand.nameFa} میشوند. ادامه؟`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
productSyncBusy.value = true;
|
||||||
|
const { data } = await api.post<{
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
}>('/integrations/tahesab/sync/products/import-bulk', body, { timeout: 120_000 });
|
||||||
|
ok.value = `وارد شد: ${data.imported} · قبلاً بود: ${data.skipped} · ناموفق: ${data.failed}`;
|
||||||
|
await searchProducts();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiErr(e, 'وارد کردن دستهای محصولات ناموفق');
|
||||||
|
} finally {
|
||||||
|
productSyncBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -636,6 +761,106 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UiCard>
|
</UiCard>
|
||||||
|
|
||||||
|
<UiCard title="وارد کردن کارساخته از تهحساب به محصولات" class="section-card">
|
||||||
|
<p class="sync-desc">
|
||||||
|
نامهای کارساختهٔ تهحساب (لیست <span class="font-mono" dir="ltr">DoListNameKarsakhte</span>)
|
||||||
|
را بهعنوان محصول با دستهٔ «{{ productCategoryLabel }}» وارد کنید.
|
||||||
|
موجودی همچنان از تهحساب است؛ اینجا فقط کاتالوگ برای معامله/فاکتور ساخته میشود.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="crm-form">
|
||||||
|
<UiField label="اتصال مورد نظر" required>
|
||||||
|
<UiSelect v-model="productSync.connectionId">
|
||||||
|
<option disabled value="">انتخاب اتصال</option>
|
||||||
|
<option v-for="row in items" :key="row.id" :value="row.id">
|
||||||
|
{{ row.label }} ({{ row.dbName }})
|
||||||
|
</option>
|
||||||
|
</UiSelect>
|
||||||
|
</UiField>
|
||||||
|
|
||||||
|
<div class="sync-sub-section">
|
||||||
|
<h4 class="sync-subhead">لیست و جستجو</h4>
|
||||||
|
<div class="crm-form-grid-2">
|
||||||
|
<UiInput
|
||||||
|
v-model="productSync.q"
|
||||||
|
placeholder="جستجو نام یا عیار…"
|
||||||
|
@keydown.enter.prevent="searchProducts"
|
||||||
|
/>
|
||||||
|
<div class="sync-buttons" style="margin-top: 0">
|
||||||
|
<UiButton
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="!productSync.connectionId || productSyncBusy"
|
||||||
|
@click="searchProducts"
|
||||||
|
>
|
||||||
|
دریافت لیست
|
||||||
|
</UiButton>
|
||||||
|
<UiButton
|
||||||
|
:disabled="!productSync.connectionId || productSyncBusy || !pendingProductNames().length"
|
||||||
|
@click="importProductsBulk('pending')"
|
||||||
|
>
|
||||||
|
ورود نتایج جدید ({{ pendingProductNames().length }})
|
||||||
|
</UiButton>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="!productSync.connectionId || productSyncBusy"
|
||||||
|
@click="importProductsBulk('all')"
|
||||||
|
>
|
||||||
|
ورود همه
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="productSyncResults.length" class="sync-results-box">
|
||||||
|
<h4 class="sync-results-title">
|
||||||
|
نتایج ({{ productSyncResults.length }}) — دسته: {{ productCategoryLabel }}
|
||||||
|
</h4>
|
||||||
|
<div class="crm-table-container">
|
||||||
|
<table class="crm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>نام</th>
|
||||||
|
<th>عیار</th>
|
||||||
|
<th>آخرین سند</th>
|
||||||
|
<th>وضعیت</th>
|
||||||
|
<th>عملیات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in productSyncResults" :key="r.name">
|
||||||
|
<td class="font-bold">{{ r.name }}</td>
|
||||||
|
<td class="font-mono" dir="ltr">{{ r.ayar ?? '—' }}</td>
|
||||||
|
<td>{{ r.lastSanad || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<UiBadge v-if="r.alreadyImported" variant="success">
|
||||||
|
وارد شده{{ r.productName ? ` (${r.productName})` : '' }}
|
||||||
|
</UiBadge>
|
||||||
|
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<UiButton
|
||||||
|
v-if="!r.alreadyImported"
|
||||||
|
size="sm"
|
||||||
|
:disabled="productSyncBusy"
|
||||||
|
@click="importProductOne(r.name)"
|
||||||
|
>
|
||||||
|
وارد کردن
|
||||||
|
</UiButton>
|
||||||
|
<RouterLink
|
||||||
|
v-else-if="r.productId"
|
||||||
|
:to="{ name: 'product-edit', params: { id: r.productId } }"
|
||||||
|
>
|
||||||
|
<UiButton variant="ghost" size="sm">مشاهده محصول</UiButton>
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UiCard>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
| `AccountingDocumentLog` | لاگ push سند |
|
| `AccountingDocumentLog` | لاگ push سند |
|
||||||
| `AccountingBalanceCache` | cache مانده |
|
| `AccountingBalanceCache` | cache مانده |
|
||||||
| `AccountingSyncJob` | لاگ جستجو/وارد کردن |
|
| `AccountingSyncJob` | لاگ جستجو/وارد کردن |
|
||||||
|
| `ProductAccountingLink` | لینک محصول ↔ نام کارساخته روی یک اتصال |
|
||||||
|
|
||||||
## APIهای CRM
|
## APIهای CRM
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
- دارایی: `GET .../people/:id/assets` · اسناد: `GET .../people/:id/asnad`
|
- دارایی: `GET .../people/:id/assets` · اسناد: `GET .../people/:id/asnad`
|
||||||
- ثبت سند: `POST .../people/:id/sanad`
|
- ثبت سند: `POST .../people/:id/sanad`
|
||||||
- sync دوطرفه: `POST /sync/search` · `POST /sync/import` · `POST /sync/import-bulk` (بازه کد یا لیست کدها)
|
- sync دوطرفه: `POST /sync/search` · `POST /sync/import` · `POST /sync/import-bulk` (بازه کد یا لیست کدها)
|
||||||
|
- sync محصولات/کارساخته: `POST /sync/products/search` · `POST /sync/products/import` · `POST /sync/products/import-bulk`
|
||||||
|
|
||||||
## تطبیق مشتری
|
## تطبیق مشتری
|
||||||
|
|
||||||
@@ -65,11 +67,23 @@
|
|||||||
|
|
||||||
مانده و اسناد بعد از لینک، در پروفایل شخص → تب **دارایی** دیده میشود.
|
مانده و اسناد بعد از لینک، در پروفایل شخص → تب **دارایی** دیده میشود.
|
||||||
|
|
||||||
|
## وارد کردن کارساخته (محصولات)
|
||||||
|
|
||||||
|
از **تنظیمات → تهحساب → وارد کردن کارساخته**:
|
||||||
|
|
||||||
|
1. اتصال را انتخاب و «دریافت لیست» بزنید (`DoListNameKarsakhte`).
|
||||||
|
2. تکی، نتایج فیلترشده، یا همه را وارد کنید.
|
||||||
|
|
||||||
|
هر نام بهعنوان `Product` با دستهٔ **کارساخته** ساخته و در `ProductAccountingLink` لینک میشود (عیار در تگ/توضیح). موجودی همچنان منبع حقیقت تهحساب است؛ این فقط کاتالوگ CRM برای معامله/فاکتور است.
|
||||||
|
|
||||||
|
در کسبوکارهای غیرطلا ممکن است همان لیست API با عنوان دیگری شناخته شود؛ فعلاً برچسب CRM «کارساخته» است.
|
||||||
|
|
||||||
## چکلیست تست دستی
|
## چکلیست تست دستی
|
||||||
|
|
||||||
- [ ] افزودن اتصال + تست health
|
- [ ] افزودن اتصال + تست health
|
||||||
- [ ] افزودن اتصال دوم با DB دیگر؛ انتخاب پیشفرض
|
- [ ] افزودن اتصال دوم با DB دیگر؛ انتخاب پیشفرض
|
||||||
- [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص
|
- [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص
|
||||||
|
- [ ] دریافت لیست کارساخته + وارد کردن تکی/دستهای به محصولات
|
||||||
- [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید
|
- [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید
|
||||||
- [ ] نمایش مانده و جزئیات در تب دارایی
|
- [ ] نمایش مانده و جزئیات در تب دارایی
|
||||||
- [ ] لیست اسناد با بازه شمسی
|
- [ ] لیست اسناد با بازه شمسی
|
||||||
|
|||||||
Reference in New Issue
Block a user