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[]
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
|
||||
const CATEGORIES = [
|
||||
'کارساخته',
|
||||
'طلا و جواهر',
|
||||
'سکه',
|
||||
'خدمات',
|
||||
|
||||
@@ -56,12 +56,35 @@ type 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() {
|
||||
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<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);
|
||||
</script>
|
||||
|
||||
@@ -636,6 +761,106 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 دیگر؛ انتخاب پیشفرض
|
||||
- [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص
|
||||
- [ ] دریافت لیست کارساخته + وارد کردن تکی/دستهای به محصولات
|
||||
- [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید
|
||||
- [ ] نمایش مانده و جزئیات در تب دارایی
|
||||
- [ ] لیست اسناد با بازه شمسی
|
||||
|
||||
Reference in New Issue
Block a user