diff --git a/apps/api/prisma/migrations/20261024120000_tahesab_invoice_settings/migration.sql b/apps/api/prisma/migrations/20261024120000_tahesab_invoice_settings/migration.sql new file mode 100644 index 0000000..732b7fb --- /dev/null +++ b/apps/api/prisma/migrations/20261024120000_tahesab_invoice_settings/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "TahesabInvoiceSettings" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "logoDataUrl" TEXT, + "companyName" TEXT NOT NULL DEFAULT '', + "companySubtitle" TEXT NOT NULL DEFAULT '', + "address" TEXT NOT NULL DEFAULT '', + "phone" TEXT NOT NULL DEFAULT '', + "headerText" TEXT NOT NULL DEFAULT '', + "footerText" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TahesabInvoiceSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TahesabInvoiceSettings_organizationId_key" ON "TahesabInvoiceSettings"("organizationId"); + +-- AddForeignKey +ALTER TABLE "TahesabInvoiceSettings" ADD CONSTRAINT "TahesabInvoiceSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4471b18..939272f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -197,6 +197,7 @@ model Organization { accountingConnections AccountingConnection[] accountingSyncJobs AccountingSyncJob[] accountingAsnadLines AccountingAsnadLine[] + tahesabInvoiceSettings TahesabInvoiceSettings? warehouses Warehouse[] stockMovements StockMovement[] partyOpenings PartyOpeningBalance[] @@ -1910,6 +1911,23 @@ model AccountingSyncJob { @@index([organizationId, createdAt]) } +/// تنظیمات ظاهر فاکتور PDF اسناد ته‌حساب (جدا از فاکتور CRM) +model TahesabInvoiceSettings { + id String @id @default(cuid()) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + /// data URL تصویر لوگو (مثلاً data:image/png;base64,...) + logoDataUrl String? @db.Text + companyName String @default("") + companySubtitle String @default("") + address String @default("") + phone String @default("") + headerText String @default("") + footerText String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + /// انبار داخلی — وقتی ته‌حساب وصل نیست model Warehouse { id String @id @default(cuid()) diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 2c4d857..11aff5c 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -449,6 +449,42 @@ export class SyncAsnadReportDto { to!: string; } +export class UpdateTahesabInvoiceSettingsDto { + /** null = حذف لوگو */ + @IsOptional() + logoDataUrl?: string | null; + + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + companySubtitle?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + address?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + headerText?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + footerText?: string; +} + // silence unused import warnings for ValidateNested/Type if unused void ValidateNested; void Type; diff --git a/apps/api/src/integrations/tahesab/invoice-settings.service.ts b/apps/api/src/integrations/tahesab/invoice-settings.service.ts new file mode 100644 index 0000000..ccd3b64 --- /dev/null +++ b/apps/api/src/integrations/tahesab/invoice-settings.service.ts @@ -0,0 +1,111 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import type { + TahesabInvoiceSettingsDto, + UpdateTahesabInvoiceSettingsRequest, +} from '@mento/shared'; +import { PrismaService } from '../../prisma/prisma.service'; + +const MAX_LOGO_CHARS = 700_000; // ~500KB data URL + +function toDto(row: { + id: string; + organizationId: string; + logoDataUrl: string | null; + companyName: string; + companySubtitle: string; + address: string; + phone: string; + headerText: string; + footerText: string; + updatedAt: Date; +}): TahesabInvoiceSettingsDto { + return { + id: row.id, + organizationId: row.organizationId, + logoDataUrl: row.logoDataUrl, + companyName: row.companyName, + companySubtitle: row.companySubtitle, + address: row.address, + phone: row.phone, + headerText: row.headerText, + footerText: row.footerText, + updatedAt: row.updatedAt.toISOString(), + }; +} + +@Injectable() +export class TahesabInvoiceSettingsService { + constructor(private readonly prisma: PrismaService) {} + + async getOrCreate(organizationId: string): Promise { + const existing = await this.prisma.tahesabInvoiceSettings.findUnique({ + where: { organizationId }, + }); + if (existing) return toDto(existing); + + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + const created = await this.prisma.tahesabInvoiceSettings.create({ + data: { + organizationId, + companyName: org?.name ?? '', + }, + }); + return toDto(created); + } + + async update( + organizationId: string, + dto: UpdateTahesabInvoiceSettingsRequest, + ): Promise { + if (dto.logoDataUrl != null && dto.logoDataUrl.length > MAX_LOGO_CHARS) { + throw new BadRequestException( + 'حجم لوگو زیاد است؛ تصویر کوچک‌تری انتخاب کنید.', + ); + } + if ( + dto.logoDataUrl != null && + dto.logoDataUrl !== '' && + !/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dto.logoDataUrl) + ) { + throw new BadRequestException('لوگو باید تصویر با فرمت data URL باشد.'); + } + + const data = { + ...(dto.logoDataUrl !== undefined + ? { logoDataUrl: dto.logoDataUrl?.trim() || null } + : {}), + ...(dto.companyName !== undefined + ? { companyName: dto.companyName.trim().slice(0, 200) } + : {}), + ...(dto.companySubtitle !== undefined + ? { companySubtitle: dto.companySubtitle.trim().slice(0, 200) } + : {}), + ...(dto.address !== undefined + ? { address: dto.address.trim().slice(0, 500) } + : {}), + ...(dto.phone !== undefined + ? { phone: dto.phone.trim().slice(0, 80) } + : {}), + ...(dto.headerText !== undefined + ? { headerText: dto.headerText.trim().slice(0, 1000) } + : {}), + ...(dto.footerText !== undefined + ? { footerText: dto.footerText.trim().slice(0, 1000) } + : {}), + }; + + const row = await this.prisma.tahesabInvoiceSettings.upsert({ + where: { organizationId }, + create: { + organizationId, + companyName: '', + ...data, + }, + update: data, + }); + return toDto(row); + } +} diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index d46b25f..2a9c232 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -22,6 +22,7 @@ import { TahesabProductLinkService } from './product-link.service'; import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; import { TahesabAsnadReportService } from './asnad-report.service'; +import { TahesabInvoiceSettingsService } from './invoice-settings.service'; import { CreateInTahesabDto, CreateTahesabConnectionDto, @@ -38,6 +39,7 @@ import { SyncAsnadReportDto, SyncGroupsPageDto, UpdateTahesabConnectionDto, + UpdateTahesabInvoiceSettingsDto, } from './dto'; @Controller('integrations/tahesab') @@ -51,6 +53,7 @@ export class TahesabController { private readonly balance: TahesabBalanceService, private readonly documents: TahesabDocumentsService, private readonly asnadReport: TahesabAsnadReportService, + private readonly invoiceSettings: TahesabInvoiceSettingsService, ) {} // —— Connections —— @@ -279,6 +282,21 @@ export class TahesabController { return this.products.importBulk(user.organizationId, dto); } + // —— تنظیمات فاکتور PDF اسناد ته‌حساب —— + @Get('invoice-settings') + getInvoiceSettings(@CurrentUser() user: { organizationId: string }) { + return this.invoiceSettings.getOrCreate(user.organizationId); + } + + @Patch('invoice-settings') + @Roles('owner', 'admin') + updateInvoiceSettings( + @CurrentUser() user: { organizationId: string }, + @Body() dto: UpdateTahesabInvoiceSettingsDto, + ) { + return this.invoiceSettings.update(user.organizationId, dto); + } + // —— گزارش فروش اسناد (اسنپ‌شات DoListAsnad) —— @Post('reports/asnad/sync') @Roles('owner', 'admin', 'sales') diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index 31963dc..f1e66be 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -11,6 +11,7 @@ import { TahesabProductLinkService } from './product-link.service'; import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; import { TahesabAsnadReportService } from './asnad-report.service'; +import { TahesabInvoiceSettingsService } from './invoice-settings.service'; import { TahesabController } from './tahesab.controller'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; @@ -27,6 +28,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu TahesabBalanceService, TahesabDocumentsService, TahesabAsnadReportService, + TahesabInvoiceSettingsService, ], exports: [ TahesabCryptoService, @@ -36,6 +38,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu TahesabBalanceService, TahesabDocumentsService, TahesabAsnadReportService, + TahesabInvoiceSettingsService, ], }) export class TahesabModule {} diff --git a/apps/web/src/components/person-assets/PersonAssetsPanel.vue b/apps/web/src/components/person-assets/PersonAssetsPanel.vue index 747c4a8..77092cf 100644 --- a/apps/web/src/components/person-assets/PersonAssetsPanel.vue +++ b/apps/web/src/components/person-assets/PersonAssetsPanel.vue @@ -15,12 +15,17 @@ import type { PersonAccountingLinkDto, PersonAssetsDto, TahesabConnectionDto, + TahesabInvoiceSettingsDto, TahesabSanadType, } from '@mento/shared'; import { TAHSAB_SANAD_LABELS } from '@/lib/tahesab'; +import { printTahesabInvoicePdf } from '@/lib/tahesab-invoice-pdf'; +import TahesabInvoiceSettingsModal from '@/components/person-assets/TahesabInvoiceSettingsModal.vue'; const props = defineProps<{ personId: string; + /** نام نمایشی مشتری برای فاکتور PDF */ + personName?: string; }>(); const emit = defineEmits<{ @@ -37,6 +42,8 @@ const assets = ref(null); const activeTab = ref<'مانده حساب' | 'جزئیات حساب' | 'ثبت سند'>('مانده حساب'); const asnadLoading = ref(false); const asnadItems = ref[]>([]); +const invoiceSettingsOpen = ref(false); +const invoiceSettings = ref(null); /** مدل JalaliDateInput = تاریخ محلی میلادی YYYY-MM-DD */ const fromDate = ref(''); const toDate = ref(''); @@ -383,6 +390,38 @@ async function refreshAssets() { } } +async function loadInvoiceSettings() { + try { + const { data } = await api.get( + '/integrations/tahesab/invoice-settings', + ); + invoiceSettings.value = data; + } catch { + invoiceSettings.value = null; + } +} + +function onInvoiceSettingsSaved(dto: TahesabInvoiceSettingsDto) { + invoiceSettings.value = dto; +} + +async function exportAsnadPdf(g: { + no: string; + date: string; + rows: Record[]; +}) { + error.value = ''; + if (!invoiceSettings.value) { + await loadInvoiceSettings(); + } + printTahesabInvoicePdf({ + group: g, + settings: invoiceSettings.value, + customerName: props.personName?.trim() || 'مشتری', + moshtariCode: currentLink.value?.moshtariCode ?? null, + }); +} + async function loadAsnad() { if (!connectionId.value || !currentLink.value) return; const from = toShamsiYmd(fromDate.value); @@ -394,22 +433,26 @@ async function loadAsnad() { asnadLoading.value = true; error.value = ''; try { - const { data } = await api.get<{ items: Record[] }>( - `/integrations/tahesab/people/${props.personId}/asnad`, - { - params: { - connectionId: connectionId.value, - from, - to, + const [{ data }] = await Promise.all([ + api.get<{ items: Record[] }>( + `/integrations/tahesab/people/${props.personId}/asnad`, + { + params: { + connectionId: connectionId.value, + from, + to, + }, + timeout: 60_000, }, - timeout: 60_000, - }, - ); + ), + loadInvoiceSettings(), + ]); asnadItems.value = data.items ?? []; ok.value = asnadItems.value.length ? `${asnadItems.value.length} سند نمایش داده شد.` : 'در این بازه سندی یافت نشد.'; } catch (e) { + asnadItems.value = []; error.value = axios.isAxiosError(e) ? String( (e.response?.data as { message?: string })?.message ?? @@ -715,6 +758,9 @@ onMounted(async () => { +
@@ -748,6 +794,14 @@ onMounted(async () => { سند شماره {{ showRaw(g.no) }} موقت {{ g.rows.length.toLocaleString('fa-IR') }} ردیف +
@@ -954,6 +1008,11 @@ onMounted(async () => { + +
@@ -1195,9 +1254,15 @@ onMounted(async () => { .inv-title { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.5rem; font-size: 0.95rem; } +.inv-pdf-btn { + margin-inline-start: auto; + font-size: 0.8rem; + padding: 0.3rem 0.65rem; +} .inv-badge { font-size: 0.72rem; padding: 0.1rem 0.45rem; diff --git a/apps/web/src/components/person-assets/TahesabInvoiceSettingsModal.vue b/apps/web/src/components/person-assets/TahesabInvoiceSettingsModal.vue new file mode 100644 index 0000000..1a7f9ac --- /dev/null +++ b/apps/web/src/components/person-assets/TahesabInvoiceSettingsModal.vue @@ -0,0 +1,305 @@ + + +