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[] 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
+37
View File
@@ -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,
], ],
+1
View File
@@ -23,6 +23,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
const CATEGORIES = [ const CATEGORIES = [
'کارساخته',
'طلا و جواهر', 'طلا و جواهر',
'سکه', 'سکه',
'خدمات', 'خدمات',
+225
View File
@@ -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>
+14
View File
@@ -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 دیگر؛ انتخاب پیش‌فرض
- [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص - [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص
- [ ] دریافت لیست کارساخته + وارد کردن تکی/دسته‌ای به محصولات
- [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید - [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید
- [ ] نمایش مانده و جزئیات در تب دارایی - [ ] نمایش مانده و جزئیات در تب دارایی
- [ ] لیست اسناد با بازه شمسی - [ ] لیست اسناد با بازه شمسی