diff --git a/apps/api/prisma/migrations/20261105140000_tahesab_customer_sync_hours/migration.sql b/apps/api/prisma/migrations/20261105140000_tahesab_customer_sync_hours/migration.sql new file mode 100644 index 0000000..589dd1f --- /dev/null +++ b/apps/api/prisma/migrations/20261105140000_tahesab_customer_sync_hours/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "AccountingConnection" + ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[], + ADD COLUMN "customerSyncLastSlot" TEXT, + ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3), + ADD COLUMN "customerSyncLastSummary" TEXT, + ADD COLUMN "customerSyncLastError" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5e5b710..132d552 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1843,6 +1843,13 @@ model AccountingConnection { lastHealthAt DateTime? lastHealthOk Boolean? lastError String? + /// ساعت‌های بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش. + customerSyncHours Int[] @default([]) + /// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود + customerSyncLastSlot String? + customerSyncLastRunAt DateTime? + customerSyncLastSummary String? + customerSyncLastError String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt personLinks PersonAccountingLink[] diff --git a/apps/api/src/integrations/tahesab/connections.service.ts b/apps/api/src/integrations/tahesab/connections.service.ts index ed02bd2..2a6a92a 100644 --- a/apps/api/src/integrations/tahesab/connections.service.ts +++ b/apps/api/src/integrations/tahesab/connections.service.ts @@ -43,6 +43,10 @@ export class TahesabConnectionsService { lastHealthAt: Date | null; lastHealthOk: boolean | null; lastError: string | null; + customerSyncHours: number[]; + customerSyncLastRunAt: Date | null; + customerSyncLastSummary: string | null; + customerSyncLastError: string | null; createdAt: Date; updatedAt: Date; }) { @@ -60,6 +64,10 @@ export class TahesabConnectionsService { lastHealthAt: row.lastHealthAt?.toISOString() ?? null, lastHealthOk: row.lastHealthOk, lastError: row.lastError, + customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b), + customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null, + customerSyncLastSummary: row.customerSyncLastSummary, + customerSyncLastError: row.customerSyncLastError, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; @@ -201,6 +209,15 @@ export class TahesabConnectionsService { if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id); + let customerSyncHours: number[] | undefined; + if (dto.customerSyncHours !== undefined) { + const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour)); + if (new Set(hours).size !== hours.length) { + throw new BadRequestException('ساعت‌های بروزرسانی نباید تکراری باشند.'); + } + customerSyncHours = [...hours].sort((a, b) => a - b); + } + let enc = { ciphertext: row.tokenCiphertext, iv: row.tokenIv, @@ -225,6 +242,7 @@ export class TahesabConnectionsService { enabled: dto.enabled ?? row.enabled, isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault, allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls, + ...(customerSyncHours !== undefined ? { customerSyncHours } : {}), }, }); return this.toDto(updated); diff --git a/apps/api/src/integrations/tahesab/customer-sync.scheduler.ts b/apps/api/src/integrations/tahesab/customer-sync.scheduler.ts new file mode 100644 index 0000000..836d17e --- /dev/null +++ b/apps/api/src/integrations/tahesab/customer-sync.scheduler.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TahesabPersonLinkService } from './person-link.service'; + +/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یک‌بار در هر ساعت. */ +export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23', + }).formatToParts(now); + const read = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ''; + let hour = Number(read('hour')); + if (!Number.isFinite(hour) || hour === 24) hour = 0; + const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`; + return { hour, slot }; +} + +@Injectable() +export class TahesabCustomerSyncScheduler { + private readonly logger = new Logger(TahesabCustomerSyncScheduler.name); + private busy = false; + + constructor( + private readonly prisma: PrismaService, + private readonly links: TahesabPersonLinkService, + ) {} + + @Cron('* * * * *', { timeZone: 'Asia/Tehran' }) + async tick() { + if (process.env.NODE_ENV === 'test' || this.busy) return; + this.busy = true; + try { + const { hour, slot } = tehranSyncSlot(); + const connections = await this.prisma.accountingConnection.findMany({ + where: { + provider: 'tahesab', + enabled: true, + customerSyncHours: { has: hour }, + }, + select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true }, + }); + for (const connection of connections) { + if (connection.customerSyncLastSlot === slot) continue; + const claimed = await this.prisma.accountingConnection.updateMany({ + where: { + id: connection.id, + OR: [ + { customerSyncLastSlot: null }, + { customerSyncLastSlot: { not: slot } }, + ], + }, + data: { customerSyncLastSlot: slot }, + }); + if (claimed.count !== 1) continue; + await this.run(connection.organizationId, connection.id, connection.label); + } + } catch (err) { + this.logger.warn( + `بررسی زمان‌بندی مشتریان ته‌حساب ناموفق بود: ${ + err instanceof Error ? err.message : err + }`, + ); + } finally { + this.busy = false; + } + } + + private async run(organizationId: string, connectionId: string, label: string) { + try { + const result = await this.links.syncAllCustomers(organizationId, connectionId); + await this.prisma.accountingConnection.update({ + where: { id: connectionId }, + data: { + customerSyncLastRunAt: new Date(), + customerSyncLastSummary: result.summary, + customerSyncLastError: result.error, + }, + }); + this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`); + } catch (err) { + const message = (err instanceof Error ? err.message : 'همگام‌سازی ناموفق بود').slice(0, 500); + await this.prisma.accountingConnection.update({ + where: { id: connectionId }, + data: { + customerSyncLastRunAt: new Date(), + customerSyncLastSummary: null, + customerSyncLastError: message, + }, + }); + this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`); + } + } +} diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 17214b7..5816305 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto { @IsOptional() @IsBoolean() allowInsecureTls?: boolean; + + /** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */ + @IsOptional() + @IsArray() + @ArrayMaxSize(6) + @Type(() => Number) + @IsInt({ each: true }) + @Min(0, { each: true }) + @Max(23, { each: true }) + customerSyncHours?: number[]; } export class ManualLinkDto { diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 94b5003..7cdda80 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; -import { carryPreviousSource } from '../../contacts/person-source'; +import { carryPreviousSource, namesDiffer } from '../../contacts/person-source'; import { canonicalIranMobile, phoneIdentityKeys, @@ -558,6 +558,7 @@ export class TahesabPersonLinkService { organizationId: string, dto: ImportFromTahesabDto, prefetched?: { api: TahesabApi; row?: Record }, + options?: { skipJob?: boolean }, ) { const clash = await this.prisma.personAccountingLink.findFirst({ where: { @@ -711,18 +712,169 @@ export class TahesabPersonLinkService { }); await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); + if (!options?.skipJob) { + await this.prisma.accountingSyncJob.create({ + data: { + organizationId, + connectionId: dto.connectionId, + kind: 'import', + status: 'success', + request: dto as unknown as Prisma.InputJsonValue, + result: { personId, linkId: link.id } as Prisma.InputJsonValue, + }, + }); + } + + return { personId, link: this.linkDto(link) }; + } + + /** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */ + private async refreshLinkedCustomer( + organizationId: string, + connectionId: string, + personId: string, + moshtariCode: number, + row: Record, + ) { + const existing = await this.getPerson(organizationId, personId); + const picked = pickName(row).trim(); + const named = picked + ? applyPersonGenderTitle({ firstName: picked, lastName: '' }) + : null; + const nameChanged = + named != null && + namesDiffer( + existing.firstName, + existing.lastName, + named.firstName, + named.lastName, + ); + await this.prisma.person.update({ + where: { id: personId }, + data: { + ...(named + ? { + firstName: named.firstName, + lastName: named.lastName, + gender: named.gender, + showGenderInName: named.showGenderInName, + } + : {}), + ...(nameChanged + ? { + previousFirstName: existing.firstName || null, + previousLastName: existing.lastName || null, + } + : {}), + ...carryPreviousSource(existing, 'tahesab'), + importSource: 'tahesab', + sourceRef: String(moshtariCode), + ...(!existing.birthDate && pickBirthDate(row) + ? { birthDate: pickBirthDate(row) } + : {}), + ...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing), + }, + }); + await this.applyTahesabGroup(organizationId, connectionId, personId, row); + } + + /** + * همهٔ مشتریان ته‌حساب را صفحه به صفحه می‌خواند. + * کد تازه وارد می‌شود؛ کد لینک‌شده نام و شماره و گروهش تازه می‌شود. + */ + async syncAllCustomers(organizationId: string, connectionId: string) { + const { api } = await this.connections.getApi(organizationId, connectionId); + let page = 1; + let emptyStreak = 0; + let imported = 0; + let updated = 0; + let failed = 0; + const errors: string[] = []; + const maxPages = 300; + + while (emptyStreak < 2 && page <= maxPages) { + let listed: Awaited>; + try { + listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE); + } catch (err) { + const message = err instanceof Error ? err.message : 'خواندن ته‌حساب ناموفق بود'; + errors.push(message); + break; + } + if (!listed.rows.length) { + emptyStreak += 1; + page += 1; + continue; + } + emptyStreak = 0; + const rowsByCode = new Map>(); + for (const row of listed.rows) { + const code = pickCode(row); + if (code != null && code >= 1) rowsByCode.set(code, row); + } + const links = rowsByCode.size + ? await this.prisma.personAccountingLink.findMany({ + where: { + connectionId, + moshtariCode: { in: [...rowsByCode.keys()] }, + }, + select: { moshtariCode: true, personId: true }, + }) + : []; + const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId])); + for (const [code, row] of rowsByCode) { + try { + const personId = linkedByCode.get(code); + if (personId) { + await this.refreshLinkedCustomer( + organizationId, + connectionId, + personId, + code, + row, + ); + updated += 1; + } else { + await this.importFromTahesab( + organizationId, + { connectionId, moshtariCode: code }, + { api, row }, + { skipJob: true }, + ); + imported += 1; + } + } catch (err) { + failed += 1; + if (errors.length < 5) { + errors.push(err instanceof Error ? err.message : 'خطا'); + } + } + } + page += 1; + } + + if (page > maxPages && emptyStreak < 2) { + errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.'); + } + + const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`; await this.prisma.accountingSyncJob.create({ data: { organizationId, - connectionId: dto.connectionId, - kind: 'import', - status: 'success', - request: dto as unknown as Prisma.InputJsonValue, - result: { personId, linkId: link.id } as Prisma.InputJsonValue, + connectionId, + kind: 'customer-sync', + status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success', + result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue, }, }); - - return { personId, link: this.linkDto(link) }; + this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`); + return { + imported, + updated, + failed, + summary, + error: errors.length ? errors.join('؛ ') : null, + }; } async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index f1e66be..eb04243 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; import { TahesabAsnadReportService } from './asnad-report.service'; import { TahesabInvoiceSettingsService } from './invoice-settings.service'; +import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler'; import { TahesabController } from './tahesab.controller'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; @@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu TahesabDocumentsService, TahesabAsnadReportService, TahesabInvoiceSettingsService, + TahesabCustomerSyncScheduler, ], exports: [ TahesabCryptoService, diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index f8cb47f..467f379 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -10,7 +10,7 @@ import type { UpdateTahesabConnectionRequest, } from '@mento/shared'; import { brand } from '@mento/shared'; -import { toFaDigits } from '@/lib/jalali'; +import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali'; import { UiBadge, UiBusyOverlay, @@ -74,6 +74,77 @@ const syncTablePageSize = ref(50); const syncTablePage = ref(1); const syncOnlyNew = ref(false); +const autoCount = ref('0'); +const autoHours = ref([]); +const autoSyncSaving = ref(false); +const AUTO_HOUR_PRESETS: Record = { + 1: [8], + 2: [8, 14], + 3: [8, 12, 16], + 4: [8, 12, 16, 20], + 5: [8, 11, 14, 17, 20], + 6: [8, 10, 12, 14, 16, 20], +}; + +const selectedConnection = computed(() => + items.value.find((row) => row.id === sync.connectionId), +); + +function hourLabel(hour: number) { + return `${toFaDigits(String(hour).padStart(2, '0'))}:۰۰`; +} + +function fillAutoSync() { + const hours = [...(selectedConnection.value?.customerSyncHours ?? [])] + .sort((a, b) => a - b) + .slice(0, 6); + autoCount.value = String(hours.length); + autoHours.value = hours.map(String); +} + +function onAutoCount(value: string) { + const count = Math.min(6, Math.max(0, Number(value) || 0)); + const preset = AUTO_HOUR_PRESETS[count] ?? []; + const next = autoHours.value.slice(0, count); + while (next.length < count) { + next.push(String(preset[next.length] ?? 8)); + } + autoCount.value = String(count); + autoHours.value = next; +} + +function setAutoHour(index: number, value: string) { + autoHours.value[index] = value; +} + +async function saveAutoSync() { + if (!sync.connectionId) return; + const hours = autoHours.value.map((hour) => Number(hour)); + if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) { + error.value = 'ساعت باید بین ۰ و ۲۳ باشد.'; + return; + } + if (new Set(hours).size !== hours.length) { + error.value = 'ساعت‌ها نباید تکراری باشند.'; + return; + } + autoSyncSaving.value = true; + error.value = ''; + ok.value = ''; + try { + await api.patch(`/integrations/tahesab/connections/${sync.connectionId}`, { + customerSyncHours: hours, + }); + ok.value = hours.length + ? 'زمان‌بندی بروزرسانی ذخیره شد.' + : 'بروزرسانی خودکار خاموش شد.'; + await load(); + } catch (e) { + error.value = apiErr(e, 'ذخیره زمان‌بندی ناموفق بود'); + } finally { + autoSyncSaving.value = false; + } +} const syncVisibleResults = computed(() => syncOnlyNew.value ? syncResults.value.filter((r) => !r.alreadyLinked) @@ -86,6 +157,7 @@ const syncPagedResults = computed(() => { const start = (syncTablePage.value - 1) * syncTablePageSize.value; return syncVisibleResults.value.slice(start, start + syncTablePageSize.value); }); +watch(() => sync.connectionId, () => fillAutoSync()); watch([syncOnlyNew, syncTablePageSize], () => { syncTablePage.value = 1; }); @@ -178,6 +250,7 @@ async function load() { 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; + fillAutoSync(); } catch (e) { error.value = axios.isAxiosError(e) ? String( @@ -875,6 +948,63 @@ onMounted(load); +
+

بروزرسانی خودکار

+

+ تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعت‌ها، به وقت تهران، + مشتریان تازه را وارد می‌کند و نام مشتری‌های قبلی را هم با ته‌حساب تازه می‌کند. اگر شماره یکی باشد + همان مشتری می‌ماند، نام قبلی حفظ می‌شود و شماره به شکل ۰۹۱۲ ذخیره می‌شود. خاموش یعنی فقط ورود دستی. +

+ + + + + + +
+ + + + + +
+
+ + ذخیره زمان‌بندی + +
+

+ آخرین اجرا: + {{ formatJalaliDateTime(new Date(selectedConnection.customerSyncLastRunAt)) }} + +

+

+ آخرین خطا: {{ selectedConnection.customerSyncLastError }} +

+
+

۱) وارد کردن دسته‌ای با بازه کد مشتری

diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2a88650..182f11d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4287,6 +4287,11 @@ export interface TahesabConnectionDto { lastHealthAt: string | null; lastHealthOk: boolean | null; lastError: string | null; + /** ساعت‌های ۰ تا ۲۳ به وقت تهران. خالی یعنی بروزرسانی خودکار خاموش است. */ + customerSyncHours: number[]; + customerSyncLastRunAt: string | null; + customerSyncLastSummary: string | null; + customerSyncLastError: string | null; createdAt: string; updatedAt: string; } @@ -4301,7 +4306,11 @@ export interface CreateTahesabConnectionRequest { allowInsecureTls?: boolean; } -export type UpdateTahesabConnectionRequest = Partial; +export interface UpdateTahesabConnectionRequest + extends Partial { + /** آرایهٔ خالی بروزرسانی خودکار را خاموش می‌کند. */ + customerSyncHours?: number[]; +} export interface PersonAccountingLinkDto { id: string;