diff --git a/apps/api/prisma/migrations/20260921210000_product_accounting_link/migration.sql b/apps/api/prisma/migrations/20260921210000_product_accounting_link/migration.sql new file mode 100644 index 0000000..583e246 --- /dev/null +++ b/apps/api/prisma/migrations/20260921210000_product_accounting_link/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0fbaf04..040cfa6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 7c2dd91..62bb2ef 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -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() diff --git a/apps/api/src/integrations/tahesab/product-link.service.ts b/apps/api/src/integrations/tahesab/product-link.service.ts new file mode 100644 index 0000000..c369b3d --- /dev/null +++ b/apps/api/src/integrations/tahesab/product-link.service.ts @@ -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> { + if (!raw) return []; + if (Array.isArray(raw)) return raw as Array>; + if (typeof raw === 'object') { + const vals = Object.values(raw as Record); + if (vals.every((v) => v && typeof v === 'object')) { + return vals as Array>; + } + } + return []; +} + +function pickKarName(row: Record): string { + return String(row.Name ?? row.name ?? row.Name_Kar ?? '').trim(); +} + +function pickAyar(row: Record): number | null { + const n = Number(row.Ayar ?? row.ayar); + return Number.isFinite(n) ? Math.round(n) : null; +} + +function pickVaznSabet(row: Record): number | null { + const n = Number(row.VaznSabet ?? row.vaznSabet); + return Number.isFinite(n) ? n : null; +} + +function pickLastSanad(row: Record): 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 => Boolean(r)); + + // یکتا بر اساس نام (آخرین ردیف برنده) + const byName = new Map(); + 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 }; + } +} diff --git a/apps/api/src/integrations/tahesab/tahesab-methods.ts b/apps/api/src/integrations/tahesab/tahesab-methods.ts index c72db02..6f16c7d 100644 --- a/apps/api/src/integrations/tahesab/tahesab-methods.ts +++ b/apps/api/src/integrations/tahesab/tahesab-methods.ts @@ -261,6 +261,11 @@ export class TahesabApi { return this.call('DoListNameSekeh', 'DoListNameSekeh', []); } + /** لیست نام کارهای ساخته دارای سند */ + doListNameKarsakhte() { + return this.call('DoListNameKarsakhte', 'DoListNameKarsakhte', []); + } + doListNameArz() { return this.call('DoListNameARZ', 'DoListNameARZ', []); } diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index 47a0065..5d09d6a 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -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); + } } diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index d5b85fb..73b2835 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -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, ], diff --git a/apps/web/src/views/ProductFormView.vue b/apps/web/src/views/ProductFormView.vue index 5e0e395..43d5e52 100644 --- a/apps/web/src/views/ProductFormView.vue +++ b/apps/web/src/views/ProductFormView.vue @@ -23,6 +23,7 @@ import type { } from '@mento/shared'; const CATEGORIES = [ + 'کارساخته', 'طلا و جواهر', 'سکه', 'خدمات', diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index e530cfc..8f0eee8 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -56,12 +56,35 @@ type SyncResult = { }; const syncResults = ref([]); +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([]); +const productCategoryLabel = ref('کارساخته'); + function pendingImportCodes() { return syncResults.value .filter((r) => r.moshtariCode != null && !r.alreadyLinked) .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) { if (!axios.isAxiosError(e)) return fallback; const msg = (e.response?.data as { message?: string | string[] })?.message; @@ -117,6 +140,7 @@ async function load() { items.value = data; if (!editingId.value) form.isDefault = data.length === 0; if (!sync.connectionId && data[0]) sync.connectionId = data[0].id; + if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id; } catch (e) { error.value = axios.isAxiosError(e) ? 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 = { + 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 = { 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); @@ -636,6 +761,106 @@ onMounted(load); + + +

+ نام‌های کارساختهٔ ته‌حساب (لیست DoListNameKarsakhte) + را به‌عنوان محصول با دستهٔ «{{ productCategoryLabel }}» وارد کنید. + موجودی همچنان از ته‌حساب است؛ اینجا فقط کاتالوگ برای معامله/فاکتور ساخته می‌شود. +

+ +
+ + + + + + + +
+

لیست و جستجو

+
+ +
+ + دریافت لیست + + + ورود نتایج جدید ({{ pendingProductNames().length }}) + + + ورود همه + +
+
+
+ +
+

+ نتایج ({{ productSyncResults.length }}) — دسته: {{ productCategoryLabel }} +

+
+ + + + + + + + + + + + + + + + + + + +
نامعیارآخرین سندوضعیتعملیات
{{ r.name }}{{ r.ayar ?? '—' }}{{ r.lastSanad || '—' }} + + وارد شده{{ r.productName ? ` (${r.productName})` : '' }} + + آماده ورود + + + وارد کردن + + + مشاهده محصول + +
+
+
+
+
diff --git a/docs/integrations/tahesab.md b/docs/integrations/tahesab.md index a857ae8..e2044d0 100644 --- a/docs/integrations/tahesab.md +++ b/docs/integrations/tahesab.md @@ -18,6 +18,7 @@ | `AccountingDocumentLog` | لاگ push سند | | `AccountingBalanceCache` | cache مانده | | `AccountingSyncJob` | لاگ جستجو/وارد کردن | +| `ProductAccountingLink` | لینک محصول ↔ نام کارساخته روی یک اتصال | ## APIهای CRM @@ -30,6 +31,7 @@ - دارایی: `GET .../people/:id/assets` · اسناد: `GET .../people/:id/asnad` - ثبت سند: `POST .../people/:id/sanad` - 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 - [ ] افزودن اتصال دوم با DB دیگر؛ انتخاب پیش‌فرض - [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص +- [ ] دریافت لیست کارساخته + وارد کردن تکی/دسته‌ای به محصولات - [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید - [ ] نمایش مانده و جزئیات در تب دارایی - [ ] لیست اسناد با بازه شمسی