From d71c2dbef4dc9d4cf0eefc09dc9e7dafd54b9373 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 4 Nov 2026 11:36:55 +0330 Subject: [PATCH 01/12] feat(treasury): show who still owes on the finance pages Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens. Co-authored-by: Cursor --- .../migration.sql | 9 + apps/api/prisma/schema.prisma | 10 + apps/api/src/treasury/treasury.controller.ts | 23 ++ apps/api/src/treasury/treasury.module.ts | 3 +- apps/api/src/treasury/treasury.service.ts | 216 ++++++++++++++- .../treasury/TreasuryPartyBalanceTable.vue | 52 ++++ .../treasury/TreasuryPartySelect.vue | 262 ++++++++++++++++++ .../components/treasury/useSettleDefault.ts | 52 ++++ .../views/treasury/TreasuryExpensesView.vue | 65 ++++- .../views/treasury/TreasuryIncomesView.vue | 77 +++-- packages/shared/src/index.ts | 24 ++ 11 files changed, 755 insertions(+), 38 deletions(-) create mode 100644 apps/api/prisma/migrations/20261104140000_financial_document_party/migration.sql create mode 100644 apps/web/src/components/treasury/TreasuryPartyBalanceTable.vue create mode 100644 apps/web/src/components/treasury/TreasuryPartySelect.vue create mode 100644 apps/web/src/components/treasury/useSettleDefault.ts diff --git a/apps/api/prisma/migrations/20261104140000_financial_document_party/migration.sql b/apps/api/prisma/migrations/20261104140000_financial_document_party/migration.sql new file mode 100644 index 0000000..6c9da38 --- /dev/null +++ b/apps/api/prisma/migrations/20261104140000_financial_document_party/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT; +ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT; +ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT; + +CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId"); +CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId"); + +ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3d135b0..76bb1ef 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -295,6 +295,7 @@ model User { ownedInvoices Invoice[] @relation("InvoiceOwner") ownedPeople Person[] @relation("PersonOwner") ownedCompanies Company[] @relation("CompanyOwner") + financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") activities Activity[] @relation("ActivityAssignee") assignedActivities Activity[] @relation("ActivityAssignees") @@ -524,6 +525,7 @@ model Company { customFields Json @default("{}") groupLinks CompanyGroupLink[] people Person[] + financialDocuments FinancialDocument[] deals Deal[] activities Activity[] smartNotes Note[] @@ -2330,6 +2332,12 @@ model FinancialDocument { category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + userId String? + partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull) + /// person | company | user | other + partyKind String? partyName String? accountId String? account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull) @@ -2350,6 +2358,8 @@ model FinancialDocument { @@index([organizationId, kind, bookedAt]) @@index([organizationId, status]) @@index([personId]) + @@index([companyId]) + @@index([userId]) } /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 5cfe7b3..4d96f27 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -92,6 +92,29 @@ export class TreasuryController { return this.treasury.transfer(user, body); } + @Get('parties') + parties(@CurrentUser() user: Actor, @Query('q') q?: string) { + return this.treasury.searchParties(user, q); + } + + @Post('parties/person') + createPartyPerson( + @CurrentUser() user: Actor, + @Body() body: { firstName?: string; lastName?: string }, + ) { + return this.treasury.createPartyPerson(user, body); + } + + @Post('parties/company') + createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) { + return this.treasury.createPartyCompany(user, body); + } + + @Get('party-balances') + partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) { + return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income'); + } + @Get('documents') documents( @CurrentUser() user: Actor, diff --git a/apps/api/src/treasury/treasury.module.ts b/apps/api/src/treasury/treasury.module.ts index c48cca6..f9a5fa2 100644 --- a/apps/api/src/treasury/treasury.module.ts +++ b/apps/api/src/treasury/treasury.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { ContactsModule } from '../contacts/contacts.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { TreasuryController } from './treasury.controller'; import { TreasuryAccessGuard } from './treasury-access.guard'; @@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryService } from './treasury.service'; @Module({ - imports: [AuthModule, NotificationsModule], + imports: [AuthModule, NotificationsModule, ContactsModule], controllers: [TreasuryController], providers: [ TreasuryService, diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index c546a92..5fde7ab 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -13,8 +13,13 @@ import type { FinancialCategoryDto, FinancialDocumentDto, FinancialMovementDto, + FinancialPartyKind, TreasuryDashboardDto, + TreasuryPartyBalanceDto, + TreasuryPartySearchDto, } from '@mento/shared'; +import { CompaniesService } from '../contacts/companies.service'; +import { PeopleService } from '../contacts/people.service'; import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; @@ -65,6 +70,8 @@ export class TreasuryService { private readonly receivables: TreasuryReceivablesService, private readonly files: LocalFileStorage, private readonly storage: StorageQuotaService, + private readonly people: PeopleService, + private readonly companies: CompaniesService, ) {} async ensureCategories(organizationId: string) { @@ -462,7 +469,10 @@ export class TreasuryService { bookedAt?: string; dueAt?: string | null; categoryId?: string | null; + partyKind?: FinancialPartyKind | null; personId?: string | null; + companyId?: string | null; + userId?: string | null; partyName?: string | null; method?: string | null; reference?: string | null; @@ -476,9 +486,13 @@ export class TreasuryService { const amount = dec(input.amount); if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.'); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); + const party = await this.resolveParty(actor, input); const settle = input.settleAmount == null || input.settleAmount === '' ? new Prisma.Decimal(0) : dec(input.settleAmount); + if (party.partyKind === 'other' && !settle.equals(amount)) { + throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.'); + } if (settle.isNegative() || settle.greaterThan(amount)) { throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.'); } @@ -506,8 +520,11 @@ export class TreasuryService { bookedAt, dueAt: input.dueAt ? startOfDay(input.dueAt) : null, categoryId: input.categoryId || null, - personId: input.personId || null, - partyName: input.partyName?.trim() || null, + personId: party.personId, + companyId: party.companyId, + userId: party.userId, + partyKind: party.partyKind, + partyName: party.partyName, accountId: input.accountId || null, method: input.method || null, status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', @@ -540,6 +557,195 @@ export class TreasuryService { return this.toDocumentDto(doc); } + async searchParties(actor: Actor, q?: string): Promise { + const term = q?.trim() ?? ''; + const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined; + const [people, companies, users] = await Promise.all([ + this.prisma.person.findMany({ + where: { + organizationId: actor.organizationId, + ...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}), + }, + select: { id: true, firstName: true, lastName: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, + }), + this.prisma.company.findMany({ + where: { + organizationId: actor.organizationId, + ...(contains ? { name: contains } : {}), + }, + select: { id: true, name: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, + }), + this.prisma.user.findMany({ + where: { + organizationId: actor.organizationId, + isActive: true, + ...(contains ? { fullName: contains } : {}), + }, + select: { id: true, fullName: true }, + orderBy: { fullName: 'asc' }, + take: 20, + }), + ]); + return { + people: people.map((row) => ({ + id: row.id, + name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری', + })), + companies, + users: users.map((row) => ({ id: row.id, name: row.fullName })), + }; + } + + async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) { + const firstName = input.firstName?.trim() ?? ''; + const lastName = input.lastName?.trim() ?? ''; + if (`${firstName} ${lastName}`.trim().length < 2) { + throw new BadRequestException('نام شخص را وارد کنید.'); + } + const person = await this.people.create(actor, { firstName, lastName }); + return { id: person.id, name: person.fullName, kind: 'person' as const }; + } + + async createPartyCompany(actor: Actor, input: { name?: string }) { + const name = input.name?.trim() ?? ''; + if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.'); + const company = await this.companies.create(actor, { name }); + return { id: company.id, name: company.name, kind: 'company' as const }; + } + + async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise { + const scope = await this.accountScope(actor); + const rows = await this.prisma.financialDocument.findMany({ + where: { + organizationId: actor.organizationId, + kind, + voidedAt: null, + status: { in: ['open', 'partial'] }, + ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), + }, + select: { + amount: true, + partyKind: true, + personId: true, + companyId: true, + userId: true, + partyName: true, + movements: { select: { amount: true, voidedAt: true } }, + }, + }); + const buckets = new Map(); + for (const row of rows) { + if (row.partyKind === 'other') continue; + const settled = this.settledOf(row.movements); + const rawRemaining = row.amount.minus(settled); + if (!rawRemaining.greaterThan(0)) continue; + const linkedId = row.personId || row.companyId || row.userId; + const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user' + ? row.partyKind + : null; + const key = partyKind && linkedId + ? `${partyKind}:${linkedId}` + : `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`; + const current = buckets.get(key) ?? { + key, + partyKind, + partyName: row.partyName?.trim() || 'بدون طرف حساب', + amount: new Prisma.Decimal(0), + settled: new Prisma.Decimal(0), + remaining: new Prisma.Decimal(0), + }; + current.amount = current.amount.plus(row.amount); + current.settled = current.settled.plus(settled); + current.remaining = current.remaining.plus(rawRemaining); + buckets.set(key, current); + } + return [...buckets.values()] + .sort((a, b) => b.remaining.comparedTo(a.remaining)) + .map((row) => ({ + key: row.key, + partyKind: row.partyKind, + partyName: row.partyName, + amount: money(row.amount), + settledAmount: money(row.settled), + remaining: money(row.remaining), + })); + } + + private async resolveParty( + actor: Actor, + input: { + partyKind?: FinancialPartyKind | null; + personId?: string | null; + companyId?: string | null; + userId?: string | null; + }, + ) { + const kind = input.partyKind; + if (kind === 'other') { + return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' }; + } + if (kind === 'person') { + const id = input.personId?.trim(); + if (!id) throw new BadRequestException('مشتری را انتخاب کنید.'); + const person = await this.prisma.person.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { id: true, firstName: true, lastName: true }, + }); + if (!person) throw new BadRequestException('مشتری پیدا نشد.'); + return { + partyKind: 'person' as const, + personId: person.id, + companyId: null, + userId: null, + partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری', + }; + } + if (kind === 'company') { + const id = input.companyId?.trim(); + if (!id) throw new BadRequestException('شرکت را انتخاب کنید.'); + const company = await this.prisma.company.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { id: true, name: true }, + }); + if (!company) throw new BadRequestException('شرکت پیدا نشد.'); + return { + partyKind: 'company' as const, + personId: null, + companyId: company.id, + userId: null, + partyName: company.name, + }; + } + if (kind === 'user') { + const id = input.userId?.trim(); + if (!id) throw new BadRequestException('کاربر را انتخاب کنید.'); + const user = await this.prisma.user.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { id: true, fullName: true }, + }); + if (!user) throw new BadRequestException('کاربر پیدا نشد.'); + return { + partyKind: 'user' as const, + personId: null, + companyId: null, + userId: user.id, + partyName: user.fullName, + }; + } + throw new BadRequestException('طرف حساب را انتخاب کنید.'); + } + async settleDocument( actor: Actor, id: string, @@ -948,7 +1154,10 @@ export class TreasuryService { bookedAt: Date; dueAt: Date | null; categoryId: string | null; + partyKind?: string | null; personId: string | null; + companyId?: string | null; + userId?: string | null; partyName: string | null; accountId: string | null; method: string | null; @@ -975,7 +1184,10 @@ export class TreasuryService { dueAt: row.dueAt ? row.dueAt.toISOString() : null, categoryId: row.categoryId, categoryName: row.category?.name ?? null, + partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null, personId: row.personId, + companyId: row.companyId ?? null, + userId: row.userId ?? null, partyName: row.partyName, accountId: row.accountId, accountName: row.account?.name ?? null, diff --git a/apps/web/src/components/treasury/TreasuryPartyBalanceTable.vue b/apps/web/src/components/treasury/TreasuryPartyBalanceTable.vue new file mode 100644 index 0000000..4dd929b --- /dev/null +++ b/apps/web/src/components/treasury/TreasuryPartyBalanceTable.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/web/src/components/treasury/TreasuryPartySelect.vue b/apps/web/src/components/treasury/TreasuryPartySelect.vue new file mode 100644 index 0000000..3c39dcb --- /dev/null +++ b/apps/web/src/components/treasury/TreasuryPartySelect.vue @@ -0,0 +1,262 @@ + + + diff --git a/apps/web/src/components/treasury/useSettleDefault.ts b/apps/web/src/components/treasury/useSettleDefault.ts new file mode 100644 index 0000000..6f92a11 --- /dev/null +++ b/apps/web/src/components/treasury/useSettleDefault.ts @@ -0,0 +1,52 @@ +import { ref, watch, type Ref } from 'vue'; + +/** مبلغ دریافت/پرداخت را با مبلغ کل یکی می‌کند تا کاربر خودش آن را عوض کند. */ +export function useSettleDefault( + form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>, +) { + const dirty = ref(false); + let mirroring = false; + + function locked() { + return form.value.partyKind === 'other'; + } + + function copy() { + mirroring = true; + form.value.settleAmount = form.value.amount; + mirroring = false; + } + + watch( + () => form.value.amount, + () => { + if (locked() || !dirty.value) copy(); + }, + { flush: 'sync' }, + ); + + watch( + () => form.value.settleAmount, + () => { + if (!mirroring && !locked()) dirty.value = true; + }, + { flush: 'sync' }, + ); + + watch( + () => form.value.partyKind, + (kind) => { + if (kind === 'other') { + dirty.value = false; + copy(); + } + }, + { flush: 'sync' }, + ); + + return { + reset() { + dirty.value = false; + }, + }; +} diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 6f444b9..45d6fe1 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -19,8 +19,17 @@ import { message, } from 'ant-design-vue'; import { api } from '@/lib/api'; -import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared'; +import type { + FinancialAccountDto, + FinancialCategoryDto, + FinancialDocumentDto, + FinancialPartyKind, + TreasuryPartyBalanceDto, +} from '@mento/shared'; import MoneyInput from '@/components/MoneyInput.vue'; +import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; +import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; +import { useSettleDefault } from '@/components/treasury/useSettleDefault'; import { toman } from './treasury-format'; import { parseMoneyNumber } from '@/lib/money'; @@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option; const kind = 'expense' as const; const rows = ref([]); +const balances = ref([]); const categories = ref([]); const accounts = ref([]); const error = ref(''); @@ -41,13 +51,15 @@ const form = ref({ title: '', amount: '', categoryId: '', - partyName: '', + partyKind: undefined as FinancialPartyKind | undefined, + partyId: undefined as string | undefined, accountId: '', settleAmount: '', method: 'transfer', reference: '', notes: '', }); +const settleMirror = useSettleDefault(form); const settleLabel = 'مبلغ پرداخت‌شده'; const statusLabel: Record = { @@ -71,13 +83,28 @@ function apiMessage(e: unknown) { return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; } +function docLine(item: FinancialDocumentDto) { + const parts = [ + item.partyName || 'بدون طرف حساب', + statusLabel[item.status] ?? item.status, + `مانده ${toman(item.remaining)}`, + ]; + if (item.categoryName) parts.push(item.categoryName); + return parts.join(' · '); +} + async function load() { - const [docs, cats, acc] = await Promise.all([ + const [docs, cats, acc, bals] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get('/treasury/categories', { params: { kind } }), api.get('/treasury/accounts'), + api.get('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { + if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; + throw e; + }), ]); rows.value = docs.data; + balances.value = bals.data; categories.value = cats.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive); } @@ -96,24 +123,37 @@ async function createDoc() { error.value = 'مبلغ کل معتبر نیست.'; return; } + if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { + error.value = 'طرف حساب را انتخاب کنید.'; + return; + } const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; + if (form.value.partyKind === 'other' && settleAmount !== amount) { + error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.'; + return; + } await api.post('/treasury/documents', { kind, title: form.value.title, amount, categoryId: form.value.categoryId || null, - partyName: form.value.partyName || null, + partyKind: form.value.partyKind, + personId: form.value.partyKind === 'person' ? form.value.partyId : null, + companyId: form.value.partyKind === 'company' ? form.value.partyId : null, + userId: form.value.partyKind === 'user' ? form.value.partyId : null, accountId: form.value.accountId || null, settleAmount, method: form.value.method, reference: form.value.reference || null, notes: form.value.notes || null, }); + settleMirror.reset(); form.value = { title: '', amount: '', categoryId: '', - partyName: '', + partyKind: undefined, + partyId: undefined, accountId: '', settleAmount: '', method: 'transfer', @@ -180,7 +220,7 @@ onMounted(() => { - + @@ -198,8 +238,8 @@ onMounted(() => { - - + + @@ -234,6 +274,9 @@ onMounted(() => { ثبت + مانده پرداختنی + + فهرست اسناد @@ -268,11 +311,7 @@ onMounted(() => { - + diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 01e5606..5e502f2 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -37,7 +37,6 @@ import { NumberOutlined, PlusOutlined, TagOutlined, - UserOutlined, } from '@ant-design/icons-vue'; import { api } from '@/lib/api'; import { formatLocalDateInput } from '@/lib/jalali'; @@ -45,10 +44,15 @@ import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto, + FinancialPartyKind, TreasuryDashboardDto, + TreasuryPartyBalanceDto, } from '@mento/shared'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; import MoneyInput from '@/components/MoneyInput.vue'; +import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; +import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; +import { useSettleDefault } from '@/components/treasury/useSettleDefault'; import { toman } from './treasury-format'; import { parseMoneyNumber } from '@/lib/money'; @@ -62,6 +66,7 @@ const isMobile = computed(() => !screens.value.md); const kind = 'income' as const; const rows = ref([]); +const balances = ref([]); const categories = ref([]); const accounts = ref([]); const dashboard = ref(null); @@ -80,7 +85,8 @@ function emptyForm() { title: '', amount: '', categoryId: undefined as string | undefined, - partyName: '', + partyKind: undefined as FinancialPartyKind | undefined, + partyId: undefined as string | undefined, accountId: undefined as string | undefined, settleAmount: '', method: 'transfer', @@ -91,6 +97,7 @@ function emptyForm() { } const form = ref(emptyForm()); +const settleMirror = useSettleDefault(form); const settleLabel = 'مبلغ دریافت‌شده'; const statusLabel: Record = { @@ -137,20 +144,36 @@ function apiMessage(e: unknown) { return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; } +function docLine(item: FinancialDocumentDto) { + const parts = [ + item.partyName || 'بدون طرف حساب', + statusLabel[item.status] ?? item.status, + `مانده ${toman(item.remaining)}`, + ]; + if (item.categoryName) parts.push(item.categoryName); + return parts.join(' · '); +} + async function load() { - const [docs, cats, acc, dash] = await Promise.all([ + const [docs, cats, acc, dash, bals] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get('/treasury/categories', { params: { kind } }), api.get('/treasury/accounts'), api.get('/treasury/dashboard').catch(() => null), + api.get('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { + if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; + throw e; + }), ]); rows.value = docs.data; + balances.value = bals.data; categories.value = cats.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive); if (dash) dashboard.value = dash.data; } function resetForm() { + settleMirror.reset(); form.value = emptyForm(); formTab.value = 'main'; error.value = ''; @@ -167,13 +190,24 @@ async function createDoc() { error.value = 'مبلغ کل معتبر نیست.'; return; } + if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { + error.value = 'طرف حساب را انتخاب کنید.'; + return; + } const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; + if (form.value.partyKind === 'other' && settleAmount !== amount) { + error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.'; + return; + } await api.post('/treasury/documents', { kind, title: form.value.title, amount, categoryId: form.value.categoryId || null, - partyName: form.value.partyName || null, + partyKind: form.value.partyKind, + personId: form.value.partyKind === 'person' ? form.value.partyId : null, + companyId: form.value.partyKind === 'company' ? form.value.partyId : null, + userId: form.value.partyKind === 'user' ? form.value.partyId : null, accountId: form.value.accountId || null, settleAmount, method: form.value.method, @@ -358,10 +392,8 @@ onMounted(() => { - - - - + + @@ -426,7 +458,12 @@ onMounted(() => { name="settleAmount" :rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]" > - + @@ -511,6 +548,8 @@ onMounted(() => { + مانده دریافتنی + فهرست اسناد @@ -540,11 +579,7 @@ onMounted(() => { - + @@ -614,7 +649,7 @@ onMounted(() => { - + @@ -632,8 +667,8 @@ onMounted(() => { - - + + @@ -688,6 +723,8 @@ onMounted(() => { ثبت + مانده دریافتنی + فهرست اسناد @@ -722,11 +759,7 @@ onMounted(() => { - + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d534472..4e886d6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6461,6 +6461,7 @@ export interface RoutineUserSettingsDto { export type FinancialAccountType = "bank" | "cash" | "petty"; export type FinancialDocKind = "income" | "expense"; export type FinancialDocStatus = "open" | "partial" | "settled" | "void"; +export type FinancialPartyKind = "person" | "company" | "user" | "other"; export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other"; export interface FinancialAccountDto { @@ -6499,7 +6500,10 @@ export interface FinancialDocumentDto { dueAt: string | null; categoryId: string | null; categoryName: string | null; + partyKind: FinancialPartyKind | null; personId: string | null; + companyId: string | null; + userId: string | null; partyName: string | null; accountId: string | null; accountName: string | null; @@ -6552,6 +6556,26 @@ export interface FinancialReceivableDto { createdAt: string; } +export interface TreasuryPartyChoiceDto { + id: string; + name: string; +} + +export interface TreasuryPartySearchDto { + people: TreasuryPartyChoiceDto[]; + companies: TreasuryPartyChoiceDto[]; + users: TreasuryPartyChoiceDto[]; +} + +export interface TreasuryPartyBalanceDto { + key: string; + partyKind: FinancialPartyKind | null; + partyName: string; + amount: string; + settledAmount: string; + remaining: string; +} + export interface TreasuryDashboardDto { bookedIncome: string; actualReceipts: string; From 08a278e1c8ec3e2712931080361f6ccf759aef31 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 4 Nov 2026 13:11:09 +0330 Subject: [PATCH 02/12] feat(treasury): number documents and manage categories on their tab Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab. Co-authored-by: Cursor --- apps/api/src/treasury/treasury.controller.ts | 16 +- apps/api/src/treasury/treasury.service.ts | 111 +++++++-- .../src/components/treasury/TreasuryNav.vue | 2 +- .../treasury/TreasuryPartySelect.vue | 145 +++++------- apps/web/src/router/index.ts | 4 +- apps/web/src/views/CompanyFormView.vue | 4 +- .../views/treasury/TreasuryCategoriesView.vue | 215 +++++++++++++----- .../views/treasury/TreasuryExpensesView.vue | 19 +- .../views/treasury/TreasuryIncomesView.vue | 99 ++------ packages/shared/src/index.ts | 2 +- 10 files changed, 361 insertions(+), 256 deletions(-) diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 4d96f27..c627132 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -43,6 +43,20 @@ export class TreasuryController { return this.treasury.createCategory(user, body); } + @Patch('categories/:id') + updateCategory( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() body: { name?: string; isActive?: boolean }, + ) { + return this.treasury.updateCategory(user, id, body); + } + + @Delete('categories/:id') + deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.treasury.deleteCategory(user, id); + } + @Get('accounts') accounts( @CurrentUser() user: Actor, diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index 5fde7ab..b83c03e 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -20,6 +20,7 @@ import type { } from '@mento/shared'; import { CompaniesService } from '../contacts/companies.service'; import { PeopleService } from '../contacts/people.service'; +import { dateToJalali } from '../common/jalali'; import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; @@ -119,19 +120,68 @@ export class TreasuryService { ): Promise { const name = input.name.trim(); if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); - const row = await this.prisma.financialCategory.create({ - data: { - organizationId: actor.organizationId, - name, - kind: input.kind, - }, + try { + const row = await this.prisma.financialCategory.create({ + data: { + organizationId: actor.organizationId, + name, + kind: input.kind, + }, + }); + return { + id: row.id, + name: row.name, + kind: row.kind as FinancialCategoryDto['kind'], + isActive: row.isActive, + }; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.'); + } + throw e; + } + } + + async updateCategory( + actor: Actor, + id: string, + input: { name?: string; isActive?: boolean }, + ): Promise { + const current = await this.prisma.financialCategory.findFirst({ + where: { id, organizationId: actor.organizationId }, }); - return { - id: row.id, - name: row.name, - kind: row.kind as FinancialCategoryDto['kind'], - isActive: row.isActive, - }; + if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.'); + const name = input.name == null ? current.name : input.name.trim(); + if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); + try { + const row = await this.prisma.financialCategory.update({ + where: { id: current.id }, + data: { + name, + ...(input.isActive == null ? {} : { isActive: input.isActive }), + }, + }); + return { + id: row.id, + name: row.name, + kind: row.kind as FinancialCategoryDto['kind'], + isActive: row.isActive, + }; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.'); + } + throw e; + } + } + + async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> { + const current = await this.prisma.financialCategory.findFirst({ + where: { id, organizationId: actor.organizationId }, + }); + if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.'); + await this.prisma.financialCategory.delete({ where: { id: current.id } }); + return { ok: true }; } async listAccounts( @@ -511,6 +561,13 @@ export class TreasuryService { } const bookedAt = startOfDay(input.bookedAt) ?? new Date(); const doc = await this.prisma.$transaction(async (tx) => { + const reference = await this.nextDocumentReference( + tx, + actor.organizationId, + input.kind, + input.bookedAt, + bookedAt, + ); const created = await tx.financialDocument.create({ data: { organizationId: actor.organizationId, @@ -528,7 +585,7 @@ export class TreasuryService { accountId: input.accountId || null, method: input.method || null, status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', - reference: input.reference?.trim() || null, + reference, notes: input.notes?.trim() || null, createdById: actor.userId, }, @@ -544,7 +601,7 @@ export class TreasuryService { kind: input.kind === 'income' ? 'receipt' : 'payment', occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, memo: title, - reference: input.reference?.trim() || null, + reference, createdById: actor.userId, }, }); @@ -557,6 +614,32 @@ export class TreasuryService { return this.toDocumentDto(doc); } + /** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */ + private async nextDocumentReference( + tx: Prisma.TransactionClient, + organizationId: string, + kind: 'income' | 'expense', + bookedAtInput: string | undefined, + bookedAt: Date, + ) { + const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/); + const when = match + ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + : bookedAt; + const { jy, jm, jd } = dateToJalali(when); + const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`; + const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`; + const latest = await tx.financialDocument.findFirst({ + where: { organizationId, reference: { startsWith: prefix } }, + orderBy: { reference: 'desc' }, + select: { reference: true }, + }); + const current = Number(latest?.reference?.slice(prefix.length)); + const next = Number.isFinite(current) && current > 0 ? current + 1 : 1; + return `${prefix}${String(next).padStart(4, '0')}`; + } + async searchParties(actor: Actor, q?: string): Promise { const term = q?.trim() ?? ''; const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined; diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index cf89e5a..2fb2bb7 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -22,7 +22,7 @@ const links = computed(() => { { value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' }, { value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' }, { value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' }, - { value: '/finance/categories', label: 'دسته‌بندی‌ها', section: 'treasury_categories' }, + { value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' }, ]; return all.filter((link) => treasurySectionAllowed(role, permissions, link.section)); }); diff --git a/apps/web/src/components/treasury/TreasuryPartySelect.vue b/apps/web/src/components/treasury/TreasuryPartySelect.vue index 3c39dcb..23fabef 100644 --- a/apps/web/src/components/treasury/TreasuryPartySelect.vue +++ b/apps/web/src/components/treasury/TreasuryPartySelect.vue @@ -4,16 +4,15 @@ import axios from 'axios'; import { Button as AButton, Flex as AFlex, - Form as AForm, - FormItem as AFormItem, - Input as AInput, - Modal as AModal, Select as ASelect, Typography, message, } from 'ant-design-vue'; -import type { FinancialPartyKind, TreasuryPartySearchDto } from '@mento/shared'; +import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared'; +import { UiModal } from '@/components/ui'; import { api } from '@/lib/api'; +import CompanyFormView from '@/views/CompanyFormView.vue'; +import PersonFormView from '@/views/PersonFormView.vue'; const VNodes = defineComponent({ props: { vnodes: { type: Object, required: true } }, @@ -36,12 +35,9 @@ const companies = ref([]); const users = ref([]); const labels = ref>({}); const loading = ref(false); +const selectOpen = ref(false); const personOpen = ref(false); const companyOpen = ref(false); -const personSaving = ref(false); -const companySaving = ref(false); -const personForm = ref({ firstName: '', lastName: '' }); -const companyName = ref(''); const selectValue = computed(() => { if (kind.value === 'other') return 'other'; @@ -112,57 +108,37 @@ function onChange(value: unknown) { partyId.value = id; } -function savePerson() { - const firstName = personForm.value.firstName.trim(); - const lastName = personForm.value.lastName.trim(); - if (`${firstName} ${lastName}`.trim().length < 2) { - message.warning('نام شخص را وارد کنید.'); - return Promise.reject(new Error('invalid')); - } - personSaving.value = true; - return api - .post<{ id: string; name: string }>('/treasury/parties/person', { firstName, lastName }) - .then(({ data }) => { - people.value = [{ id: data.id, name: data.name }, ...people.value.filter((row) => row.id !== data.id)]; - labels.value[`person:${data.id}`] = data.name; - kind.value = 'person'; - partyId.value = data.id; - personForm.value = { firstName: '', lastName: '' }; - message.success('شخص در مشتریان ثبت شد.'); - }) - .catch((e: unknown) => { - if (axios.isAxiosError(e)) message.error(apiMessage(e)); - return Promise.reject(e); - }) - .finally(() => { - personSaving.value = false; - }); +function onDropdown(open: boolean) { + selectOpen.value = open; } -function saveCompany() { - const name = companyName.value.trim(); - if (name.length < 2) { - message.warning('نام شرکت را وارد کنید.'); - return Promise.reject(new Error('invalid')); - } - companySaving.value = true; - return api - .post<{ id: string; name: string }>('/treasury/parties/company', { name }) - .then(({ data }) => { - companies.value = [{ id: data.id, name: data.name }, ...companies.value.filter((row) => row.id !== data.id)]; - labels.value[`company:${data.id}`] = data.name; - kind.value = 'company'; - partyId.value = data.id; - companyName.value = ''; - message.success('شرکت در شرکت‌ها ثبت شد.'); - }) - .catch((e: unknown) => { - if (axios.isAxiosError(e)) message.error(apiMessage(e)); - return Promise.reject(e); - }) - .finally(() => { - companySaving.value = false; - }); +function openPersonForm() { + selectOpen.value = false; + personOpen.value = true; +} + +function openCompanyForm() { + selectOpen.value = false; + companyOpen.value = true; +} + +function onPersonCreated(person?: PersonDto) { + personOpen.value = false; + if (!person?.id) return; + const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim(); + people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)]; + labels.value[`person:${person.id}`] = name; + kind.value = 'person'; + partyId.value = person.id; +} + +function onCompanyCreated(company?: CompanyDto) { + companyOpen.value = false; + if (!company?.id) return; + companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)]; + labels.value[`company:${company.id}`] = company.name; + kind.value = 'company'; + partyId.value = company.id; } onMounted(() => { @@ -173,7 +149,9 @@ onMounted(() => { diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 1530816..5a271fa 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -369,8 +369,8 @@ export const router = createRouter({ component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[], - treasuryTitle: 'دسته‌بندی‌های مالی', - treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.', + treasuryTitle: 'دسته‌بندی', + treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.', }, }, ], diff --git a/apps/web/src/views/CompanyFormView.vue b/apps/web/src/views/CompanyFormView.vue index e5833c2..adfd432 100644 --- a/apps/web/src/views/CompanyFormView.vue +++ b/apps/web/src/views/CompanyFormView.vue @@ -27,7 +27,7 @@ const props = withDefaults( }>(), { embedded: false }, ); -const emit = defineEmits<{ saved: []; cancel: [] }>(); +const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>(); const route = useRoute(); const router = useRouter(); @@ -152,7 +152,7 @@ async function submit() { if (isNew.value) { const { data } = await api.post('/companies', payload()); if (props.embedded) { - emit('saved'); + emit('saved', data); return; } await router.replace({ name: 'company-detail', params: { id: data.id } }); diff --git a/apps/web/src/views/treasury/TreasuryCategoriesView.vue b/apps/web/src/views/treasury/TreasuryCategoriesView.vue index 20dadc1..26864fd 100644 --- a/apps/web/src/views/treasury/TreasuryCategoriesView.vue +++ b/apps/web/src/views/treasury/TreasuryCategoriesView.vue @@ -1,5 +1,5 @@ diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 45d6fe1..2560b11 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -56,7 +56,6 @@ const form = ref({ accountId: '', settleAmount: '', method: 'transfer', - reference: '', notes: '', }); const settleMirror = useSettleDefault(form); @@ -85,10 +84,11 @@ function apiMessage(e: unknown) { function docLine(item: FinancialDocumentDto) { const parts = [ + item.reference ? `پیگیری ${item.reference}` : '', item.partyName || 'بدون طرف حساب', statusLabel[item.status] ?? item.status, `مانده ${toman(item.remaining)}`, - ]; + ].filter(Boolean); if (item.categoryName) parts.push(item.categoryName); return parts.join(' · '); } @@ -132,7 +132,7 @@ async function createDoc() { error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.'; return; } - await api.post('/treasury/documents', { + const { data } = await api.post('/treasury/documents', { kind, title: form.value.title, amount, @@ -144,7 +144,6 @@ async function createDoc() { accountId: form.value.accountId || null, settleAmount, method: form.value.method, - reference: form.value.reference || null, notes: form.value.notes || null, }); settleMirror.reset(); @@ -157,10 +156,11 @@ async function createDoc() { accountId: '', settleAmount: '', method: 'transfer', - reference: '', notes: '', }; - ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; + ok.value = data.reference + ? `سند ثبت شد. شماره پیگیری: ${data.reference}` + : 'سند ثبت شد.'; await load(); } catch (e) { error.value = apiMessage(e); @@ -265,11 +265,6 @@ onMounted(() => { - - - - - ثبت @@ -285,7 +280,7 @@ onMounted(() => { diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 5e502f2..c961c76 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -15,7 +15,6 @@ import { Grid, Input as AInput, List as AList, - Modal as AModal, Row as ARow, Segmented as ASegmented, Select as ASelect, @@ -34,8 +33,6 @@ import { CreditCardOutlined, FileTextOutlined, InfoCircleOutlined, - NumberOutlined, - PlusOutlined, TagOutlined, } from '@ant-design/icons-vue'; import { api } from '@/lib/api'; @@ -75,9 +72,6 @@ const ok = ref(''); const saving = ref(false); const q = ref(''); const formTab = ref<'main' | 'extra'>('main'); -const categoryModalOpen = ref(false); -const newCategoryName = ref(''); -const categorySaving = ref(false); const mobileFormRef = ref(); function emptyForm() { @@ -90,7 +84,6 @@ function emptyForm() { accountId: undefined as string | undefined, settleAmount: '', method: 'transfer', - reference: '', notes: '', bookedAt: formatLocalDateInput(new Date()), }; @@ -146,10 +139,11 @@ function apiMessage(e: unknown) { function docLine(item: FinancialDocumentDto) { const parts = [ + item.reference ? `پیگیری ${item.reference}` : '', item.partyName || 'بدون طرف حساب', statusLabel[item.status] ?? item.status, `مانده ${toman(item.remaining)}`, - ]; + ].filter(Boolean); if (item.categoryName) parts.push(item.categoryName); return parts.join(' · '); } @@ -199,7 +193,7 @@ async function createDoc() { error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.'; return; } - await api.post('/treasury/documents', { + const { data } = await api.post('/treasury/documents', { kind, title: form.value.title, amount, @@ -211,12 +205,13 @@ async function createDoc() { accountId: form.value.accountId || null, settleAmount, method: form.value.method, - reference: form.value.reference || null, notes: form.value.notes || null, bookedAt: form.value.bookedAt || undefined, }); resetForm(); - ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; + ok.value = data.reference + ? `سند ثبت شد. شماره پیگیری: ${data.reference}` + : 'سند ثبت شد.'; await load(); } catch (e) { error.value = apiMessage(e); @@ -245,30 +240,6 @@ async function voidDoc(id: string) { } } -async function createCategoryQuick() { - const name = newCategoryName.value.trim(); - if (name.length < 2) { - error.value = 'نام دسته‌بندی کوتاه است.'; - return; - } - categorySaving.value = true; - error.value = ''; - try { - const { data } = await api.post('/treasury/categories', { - name, - kind, - }); - await load(); - form.value.categoryId = data.id; - newCategoryName.value = ''; - categoryModalOpen.value = false; - } catch (e) { - error.value = apiMessage(e); - } finally { - categorySaving.value = false; - } -} - onMounted(() => { void load().catch(() => { error.value = 'بارگذاری ناموفق بود.'; @@ -425,21 +396,16 @@ onMounted(() => { دسته‌بندی درآمد - - - - {{ cat.name }} - - - - - - + + + {{ cat.name }} + + @@ -530,12 +496,6 @@ onMounted(() => {
- - - - - - { @@ -709,11 +669,6 @@ onMounted(() => { - - - - - @@ -733,7 +688,7 @@ onMounted(() => { @@ -768,23 +723,5 @@ onMounted(() => { - - - - - - -
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4e886d6..3819db9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -843,7 +843,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{ { key: "treasury_recurring", label: "مالی — تکرارشونده" }, { key: "treasury_reports", label: "مالی — گزارش" }, { key: "treasury_audit", label: "مالی — سوابق" }, - { key: "treasury_categories", label: "مالی — دسته‌بندی‌ها" }, + { key: "treasury_categories", label: "مالی — دسته‌بندی" }, { key: "behavior", label: "شخصیت‌شناسی و دستیار فروش" }, ]; From 5b187490050b5ce637e7cebd641a8d5c194c72ed Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 4 Nov 2026 14:16:38 +0330 Subject: [PATCH 03/12] feat(treasury): list debtors and creditors on the finance report Show open receivable and payable balances on separate report tabs, and include each list in an Excel export. Co-authored-by: Cursor --- apps/api/src/treasury/treasury-ops.service.ts | 39 ++++++- apps/api/src/treasury/treasury.controller.ts | 6 +- .../views/treasury/TreasuryReportsView.vue | 106 +++++++++++++++++- 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/apps/api/src/treasury/treasury-ops.service.ts b/apps/api/src/treasury/treasury-ops.service.ts index 1a7b497..4c1f779 100644 --- a/apps/api/src/treasury/treasury-ops.service.ts +++ b/apps/api/src/treasury/treasury-ops.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes import { Cron } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import ExcelJS from 'exceljs'; +import type { TreasuryPartyBalanceDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; @@ -314,7 +315,12 @@ export class TreasuryOpsService { return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown }; } - async reportExcel(actor: Actor, from?: string, to?: string) { + async reportExcel( + actor: Actor, + from?: string, + to?: string, + parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] }, + ) { const data = await this.report(actor, from, to); const book = new ExcelJS.Workbook(); const sheet = book.addWorksheet('گزارش مالی'); @@ -331,10 +337,41 @@ export class TreasuryOpsService { sheet.addRow([]); sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']); data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share])); + if (parties) { + this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافت‌شده'); + this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداخت‌شده'); + } const buffer = await book.xlsx.writeBuffer(); return Buffer.from(buffer); } + private addPartySheet( + book: ExcelJS.Workbook, + title: string, + rows: TreasuryPartyBalanceDto[], + settledLabel: string, + ) { + const sheet = book.addWorksheet(title); + const kindLabel: Record = { person: 'مشتری', company: 'شرکت', user: 'کاربر' }; + sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']); + let amount = new Prisma.Decimal(0); + let settled = new Prisma.Decimal(0); + let remaining = new Prisma.Decimal(0); + for (const row of rows) { + sheet.addRow([ + row.partyName, + (row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی', + row.amount, + row.settledAmount, + row.remaining, + ]); + amount = amount.plus(row.amount); + settled = settled.plus(row.settledAmount); + remaining = remaining.plus(row.remaining); + } + sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]); + } + private async period(organizationId: string, from: Date, to: Date) { const docs = await this.prisma.financialDocument.findMany({ where: { diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index c627132..92955dc 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -269,7 +269,11 @@ export class TreasuryController { @Query('from') from?: string, @Query('to') to?: string, ) { - const buffer = await this.ops.reportExcel(user, from, to); + const [debtors, creditors] = await Promise.all([ + this.treasury.partyBalances(user, 'income'), + this.treasury.partyBalances(user, 'expense'), + ]); + const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors }); return new StreamableFile(buffer, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', disposition: 'attachment; filename="treasury-report.xlsx"', diff --git a/apps/web/src/views/treasury/TreasuryReportsView.vue b/apps/web/src/views/treasury/TreasuryReportsView.vue index 8d3ac8c..aa9e65e 100644 --- a/apps/web/src/views/treasury/TreasuryReportsView.vue +++ b/apps/web/src/views/treasury/TreasuryReportsView.vue @@ -1,5 +1,6 @@ @@ -108,9 +176,12 @@ onMounted(() => { + - مقایسه دوره‌ها + + + { + + + + + + + + {{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }} + + + + خروجی اکسل + + + + + + + + + + + {{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }} + + + + خروجی اکسل + + + + + + From 78a7d6a68a2a401879cedd109c716a978483cce7 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 4 Nov 2026 19:34:36 +0330 Subject: [PATCH 04/12] feat(routines): let each person create a routine for themselves Open the new-routine action to every member, while keeping assignment to other people and teams limited to managers. Co-authored-by: Cursor --- apps/api/src/routines/routine-mapper.ts | 1 + apps/api/src/routines/routines.service.ts | 41 ++++++++++----- .../src/views/routines/RoutineFormView.vue | 52 +++++++++++++++++-- .../src/views/routines/RoutinesListView.vue | 20 +++++-- .../src/views/routines/RoutinesShellView.vue | 4 +- packages/shared/src/index.ts | 2 + 6 files changed, 97 insertions(+), 23 deletions(-) diff --git a/apps/api/src/routines/routine-mapper.ts b/apps/api/src/routines/routine-mapper.ts index c3be77a..4b28edb 100644 --- a/apps/api/src/routines/routine-mapper.ts +++ b/apps/api/src/routines/routine-mapper.ts @@ -185,6 +185,7 @@ export function toRoutineDto( entityLink: normalizeEntityLink(row.entityLink), reminderMinutesBefore: row.reminderMinutesBefore, isActive: row.isActive, + createdById: row.createdById, recurrenceLabel: describeRecurrence(rule), executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null, executionTotal: execution.total, diff --git a/apps/api/src/routines/routines.service.ts b/apps/api/src/routines/routines.service.ts index c8410b6..e12698b 100644 --- a/apps/api/src/routines/routines.service.ts +++ b/apps/api/src/routines/routines.service.ts @@ -200,8 +200,7 @@ export class RoutinesService { async create(actor: Actor, dto: SaveRoutineDto): Promise { const scope = await this.scope(actor); - assertManager(scope); - const data = await this.buildData(actor, scope, dto); + const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto)); const row = await this.prisma.routine.create({ data: { ...data, @@ -216,10 +215,9 @@ export class RoutinesService { async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise { const scope = await this.scope(actor); - assertManager(scope); const existing = await this.findRoutine(actor.organizationId, id); - this.assertCanManageRoutine(scope, existing); - const data = await this.buildData(actor, scope, dto); + this.assertCanWriteRoutine(scope, existing); + const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto)); const row = await this.prisma.routine.update({ where: { id }, data: data as Prisma.RoutineUncheckedUpdateInput, @@ -232,9 +230,8 @@ export class RoutinesService { async toggle(actor: Actor, id: string, isActive: boolean): Promise { const scope = await this.scope(actor); - assertManager(scope); const existing = await this.findRoutine(actor.organizationId, id); - this.assertCanManageRoutine(scope, existing); + this.assertCanWriteRoutine(scope, existing); const row = await this.prisma.routine.update({ where: { id }, data: { isActive }, @@ -251,9 +248,8 @@ export class RoutinesService { dto: ChangeRoutineAssigneeDto, ): Promise { const scope = await this.scope(actor); - assertManager(scope); const existing = await this.findRoutine(actor.organizationId, id); - this.assertCanManageRoutine(scope, existing); + this.assertCanWriteRoutine(scope, existing); const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId); await this.cancelTodayPending(id, 'تغییر مسئول روتین'); const row = await this.prisma.routine.update({ @@ -268,9 +264,8 @@ export class RoutinesService { async duplicate(actor: Actor, id: string): Promise { const scope = await this.scope(actor); - assertManager(scope); const src = await this.findRoutine(actor.organizationId, id); - this.assertCanManageRoutine(scope, src); + this.assertCanWriteRoutine(scope, src); const { id: _id, createdAt: _c, @@ -295,9 +290,8 @@ export class RoutinesService { async remove(actor: Actor, id: string) { const scope = await this.scope(actor); - assertManager(scope); const existing = await this.findRoutine(actor.organizationId, id); - this.assertCanManageRoutine(scope, existing); + this.assertCanWriteRoutine(scope, existing); await this.prisma.routine.update({ where: { id }, data: { deletedAt: new Date(), isActive: false }, @@ -345,6 +339,27 @@ export class RoutinesService { return row; } + /** عضو عادی فقط روتینی را می‌نویسد که خودش برای خودش ساخته است. */ + private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto { + if (scope.isManager) return dto; + if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) { + throw new ForbiddenException('فقط می‌توانید برای خودتان روتین بسازید'); + } + return { ...dto, assigneeId: actor.userId, teamId: null }; + } + + private assertCanWriteRoutine( + scope: RoutineScope, + row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, + ) { + if (scope.isManager) { + this.assertCanManageRoutine(scope, row); + return; + } + if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return; + throw new ForbiddenException('فقط روتینی که برای خودتان ساخته‌اید را می‌توانید تغییر دهید'); + } + private assertCanManageRoutine( scope: RoutineScope, row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, diff --git a/apps/web/src/views/routines/RoutineFormView.vue b/apps/web/src/views/routines/RoutineFormView.vue index 10955ba..deab2c7 100644 --- a/apps/web/src/views/routines/RoutineFormView.vue +++ b/apps/web/src/views/routines/RoutineFormView.vue @@ -1,5 +1,5 @@ diff --git a/apps/web/src/components/pipeline/PipelineColumn.vue b/apps/web/src/components/pipeline/PipelineColumn.vue index d77b035..e71ddac 100644 --- a/apps/web/src/components/pipeline/PipelineColumn.vue +++ b/apps/web/src/components/pipeline/PipelineColumn.vue @@ -13,7 +13,7 @@ import { import AddDealButton from './AddDealButton.vue'; import DealCard from './DealCard.vue'; import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; -import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board'; +import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board'; const props = withDefaults( defineProps<{ @@ -51,6 +51,8 @@ const emit = defineEmits<{ const { Text: ATypographyText } = Typography; const { token } = theme.useToken(); +const bodyEl = ref(null); +const insertAt = ref(null); const renaming = ref(false); const draft = ref(''); const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null); @@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({ margin: 0, })); -function onCardDragOver(stageId: string, event: DragEvent) { +function trackDrag(event: DragEvent, index: number | null) { event.preventDefault(); + dragScrollColumn(bodyEl.value, event.clientY); + if (index != null) insertAt.value = index; +} + +function clearDragUi() { + insertAt.value = null; + stopDragScroll(); +} + +function onCardDragOver(stageId: string, index: number, event: DragEvent) { event.stopPropagation(); + trackDrag(event, pointerDropIndex(index, event)); emit('dragOver', stageId, event); } +function onColumnDrop(event: DragEvent) { + const at = insertAt.value ?? undefined; + clearDragUi(); + emit('drop', props.stage.id, event, at); +} + function onCardDrop(stageId: string, index: number, event: DragEvent) { event.preventDefault(); event.stopPropagation(); - emit('drop', stageId, event, pointerDropIndex(index, event)); + const at = insertAt.value ?? pointerDropIndex(index, event); + clearDragUi(); + emit('drop', stageId, event, at); +} + +function slotStyle(active: boolean, collapseGap = false) { + return { + height: active ? '36px' : '0px', + marginTop: !active && collapseGap ? '-12px' : '0', + borderRadius: `${token.value.borderRadius}px`, + background: active ? token.value.colorPrimaryBg : 'transparent', + outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none', + outlineOffset: '-1px', + transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease', + overflow: 'hidden', + flexShrink: 0, + }; } function startRename() { @@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) { @@ -275,9 +279,12 @@ defineExpose({ refresh: load }); .layer { position: fixed; inset: 0; - z-index: 80; - display: grid; - place-items: center; + z-index: 120; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + overflow: auto; } .backdrop { @@ -289,7 +296,8 @@ defineExpose({ refresh: load }); .modal { position: relative; width: min(420px, calc(100vw - 2rem)); - max-height: min(90vh, 640px); + max-height: calc(100vh - 2rem); + margin: auto; overflow: auto; background: #fff; border-radius: 16px; From 7f6d9a84d67dcace78ab54aa0a1e76ca565e4550 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 5 Nov 2026 10:51:36 +0330 Subject: [PATCH 08/12] fix(contacts): treat Iranian mobile formats as one person Tahesab import still replaces the name and keeps the previous name. Co-authored-by: Cursor --- .../migration.sql | 6 + apps/api/prisma/schema.prisma | 6 + .../activities/activities-excel.service.ts | 13 +- apps/api/src/contacts/contacts.module.ts | 3 +- apps/api/src/contacts/merge-same-mobile.ts | 346 ++++++++++++++++++ apps/api/src/contacts/normalize.ts | 33 ++ apps/api/src/contacts/people.service.ts | 25 +- .../src/contacts/same-mobile-merge.service.ts | 71 ++++ .../src/integrations/montify/sync.service.ts | 16 +- .../tahesab/person-link.service.ts | 18 +- .../src/integrations/voip/calls.service.ts | 3 +- .../integrations/woocommerce/sync.service.ts | 13 +- apps/api/src/lead-forms/lead-forms.service.ts | 24 +- apps/api/src/leads/leads.service.ts | 44 +-- apps/api/src/pipelines/deals-excel.service.ts | 13 +- apps/api/src/portal/portal.service.ts | 19 +- .../project-cards-excel.service.ts | 12 +- 17 files changed, 586 insertions(+), 79 deletions(-) create mode 100644 apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql create mode 100644 apps/api/src/contacts/merge-same-mobile.ts create mode 100644 apps/api/src/contacts/same-mobile-merge.service.ts diff --git a/apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql b/apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql new file mode 100644 index 0000000..8caddf4 --- /dev/null +++ b/apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE "DataFix" ( + "key" TEXT NOT NULL, + "doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key") +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 76bb1ef..5e5b710 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -3779,3 +3779,9 @@ model AttendanceEvent { @@index([organizationId, userId, at]) @@unique([organizationId, userId, kind, sessionStartedAt]) } + +/// علامت یک‌بار اجرای اصلاح داده +model DataFix { + key String @id + doneAt DateTime @default(now()) +} diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts index 13e38d2..94aa74f 100644 --- a/apps/api/src/activities/activities-excel.service.ts +++ b/apps/api/src/activities/activities-excel.service.ts @@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { assigneeWhere } from '../common/record-scope'; -import { normalizePhone } from '../contacts/normalize'; +import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { excelFieldsFromColumns, formatClock, @@ -357,7 +357,7 @@ export class ActivitiesExcelService { return id; }; const resolvePerson = async (row: Record) => { - const mobile = normalizePhone(row.personMobile); + const mobile = storedPhoneNumber(row.personMobile); const name = (row.personName ?? '').trim().toLowerCase(); const key = mobile ? `m:${mobile}` : name ? `n:${name}` : ''; if (!key) return null; @@ -478,10 +478,13 @@ export class ActivitiesExcelService { } private async findPersonId(organizationId: string, row: Record) { - const mobile = normalizePhone(row.personMobile); - if (mobile) { + const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile)); + if (mobileKeys.length) { const byMobile = await this.prisma.person.findFirst({ - where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } }, + where: { + organizationId, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, select: { id: true }, }); if (byMobile) return byMobile.id; diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index 0c21610..5fa3765 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service'; import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; +import { SameMobileMergeService } from './same-mobile-merge.service'; import { BehaviorModule } from '../behavior/behavior.module'; import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'; @@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module' CustomerGroupsModule, ], controllers: [PeopleController, CompaniesController], - providers: [PeopleService, CompaniesService, ContactsExcelService], + providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService], exports: [PeopleService, CompaniesService], }) export class ContactsModule {} diff --git a/apps/api/src/contacts/merge-same-mobile.ts b/apps/api/src/contacts/merge-same-mobile.ts new file mode 100644 index 0000000..5de6701 --- /dev/null +++ b/apps/api/src/contacts/merge-same-mobile.ts @@ -0,0 +1,346 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { Prisma, PrismaClient } from '@prisma/client'; +import { canonicalIranMobile, storedPhoneNumber } from './normalize'; + +const personSelect = { + id: true, + createdAt: true, + firstName: true, + lastName: true, + gender: true, + showGenderInName: true, + previousFirstName: true, + previousLastName: true, + email: true, + phone: true, + mobile: true, + position: true, + website: true, + instagram: true, + birthDate: true, + referrer: true, + addresses: true, + notes: true, + tags: true, + companyId: true, + customFields: true, + importSource: true, + loyaltyPoints: true, + discLabel: true, + discReliable: true, + discAssessedAt: true, +} satisfies Prisma.PersonSelect; + +type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>; + +function blank(value: string | null | undefined) { + return !value?.trim(); +} + +function asStrings(value: Prisma.JsonValue): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => String(item)).filter(Boolean); +} + +function asObject(value: Prisma.JsonValue): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return { ...(value as Record) }; +} + +function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput { + const notes = [keeper.notes?.trim() || '']; + let email = keeper.email; + let phone = storedPhoneNumber(keeper.phone); + let position = keeper.position; + let website = keeper.website; + let instagram = keeper.instagram; + let birthDate = keeper.birthDate; + let referrer = keeper.referrer; + let companyId = keeper.companyId; + let gender = keeper.gender; + let showGenderInName = keeper.showGenderInName; + let previousFirstName = keeper.previousFirstName; + let previousLastName = keeper.previousLastName; + let discLabel = keeper.discLabel; + let discReliable = keeper.discReliable; + let discAssessedAt = keeper.discAssessedAt; + let loyaltyPoints = keeper.loyaltyPoints; + const addresses = asStrings(keeper.addresses); + const tags = new Set(keeper.tags); + const customFields = asObject(keeper.customFields); + + for (const extra of extras) { + const name = `${extra.firstName} ${extra.lastName}`.trim(); + notes.push( + `ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`, + ); + if (blank(email) && extra.email) email = extra.email; + if (blank(phone)) phone = storedPhoneNumber(extra.phone); + if (blank(position) && extra.position) position = extra.position; + if (blank(website) && extra.website) website = extra.website; + if (blank(instagram) && extra.instagram) instagram = extra.instagram; + if (!birthDate && extra.birthDate) birthDate = extra.birthDate; + if (blank(referrer) && extra.referrer) referrer = extra.referrer; + if (!companyId && extra.companyId) companyId = extra.companyId; + if (gender === 'none' && extra.gender !== 'none') { + gender = extra.gender; + showGenderInName = extra.showGenderInName; + } + if (blank(previousFirstName) && extra.previousFirstName) { + previousFirstName = extra.previousFirstName; + } + if (blank(previousLastName) && extra.previousLastName) { + previousLastName = extra.previousLastName; + } + if ( + keeper.importSource === 'tahesab' && + blank(previousFirstName) && + blank(previousLastName) + ) { + const extraName = `${extra.firstName} ${extra.lastName}`.trim(); + const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim(); + if (extraName && extraName !== keeperName) { + previousFirstName = extra.firstName || null; + previousLastName = extra.lastName || null; + } + } + if (!discLabel && extra.discLabel) { + discLabel = extra.discLabel; + discReliable = extra.discReliable; + discAssessedAt = extra.discAssessedAt; + } + loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints); + for (const address of asStrings(extra.addresses)) { + if (!addresses.includes(address)) addresses.push(address); + } + for (const tag of extra.tags) tags.add(tag); + for (const [key, value] of Object.entries(asObject(extra.customFields))) { + if (customFields[key] == null || customFields[key] === '') customFields[key] = value; + } + } + + return { + mobile: canon, + email, + phone, + position, + website, + instagram, + birthDate, + referrer, + companyId, + gender, + showGenderInName, + previousFirstName, + previousLastName, + discLabel, + discReliable, + discAssessedAt, + loyaltyPoints, + addresses, + tags: [...tags], + customFields: customFields as Prisma.InputJsonValue, + notes: notes.filter(Boolean).join('\n') || null, + }; +} + +async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) { + const rows = await prisma.$queryRaw<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'public' AND column_name = 'personId' + `; + return rows + .map((row) => row.table_name) + .filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person'); +} + +async function moveLinks( + tx: Prisma.TransactionClient, + tables: string[], + keeperId: string, + dupId: string, +) { + await tx.$executeRaw` + DELETE FROM "PersonGroupLink" AS d + USING "PersonGroupLink" AS k + WHERE d."personId" = ${dupId} + AND k."personId" = ${keeperId} + AND d."groupId" = k."groupId" + `; + await tx.$executeRaw` + DELETE FROM "PersonAccountingLink" AS d + USING "PersonAccountingLink" AS k + WHERE d."personId" = ${dupId} + AND k."personId" = ${keeperId} + AND d."connectionId" = k."connectionId" + `; + await tx.$executeRaw` + DELETE FROM "PartyOpeningBalance" + WHERE "personId" = ${dupId} + AND EXISTS ( + SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId} + ) + `; + + for (const table of tables) { + await tx.$executeRawUnsafe( + `UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`, + keeperId, + dupId, + ); + } + + await tx.$executeRaw` + UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "MontifyLink" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'customer' AND "entityId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "Notification" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'person' AND "entityId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "GamificationTransaction" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'person' AND "entityId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "FinancialAuditLog" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'person' AND "entityId" = ${dupId} + `; +} + +export async function mergeSameMobilePeople( + prisma: PrismaClient, + organizationId: string, + apply: boolean, +) { + const people = await prisma.person.findMany({ + where: { organizationId }, + select: personSelect, + orderBy: { createdAt: 'asc' }, + }); + const groups = new Map(); + for (const person of people) { + const key = canonicalIranMobile(person.mobile); + if (!key) continue; + const list = groups.get(key) ?? []; + list.push(person); + groups.set(key, list); + } + + const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1); + const preview = duplicateGroups.map(([mobile, list]) => ({ + mobile, + people: list.map((person) => ({ + id: person.id, + stored: person.mobile, + name: `${person.firstName} ${person.lastName}`.trim(), + })), + })); + + if (!apply) { + const needsRewrite = people.filter((person) => { + const canon = canonicalIranMobile(person.mobile); + return Boolean(canon && person.mobile !== canon); + }).length; + return { merged: 0, rewritten: 0, needsRewrite, groups: preview }; + } + + const tables = await personIdTables(prisma); + let merged = 0; + let rewritten = 0; + + for (const [canon, list] of groups) { + const ordered = [...list].sort((a, b) => { + const aTahesab = a.importSource === 'tahesab' ? 0 : 1; + const bTahesab = b.importSource === 'tahesab' ? 0 : 1; + if (aTahesab !== bTahesab) return aTahesab - bTahesab; + return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id); + }); + const [keeper, ...extras] = ordered; + if (!keeper) continue; + if (!extras.length) { + const phone = storedPhoneNumber(keeper.phone); + if (keeper.mobile !== canon || (phone && keeper.phone !== phone)) { + await prisma.person.update({ + where: { id: keeper.id }, + data: { + mobile: canon, + ...(phone && keeper.phone !== phone ? { phone } : {}), + }, + }); + rewritten += 1; + } + continue; + } + + await prisma.$transaction( + async (tx) => { + await tx.person.update({ + where: { id: keeper.id }, + data: mergedFields(keeper, extras, canon), + }); + for (const extra of extras) { + await moveLinks(tx, tables, keeper.id, extra.id); + await tx.person.delete({ where: { id: extra.id } }); + merged += 1; + } + }, + { timeout: 60_000 }, + ); + } + + return { merged, rewritten, needsRewrite: 0, groups: preview }; +} + +function loadEnv(file: string) { + const text = readFileSync(file, 'utf8'); + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/); + if (!match || process.env[match[1]]) continue; + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[match[1]] = value; + } +} + +const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile'); +if (direct) { + loadEnv(resolve(__dirname, '../../.env')); + const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6); + const apply = process.argv.includes('--apply'); + if (!orgId) { + console.error('org id required: --org='); + process.exit(1); + } + const prisma = new PrismaClient(); + mergeSameMobilePeople(prisma, orgId, apply) + .then((result) => { + console.log(JSON.stringify({ + apply, + merged: result.merged, + rewritten: result.rewritten, + needsRewrite: result.needsRewrite, + groupCount: result.groups.length, + groups: result.groups, + }, null, 2)); + }) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); +} diff --git a/apps/api/src/contacts/normalize.ts b/apps/api/src/contacts/normalize.ts index bbd4ded..a12db1b 100644 --- a/apps/api/src/contacts/normalize.ts +++ b/apps/api/src/contacts/normalize.ts @@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null { const cleaned = latinDigits(v).replace(/[^\d+]/g, ''); return cleaned.length ? cleaned : null; } + +/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */ +export function canonicalIranMobile(value?: string | null): string | null { + const n = normalizePhone(value); + if (!n) return null; + let d = n.replace(/\D/g, ''); + if (!d) return null; + if (d.startsWith('0098') && d.length >= 14) d = d.slice(4); + else if (d.startsWith('98') && d.length >= 12) d = d.slice(2); + if (d.length === 10 && d.startsWith('9')) d = `0${d}`; + return /^09\d{9}$/.test(d) ? d : null; +} + +/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */ +export function iranMobileLookupKeys(value?: string | null): string[] { + const canon = canonicalIranMobile(value); + if (!canon) return []; + const core = canon.slice(1); + return [canon, core, `98${core}`, `+98${core}`, `0098${core}`]; +} + +/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */ +export function phoneIdentityKeys(value?: string | null): string[] { + const keys = iranMobileLookupKeys(value); + if (keys.length) return keys; + const n = normalizePhone(value); + return n ? [n] : []; +} + +/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */ +export function storedPhoneNumber(value?: string | null): string | null { + return canonicalIranMobile(value) ?? normalizePhone(value); +} diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 3e5e748..9fce8b6 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { CompaniesService } from './companies.service'; -import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; +import { + emptyToNull, + normalizeEmail, + phoneIdentityKeys, + storedPhoneNumber, +} from './normalize'; import { asAddresses, asCustomFields, @@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit { input: { mobile?: string | null; email?: string | null; birthDate: Date }, ) { const or: Prisma.PersonWhereInput[] = []; - if (input.mobile) or.push({ mobile: input.mobile }); + const mobileKeys = phoneIdentityKeys(input.mobile); + if (mobileKeys.length) { + or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }); + } if (input.email) or.push({ email: input.email }); if (!or.length) return; await this.prisma.person.updateMany({ @@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit { importSource: normalizePersonImportSource(dto.importSource), sourceRef: emptyToNull(dto.sourceRef), email: normalizeEmail(dto.email), - phone: normalizePhone(dto.phone), - mobile: normalizePhone(dto.mobile), + phone: storedPhoneNumber(dto.phone), + mobile: storedPhoneNumber(dto.mobile), position: emptyToNull(dto.position), website: emptyToNull(dto.website), instagram: emptyToNull(dto.instagram), @@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit { } if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef); if ('email' in dto) data.email = normalizeEmail(dto.email); - if ('phone' in dto) data.phone = normalizePhone(dto.phone); - if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); + if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone); + if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile); if ('position' in dto) data.position = emptyToNull(dto.position); if ('website' in dto) data.website = emptyToNull(dto.website); if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); @@ -641,10 +649,13 @@ export class PeopleService implements OnModuleInit { ) { const email = typeof data.email === 'string' ? data.email : null; const mobile = typeof data.mobile === 'string' ? data.mobile : null; + const mobileKeys = phoneIdentityKeys(mobile); const or: Prisma.PersonWhereInput[] = []; if (email) or.push({ email }); - if (mobile) or.push({ mobile }); + if (mobileKeys.length) { + or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }); + } if (!or.length) return; const clash = await this.prisma.person.findFirst({ diff --git a/apps/api/src/contacts/same-mobile-merge.service.ts b/apps/api/src/contacts/same-mobile-merge.service.ts new file mode 100644 index 0000000..f0bbc96 --- /dev/null +++ b/apps/api/src/contacts/same-mobile-merge.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { mergeSameMobilePeople } from './merge-same-mobile'; + +const FIX_KEY = 'person-mobile-canonical-v1'; + +@Injectable() +export class SameMobileMergeService implements OnApplicationBootstrap { + private readonly logger = new Logger(SameMobileMergeService.name); + + constructor(private readonly prisma: PrismaService) {} + + onApplicationBootstrap() { + if (process.env.NODE_ENV === 'test') return; + void this.run(); + } + + private async run() { + try { + const ready = await this.prisma.$queryRaw<{ ok: number }[]>` + SELECT 1 AS ok + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'Person' + AND column_name = 'gender' + `; + if (!ready.length) return; + + const table = await this.prisma.$queryRaw<{ ok: number }[]>` + SELECT 1 AS ok + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'DataFix' + `; + if (!table.length) return; + + const done = await this.prisma.$queryRaw<{ key: string }[]>` + SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY} + `; + if (done.length) return; + + await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`; + try { + const again = await this.prisma.$queryRaw<{ key: string }[]>` + SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY} + `; + if (again.length) return; + + const orgs = await this.prisma.organization.findMany({ select: { id: true } }); + let merged = 0; + let rewritten = 0; + for (const org of orgs) { + const result = await mergeSameMobilePeople(this.prisma, org.id, true); + merged += result.merged; + rewritten += result.rewritten; + } + await this.prisma.$executeRaw` + INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY}) + ON CONFLICT ("key") DO NOTHING + `; + this.logger.log(`شماره‌های تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`); + } finally { + await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`; + } + } catch (error) { + this.logger.error( + 'یکسان‌سازی موبایل مشتری‌ها انجام نشد', + error instanceof Error ? error.stack : String(error), + ); + } + } +} diff --git a/apps/api/src/integrations/montify/sync.service.ts b/apps/api/src/integrations/montify/sync.service.ts index 7788e99..381f88d 100644 --- a/apps/api/src/integrations/montify/sync.service.ts +++ b/apps/api/src/integrations/montify/sync.service.ts @@ -17,7 +17,11 @@ import type { } from '@mento/shared'; import { parseBirthDate } from '../../common/excel'; import { PrismaService } from '../../prisma/prisma.service'; -import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; +import { + normalizeEmail, + phoneIdentityKeys, + storedPhoneNumber, +} from '../../contacts/normalize'; import { carryPreviousSource } from '../../contacts/person-source'; import { ownerWhere, type Actor } from '../../common/record-scope'; import { MontifyConnectionsService } from './connections.service'; @@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] { } function mobileVariants(mobile: string): string[] { - const digits = mobile.replace(/\D/g, ''); - let core = digits; - if (core.startsWith('0098')) core = core.slice(4); - else if (core.startsWith('98') && core.length === 12) core = core.slice(2); - else if (core.startsWith('0')) core = core.slice(1); - if (!core) return [mobile]; - return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])]; + return phoneIdentityKeys(mobile); } function formatAddress(a: MontifyAddress): string | null { @@ -417,7 +415,7 @@ export class MontifySyncService { }> { const externalId = String(c.id); const { firstName, lastName } = splitName(c); - const mobile = normalizePhone(c.phone ?? null); + const mobile = storedPhoneNumber(c.phone ?? null); const email = normalizeEmail(c.email ?? null); const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a)); const profile: CustomerProfile = { diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index a7fafca..1f54e44 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -9,6 +9,7 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { carryPreviousSource } from '../../contacts/person-source'; +import { phoneIdentityKeys, storedPhoneNumber } from '../../contacts/normalize'; import { applyPersonGenderTitle } from '@mento/shared'; import { TahesabConnectionsService } from './connections.service'; import { @@ -577,7 +578,7 @@ export class TahesabPersonLinkService { const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); const firstName = named.firstName; const lastName = named.lastName; - const mobile = tel || null; + const mobile = storedPhoneNumber(tel); const sourceRef = String(dto.moshtariCode); let personId = dto.personId; @@ -606,8 +607,8 @@ export class TahesabPersonLinkService { ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(mobile ? { - mobile: existing.mobile || mobile, - phone: existing.phone || mobile, + mobile: storedPhoneNumber(existing.mobile) || mobile, + phone: storedPhoneNumber(existing.phone) || mobile, } : {}), }, @@ -616,15 +617,18 @@ export class TahesabPersonLinkService { // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ // وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. - const byMobile = mobile - ? await this.prisma.person.findFirst({ + const mobileKeys = phoneIdentityKeys(mobile); + const matches = mobileKeys.length + ? await this.prisma.person.findMany({ where: { organizationId, - OR: [{ mobile }, { phone: mobile }], + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], accountingLinks: { none: { connectionId: dto.connectionId } }, }, + orderBy: { createdAt: 'asc' }, }) - : null; + : []; + const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; if (byMobile) { const nameChanged = `${byMobile.firstName} ${byMobile.lastName}`.trim() !== diff --git a/apps/api/src/integrations/voip/calls.service.ts b/apps/api/src/integrations/voip/calls.service.ts index 869affc..e7cfb9a 100644 --- a/apps/api/src/integrations/voip/calls.service.ts +++ b/apps/api/src/integrations/voip/calls.service.ts @@ -11,7 +11,7 @@ import type { } from '@mento/shared'; import { PrismaService } from '../../prisma/prisma.service'; import { NotificationsService } from '../../notifications/notifications.service'; -import { normalizePhone } from '../../contacts/normalize'; +import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { VoipConnectionsService } from './connections.service'; function digitsOnly(value: string | null | undefined): string { @@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] { variants.add(`0${d}`); variants.add(`98${d}`); } + for (const key of iranMobileLookupKeys(raw)) variants.add(key); return [...variants]; } diff --git a/apps/api/src/integrations/woocommerce/sync.service.ts b/apps/api/src/integrations/woocommerce/sync.service.ts index 17d18ec..759475c 100644 --- a/apps/api/src/integrations/woocommerce/sync.service.ts +++ b/apps/api/src/integrations/woocommerce/sync.service.ts @@ -6,7 +6,11 @@ import type { WooCommerceSyncResultDto, } from '@mento/shared'; import { PrismaService } from '../../prisma/prisma.service'; -import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; +import { + normalizeEmail, + phoneIdentityKeys, + storedPhoneNumber, +} from '../../contacts/normalize'; import { WooConnectionsService } from './connections.service'; import type { WooOrder } from './woo-client'; @@ -374,7 +378,8 @@ export class WooSyncService { ): Promise { const billing = order.billing ?? {}; const email = normalizeEmail(billing.email ?? null); - const mobile = normalizePhone(billing.phone ?? null); + const mobile = storedPhoneNumber(billing.phone ?? null); + const mobileKeys = phoneIdentityKeys(mobile); const firstName = (billing.first_name ?? '').trim() || 'مشتری'; const lastName = (billing.last_name ?? '').trim() || 'ووکامرس'; @@ -387,7 +392,9 @@ export class WooSyncService { organizationId, OR: [ ...(email ? [{ email }] : []), - ...(mobile ? [{ mobile }, { phone: mobile }] : []), + ...(mobileKeys.length + ? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }] + : []), ], }, select: { id: true }, diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 9dfb411..28f9fd6 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { + canonicalIranMobile, emptyToNull, normalizeEmail, normalizePhone, + phoneIdentityKeys, + storedPhoneNumber, } from '../contacts/normalize'; import { LeadStagesService } from '../leads/lead-stages.service'; @@ -458,7 +461,10 @@ export class LeadFormsService { ? await this.prisma.person.findFirst({ where: { organizationId: form.organizationId, - OR: [{ mobile }, { phone: mobile }], + OR: [ + { mobile: { in: phoneIdentityKeys(mobile) } }, + { phone: { in: phoneIdentityKeys(mobile) } }, + ], }, select: { id: true, firstName: true, lastName: true }, }) @@ -641,13 +647,16 @@ export class LeadFormsService { ownerId: string, ): Promise { const email = normalizeEmail(mapped.email); - const mobile = normalizePhone(mapped.mobile); - const phone = normalizePhone(mapped.phone); + const mobile = storedPhoneNumber(mapped.mobile); + const phone = storedPhoneNumber(mapped.phone); const or: Prisma.PersonWhereInput[] = []; if (email) or.push({ email }); - if (mobile) or.push({ mobile }); + const mobileKeys = phoneIdentityKeys(mobile); + if (mobileKeys.length) { + or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }); + } const existing = or.length ? await this.prisma.person.findFirst({ where: { organizationId, OR: or }, @@ -1115,13 +1124,12 @@ function slugKey(label: string): string { } function mobileKey(raw: string | null | undefined): string | null { + const canon = canonicalIranMobile(raw); + if (canon) return canon; const n = normalizePhone(raw); if (!n) return null; const d = n.replace(/\D/g, ''); - if (!d) return null; - if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`; - if (d.length === 10 && d.startsWith('9')) return `0${d}`; - return d.startsWith('0') ? d : d; + return d || null; } function normalizeDuplicateMode(raw?: string | null): string { diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 4203328..29dcfc0 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -38,7 +38,12 @@ import { collectAddresses, toJsonAddresses, } from '../common/custom-fields'; -import { emptyToNull, normalizePhone } from '../contacts/normalize'; +import { + canonicalIranMobile, + emptyToNull, + normalizePhone, + phoneIdentityKeys, +} from '../contacts/normalize'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; import { LeadStagesService } from './lead-stages.service'; @@ -55,13 +60,20 @@ const LOGO_EXT: Record = { }; function mobileKey(raw: string | null | undefined): string | null { + const canon = canonicalIranMobile(raw); + if (canon) return canon; const n = normalizePhone(raw); if (!n) return null; const d = n.replace(/\D/g, ''); - if (!d) return null; - if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`; - if (d.length === 10 && d.startsWith('9')) return `0${d}`; - return d.startsWith('0') ? d : d; + return d || null; +} + +function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput { + const keys = phoneIdentityKeys(mobile); + return { + organizationId, + OR: [{ mobile: { in: keys } }, { phone: { in: keys } }], + }; } function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput { @@ -323,10 +335,7 @@ export class LeadsService { }; } const person = await this.prisma.person.findFirst({ - where: { - organizationId: actor.organizationId, - OR: [{ mobile }, { phone: mobile }], - }, + where: personWithMobile(actor.organizationId, mobile), select: { id: true, firstName: true, lastName: true }, }); if (person) { @@ -966,10 +975,7 @@ export class LeadsService { } let person = await this.prisma.person.findFirst({ - where: { - organizationId: actor.organizationId, - OR: [{ mobile: lead.mobile }, { phone: lead.mobile }], - }, + where: personWithMobile(actor.organizationId, lead.mobile), }); let createdPerson = false; @@ -996,7 +1002,7 @@ export class LeadsService { lastName: named.lastName, gender: named.gender, showGenderInName: named.showGenderInName, - mobile: lead.mobile, + mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile, phone: lead.phone, instagram: lead.instagram, birthDate: lead.birthDate, @@ -1222,10 +1228,7 @@ export class LeadsService { } const person = await this.prisma.person.findFirst({ - where: { - organizationId: input.organizationId, - OR: [{ mobile }, { phone: mobile }], - }, + where: personWithMobile(input.organizationId, mobile), select: { id: true }, }); if (person) { @@ -1304,10 +1307,7 @@ export class LeadsService { } const existingPerson = await this.prisma.person.findFirst({ - where: { - organizationId, - OR: [{ mobile }, { phone: mobile }], - }, + where: personWithMobile(organizationId, mobile), select: { id: true }, }); if (existingPerson) { diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index 7fd68b4..be79d50 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope'; import { dealAccessWhere, resolveOwnerId } from '../common/record-scope'; import { dealPriorWhere } from '../common/prior-archive'; import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; -import { normalizePhone } from '../contacts/normalize'; +import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { excelFieldsFromColumns, formatJalaliDate, @@ -708,9 +708,9 @@ export class DealsExcelService { ): Promise { const orgId = actor.organizationId; const code = cleanCode(row.personCode); - const mobile = normalizePhone(row.personMobile); - const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : ''; - const validMobile = mobileCore.length >= 8 ? mobile : null; + const mobile = storedPhoneNumber(row.personMobile); + const mobileKeys = phoneIdentityKeys(mobile); + const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null; const first = (row.personFirstName ?? '').trim(); const last = (row.personName ?? '').trim(); const fullName = [first, last].filter(Boolean).join(' '); @@ -731,7 +731,10 @@ export class DealsExcelService { } if (!found && validMobile) { found = await this.prisma.person.findFirst({ - where: { organizationId: orgId, mobile: { contains: mobileCore } }, + where: { + organizationId: orgId, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, select: { id: true }, }); } diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts index ba7bea7..1554577 100644 --- a/apps/api/src/portal/portal.service.ts +++ b/apps/api/src/portal/portal.service.ts @@ -19,6 +19,7 @@ import type { } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import type { PortalActor } from './portal-auth'; @@ -62,7 +63,7 @@ export class PortalService { mobileRaw: string, ): Promise { const org = await this.findOrg(slug); - const mobile = normalizeMobile(mobileRaw); + const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } @@ -123,7 +124,8 @@ export class PortalService { codeRaw: string, ): Promise { const org = await this.findOrg(slug); - const mobile = normalizeMobile(mobileRaw); + const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw); + const mobileKeys = phoneIdentityKeys(mobile); const code = codeRaw.trim(); if (!mobile || !code) { throw new BadRequestException('موبایل یا کد ناقص است'); @@ -148,10 +150,15 @@ export class PortalService { data: { consumedAt: new Date() }, }); - let person = await this.prisma.person.findFirst({ - where: { organizationId: org.id, mobile }, - orderBy: { createdAt: 'asc' }, - }); + let person = mobileKeys.length + ? await this.prisma.person.findFirst({ + where: { + organizationId: org.id, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, + orderBy: { createdAt: 'asc' }, + }) + : null; if (!person) { person = await this.prisma.person.create({ data: { diff --git a/apps/api/src/project-boards/project-cards-excel.service.ts b/apps/api/src/project-boards/project-cards-excel.service.ts index 75609df..b9d7c32 100644 --- a/apps/api/src/project-boards/project-cards-excel.service.ts +++ b/apps/api/src/project-boards/project-cards-excel.service.ts @@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope'; -import { normalizePhone } from '../contacts/normalize'; +import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { excelFieldsFromColumns, formatJalaliDate, @@ -256,11 +256,13 @@ export class ProjectCardsExcelService { } private async findPersonId(organizationId: string, row: Record) { - const mobile = normalizePhone(row.personMobile); - if (mobile) { - const needle = mobile.replace(/^\+98|^98|^0/, ''); + const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile)); + if (mobileKeys.length) { const byMobile = await this.prisma.person.findFirst({ - where: { organizationId, mobile: { contains: needle } }, + where: { + organizationId, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, select: { id: true }, }); if (byMobile) return byMobile.id; From 57a0b379f69badbb60ebf633da74ffd72b2cf812 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 5 Nov 2026 11:11:40 +0330 Subject: [PATCH 09/12] fix(tahesab): store a matched mobile as 09xxxxxxxxx Co-authored-by: Cursor --- .../tahesab/person-link.service.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 1f54e44..94b5003 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -9,7 +9,11 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { carryPreviousSource } from '../../contacts/person-source'; -import { phoneIdentityKeys, storedPhoneNumber } from '../../contacts/normalize'; +import { + canonicalIranMobile, + phoneIdentityKeys, + storedPhoneNumber, +} from '../../contacts/normalize'; import { applyPersonGenderTitle } from '@mento/shared'; import { TahesabConnectionsService } from './connections.service'; import { @@ -26,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; +/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */ +function canonicalMobileUpdate( + incoming: string | null, + current: { mobile: string | null; phone: string | null }, +): { mobile?: string; phone?: string } { + const mobile = + canonicalIranMobile(incoming) ?? + canonicalIranMobile(current.mobile) ?? + canonicalIranMobile(current.phone); + if (!mobile) return {}; + const phone = canonicalIranMobile(current.phone); + return phone ? { mobile, phone } : { mobile }; +} + /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ const LIST_PAGE_SIZE = 2000; const LIST_TIMEOUT_MS = 55_000; @@ -605,12 +623,7 @@ export class TahesabPersonLinkService { importSource: 'tahesab', sourceRef, ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), - ...(mobile - ? { - mobile: storedPhoneNumber(existing.mobile) || mobile, - phone: storedPhoneNumber(existing.phone) || mobile, - } - : {}), + ...canonicalMobileUpdate(mobile, existing), }, }); } else { @@ -650,6 +663,7 @@ export class TahesabPersonLinkService { importSource: 'tahesab', sourceRef, ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), + ...canonicalMobileUpdate(mobile, byMobile), }, }); personId = byMobile.id; From c976c0bcd62e3ee4b19a374753d00f3099ccc912 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 5 Nov 2026 11:27:05 +0330 Subject: [PATCH 10/12] fix(contacts): merge the same mobile stored on phone or mobile Co-authored-by: Cursor --- apps/api/src/contacts/merge-same-mobile.ts | 62 ++++++++++--------- .../src/contacts/same-mobile-merge.service.ts | 38 ++++++++---- 2 files changed, 60 insertions(+), 40 deletions(-) diff --git a/apps/api/src/contacts/merge-same-mobile.ts b/apps/api/src/contacts/merge-same-mobile.ts index 5de6701..bc82304 100644 --- a/apps/api/src/contacts/merge-same-mobile.ts +++ b/apps/api/src/contacts/merge-same-mobile.ts @@ -229,7 +229,7 @@ export async function mergeSameMobilePeople( }); const groups = new Map(); for (const person of people) { - const key = canonicalIranMobile(person.mobile); + const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone); if (!key) continue; const list = groups.get(key) ?? []; list.push(person); @@ -248,15 +248,16 @@ export async function mergeSameMobilePeople( if (!apply) { const needsRewrite = people.filter((person) => { - const canon = canonicalIranMobile(person.mobile); - return Boolean(canon && person.mobile !== canon); + const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone); + return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone))); }).length; - return { merged: 0, rewritten: 0, needsRewrite, groups: preview }; + return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview }; } const tables = await personIdTables(prisma); let merged = 0; let rewritten = 0; + let failed = 0; for (const [canon, list] of groups) { const ordered = [...list].sort((a, b) => { @@ -268,37 +269,42 @@ export async function mergeSameMobilePeople( const [keeper, ...extras] = ordered; if (!keeper) continue; if (!extras.length) { - const phone = storedPhoneNumber(keeper.phone); - if (keeper.mobile !== canon || (phone && keeper.phone !== phone)) { - await prisma.person.update({ - where: { id: keeper.id }, - data: { - mobile: canon, - ...(phone && keeper.phone !== phone ? { phone } : {}), - }, - }); + const phoneCanon = canonicalIranMobile(keeper.phone); + const data: Prisma.PersonUpdateInput = {}; + if (keeper.mobile !== canon) data.mobile = canon; + if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon; + if (Object.keys(data).length) { + await prisma.person.update({ where: { id: keeper.id }, data }); rewritten += 1; } continue; } - await prisma.$transaction( - async (tx) => { - await tx.person.update({ - where: { id: keeper.id }, - data: mergedFields(keeper, extras, canon), - }); - for (const extra of extras) { - await moveLinks(tx, tables, keeper.id, extra.id); - await tx.person.delete({ where: { id: extra.id } }); - merged += 1; - } - }, - { timeout: 60_000 }, - ); + try { + await prisma.$transaction( + async (tx) => { + await tx.person.update({ + where: { id: keeper.id }, + data: mergedFields(keeper, extras, canon), + }); + for (const extra of extras) { + await moveLinks(tx, tables, keeper.id, extra.id); + await tx.person.delete({ where: { id: extra.id } }); + merged += 1; + } + }, + { timeout: 60_000 }, + ); + } catch (error) { + failed += 1; + console.error( + `merge mobile ${canon} failed`, + error instanceof Error ? error.message : error, + ); + } } - return { merged, rewritten, needsRewrite: 0, groups: preview }; + return { merged, rewritten, needsRewrite: 0, failed, groups: preview }; } function loadEnv(file: string) { diff --git a/apps/api/src/contacts/same-mobile-merge.service.ts b/apps/api/src/contacts/same-mobile-merge.service.ts index f0bbc96..907bfeb 100644 --- a/apps/api/src/contacts/same-mobile-merge.service.ts +++ b/apps/api/src/contacts/same-mobile-merge.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common' import { PrismaService } from '../prisma/prisma.service'; import { mergeSameMobilePeople } from './merge-same-mobile'; -const FIX_KEY = 'person-mobile-canonical-v1'; +const FIX_KEY = 'person-mobile-canonical-v2'; @Injectable() export class SameMobileMergeService implements OnApplicationBootstrap { @@ -21,17 +21,20 @@ export class SameMobileMergeService implements OnApplicationBootstrap { SELECT 1 AS ok FROM information_schema.columns WHERE table_schema = 'public' - AND table_name = 'Person' - AND column_name = 'gender' + AND lower(table_name) = 'person' + AND lower(column_name) = 'gender' `; - if (!ready.length) return; + if (!ready.length) { + this.logger.warn('یکسان‌سازی موبایل رد شد: ستون gender هنوز نیست'); + return; + } - const table = await this.prisma.$queryRaw<{ ok: number }[]>` - SELECT 1 AS ok - FROM information_schema.tables - WHERE table_schema = 'public' AND table_name = 'DataFix' + await this.prisma.$executeRaw` + CREATE TABLE IF NOT EXISTS "DataFix" ( + "key" TEXT PRIMARY KEY, + "doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ) `; - if (!table.length) return; const done = await this.prisma.$queryRaw<{ key: string }[]>` SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY} @@ -48,11 +51,22 @@ export class SameMobileMergeService implements OnApplicationBootstrap { const orgs = await this.prisma.organization.findMany({ select: { id: true } }); let merged = 0; let rewritten = 0; + let failed = 0; for (const org of orgs) { - const result = await mergeSameMobilePeople(this.prisma, org.id, true); - merged += result.merged; - rewritten += result.rewritten; + try { + const result = await mergeSameMobilePeople(this.prisma, org.id, true); + merged += result.merged; + rewritten += result.rewritten; + failed += result.failed; + } catch (error) { + failed += 1; + this.logger.error( + `یکسان‌سازی موبایل برای محیط ${org.id} انجام نشد`, + error instanceof Error ? error.message : String(error), + ); + } } + if (failed) return; await this.prisma.$executeRaw` INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY}) ON CONFLICT ("key") DO NOTHING From 94f0383c7926458da33e3c5b2e31f1a7cbe1149a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 5 Nov 2026 12:31:27 +0330 Subject: [PATCH 11/12] feat(tahesab): import and refresh customers at chosen hours Each connection can set how many times a day and at which Tehran hours the server pulls Tahesab customers, including name updates for people already linked. Co-authored-by: Cursor --- .../migration.sql | 6 + apps/api/prisma/schema.prisma | 7 + .../tahesab/connections.service.ts | 18 ++ .../tahesab/customer-sync.scheduler.ts | 99 +++++++++++ apps/api/src/integrations/tahesab/dto.ts | 10 ++ .../tahesab/person-link.service.ts | 168 +++++++++++++++++- .../integrations/tahesab/tahesab.module.ts | 2 + apps/web/src/views/TahesabSettingsView.vue | 132 +++++++++++++- packages/shared/src/index.ts | 11 +- 9 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 apps/api/prisma/migrations/20261105140000_tahesab_customer_sync_hours/migration.sql create mode 100644 apps/api/src/integrations/tahesab/customer-sync.scheduler.ts 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; From 1ef7f917719a5169256107c46bfa2acdff080830 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 5 Nov 2026 13:34:41 +0330 Subject: [PATCH 12/12] fix(contacts): keep one customer when Tahesab repeats a mobile A Tahesab import now updates the person who already has that mobile instead of creating another, and the boot merge runs once more so existing pairs collapse. Co-authored-by: Cursor --- apps/api/src/contacts/merge-same-mobile.ts | 15 +++++++ .../src/contacts/same-mobile-merge.service.ts | 2 +- .../tahesab/person-link.service.ts | 42 +++++++++++++++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/api/src/contacts/merge-same-mobile.ts b/apps/api/src/contacts/merge-same-mobile.ts index bc82304..7c9c041 100644 --- a/apps/api/src/contacts/merge-same-mobile.ts +++ b/apps/api/src/contacts/merge-same-mobile.ts @@ -26,6 +26,9 @@ const personSelect = { companyId: true, customFields: true, importSource: true, + sourceRef: true, + previousImportSource: true, + previousSourceRef: true, loyaltyPoints: true, discLabel: true, discReliable: true, @@ -62,6 +65,8 @@ function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Pr let showGenderInName = keeper.showGenderInName; let previousFirstName = keeper.previousFirstName; let previousLastName = keeper.previousLastName; + let previousImportSource = keeper.previousImportSource; + let previousSourceRef = keeper.previousSourceRef; let discLabel = keeper.discLabel; let discReliable = keeper.discReliable; let discAssessedAt = keeper.discAssessedAt; @@ -93,6 +98,14 @@ function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Pr if (blank(previousLastName) && extra.previousLastName) { previousLastName = extra.previousLastName; } + if ( + blank(previousImportSource) && + extra.importSource && + extra.importSource !== keeper.importSource + ) { + previousImportSource = extra.importSource; + previousSourceRef = extra.sourceRef; + } if ( keeper.importSource === 'tahesab' && blank(previousFirstName) && @@ -134,6 +147,8 @@ function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Pr showGenderInName, previousFirstName, previousLastName, + previousImportSource, + previousSourceRef, discLabel, discReliable, discAssessedAt, diff --git a/apps/api/src/contacts/same-mobile-merge.service.ts b/apps/api/src/contacts/same-mobile-merge.service.ts index 907bfeb..bed1c1e 100644 --- a/apps/api/src/contacts/same-mobile-merge.service.ts +++ b/apps/api/src/contacts/same-mobile-merge.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common' import { PrismaService } from '../prisma/prisma.service'; import { mergeSameMobilePeople } from './merge-same-mobile'; -const FIX_KEY = 'person-mobile-canonical-v2'; +const FIX_KEY = 'person-mobile-canonical-v3'; @Injectable() export class SameMobileMergeService implements OnApplicationBootstrap { diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 7cdda80..21c74df 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -601,6 +601,7 @@ export class TahesabPersonLinkService { const sourceRef = String(dto.moshtariCode); let personId = dto.personId; + let preserveExistingLink = false; if (personId) { const existing = await this.getPerson(organizationId, personId); const prevFirst = existing.firstName; @@ -629,21 +630,28 @@ export class TahesabPersonLinkService { }); } else { // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. - // شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ - // وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. + // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود + // تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود. const mobileKeys = phoneIdentityKeys(mobile); const matches = mobileKeys.length ? await this.prisma.person.findMany({ where: { organizationId, OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], - accountingLinks: { none: { connectionId: dto.connectionId } }, + }, + include: { + accountingLinks: { + where: { connectionId: dto.connectionId }, + select: { moshtariCode: true }, + }, }, orderBy: { createdAt: 'asc' }, }) : []; const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; if (byMobile) { + const linkedCode = byMobile.accountingLinks[0]?.moshtariCode; + preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode; const nameChanged = `${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${firstName} ${lastName}`.trim(); @@ -662,7 +670,7 @@ export class TahesabPersonLinkService { : {}), ...carryPreviousSource(byMobile, 'tahesab'), importSource: 'tahesab', - sourceRef, + sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef, ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...canonicalMobileUpdate(mobile, byMobile), }, @@ -688,6 +696,32 @@ export class TahesabPersonLinkService { } } + if (preserveExistingLink) { + await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); + const existing = await this.prisma.personAccountingLink.findFirst({ + where: { personId, connectionId: dto.connectionId }, + include: { + connection: { select: { label: true, dbName: true, isDefault: true } }, + }, + }); + if (!existing) { + throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.'); + } + 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: existing.id, keptLink: true } as Prisma.InputJsonValue, + }, + }); + } + return { personId, link: this.linkDto(existing) }; + } + const link = await this.prisma.personAccountingLink.upsert({ where: { personId_connectionId: {