From 29d99cbded789cfe9a57a5ad77da302957b370bd Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 17:16:31 +0330 Subject: [PATCH] feat: customer groups for people and companies with tahesab group sync and bulk register Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export --- .../migration.sql | 85 +++++ apps/api/prisma/schema.prisma | 49 +++ apps/api/src/app.module.ts | 2 + apps/api/src/contacts/companies.controller.ts | 1 + apps/api/src/contacts/companies.service.ts | 33 +- .../src/contacts/contacts-excel.service.ts | 34 +- apps/api/src/contacts/contacts.module.ts | 2 + apps/api/src/contacts/dto.ts | 21 ++ apps/api/src/contacts/people.controller.ts | 2 + apps/api/src/contacts/people.service.ts | 25 ++ .../customer-groups.controller.ts | 75 +++++ .../customer-groups/customer-groups.module.ts | 12 + .../customer-groups.service.ts | 309 ++++++++++++++++++ apps/api/src/integrations/tahesab/dto.ts | 30 ++ .../tahesab/person-link.service.ts | 234 ++++++++++++- .../tahesab/tahesab.controller.ts | 29 ++ .../integrations/tahesab/tahesab.module.ts | 3 +- .../web/src/components/CustomerGroupChips.vue | 48 +++ .../src/components/CustomerGroupPicker.vue | 68 ++++ .../src/components/CustomerGroupsManager.vue | 295 +++++++++++++++++ apps/web/src/lib/customer-groups.ts | 53 +++ apps/web/src/views/CompanyFormView.vue | 8 +- apps/web/src/views/ContactsView.vue | 89 ++++- apps/web/src/views/PersonFormView.vue | 24 ++ apps/web/src/views/PersonProfileView.vue | 8 +- apps/web/src/views/TahesabSettingsView.vue | 167 ++++++++++ packages/shared/src/index.ts | 48 +++ 27 files changed, 1728 insertions(+), 26 deletions(-) create mode 100644 apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql create mode 100644 apps/api/src/customer-groups/customer-groups.controller.ts create mode 100644 apps/api/src/customer-groups/customer-groups.module.ts create mode 100644 apps/api/src/customer-groups/customer-groups.service.ts create mode 100644 apps/web/src/components/CustomerGroupChips.vue create mode 100644 apps/web/src/components/CustomerGroupPicker.vue create mode 100644 apps/web/src/components/CustomerGroupsManager.vue create mode 100644 apps/web/src/lib/customer-groups.ts diff --git a/apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql b/apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql new file mode 100644 index 0000000..90bac5b --- /dev/null +++ b/apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql @@ -0,0 +1,85 @@ +-- CreateTable +CREATE TABLE "CustomerGroup" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL DEFAULT 'teal', + "position" INTEGER NOT NULL DEFAULT 0, + "tahesabConnectionId" TEXT, + "tahesabGid" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CustomerGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PersonGroupLink" ( + "personId" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'manual', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PersonGroupLink_pkey" PRIMARY KEY ("personId","groupId") +); + +-- CreateTable +CREATE TABLE "CompanyGroupLink" ( + "companyId" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CompanyGroupLink_pkey" PRIMARY KEY ("companyId","groupId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CustomerGroup_organizationId_name_key" ON "CustomerGroup"("organizationId", "name"); +CREATE INDEX "CustomerGroup_organizationId_position_idx" ON "CustomerGroup"("organizationId", "position"); +CREATE INDEX "CustomerGroup_tahesabConnectionId_tahesabGid_idx" ON "CustomerGroup"("tahesabConnectionId", "tahesabGid"); +CREATE INDEX "PersonGroupLink_groupId_idx" ON "PersonGroupLink"("groupId"); +CREATE INDEX "CompanyGroupLink_groupId_idx" ON "CompanyGroupLink"("groupId"); + +-- AddForeignKey +ALTER TABLE "CustomerGroup" ADD CONSTRAINT "CustomerGroup_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- تبدیل داده‌های قبلی: «گروه مشتری» اکسل (customFields.group) و «گروه‌بندی شرکت» (groupName). +-- مقدارهای چندتایی با «،» «,» «/» جدا می‌شوند. برچسب‌های «وضعیت مشتری» که دیدار در همان ستون می‌گذارد گروه حساب نمی‌شوند. +CREATE TEMP TABLE "_cg_src" AS +SELECT p."organizationId" AS org, 'person' AS kind, p."id" AS ref, left(btrim(x), 80) AS name +FROM "Person" p +CROSS JOIN LATERAL regexp_split_to_table(p."customFields"->>'group', '\s*[،,/]\s*') AS x +WHERE COALESCE(p."customFields"->>'group', '') <> '' +UNION ALL +SELECT c."organizationId", 'company', c."id", left(btrim(x), 80) +FROM "Company" c +CROSS JOIN LATERAL regexp_split_to_table(c."groupName", '\s*[،,/]\s*') AS x +WHERE COALESCE(c."groupName", '') <> ''; + +DELETE FROM "_cg_src" +WHERE name = '' + OR name IN ('مشتری تازه وارد', 'مشتری خوشحال', 'مشتری ناراضی', 'مشتری خاص'); + +INSERT INTO "CustomerGroup" ("id", "organizationId", "name", "color", "position", "createdAt", "updatedAt") +SELECT 'cgrp_' || md5(d.org || '|' || d.name), d.org, d.name, 'teal', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM (SELECT DISTINCT org, name FROM "_cg_src") AS d +ON CONFLICT ("organizationId", "name") DO NOTHING; + +INSERT INTO "PersonGroupLink" ("personId", "groupId", "source", "createdAt") +SELECT DISTINCT s.ref, g."id", 'manual', CURRENT_TIMESTAMP +FROM "_cg_src" s +JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name +WHERE s.kind = 'person' +ON CONFLICT DO NOTHING; + +INSERT INTO "CompanyGroupLink" ("companyId", "groupId", "createdAt") +SELECT DISTINCT s.ref, g."id", CURRENT_TIMESTAMP +FROM "_cg_src" s +JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name +WHERE s.kind = 'company' +ON CONFLICT DO NOTHING; + +DROP TABLE "_cg_src"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 682fa2b..ad290fb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -160,6 +160,7 @@ model Organization { orgRoles OrgRole[] teams Team[] companies Company[] + customerGroups CustomerGroup[] people Person[] attendanceEvents AttendanceEvent[] discAssessments DiscAssessment[] @@ -477,11 +478,13 @@ model Company { city String? postalCode String? address String? + /// قدیمی؛ از نسخهٔ گروه‌بندی، گروه‌ها در CompanyGroupLink هستند groupName String? notes String? registeredAt DateTime? tags String[] @default([]) customFields Json @default("{}") + groupLinks CompanyGroupLink[] people Person[] deals Deal[] activities Activity[] @@ -505,6 +508,51 @@ model Company { @@index([ownerId]) } +/// گروه مشتری (مشترک بین اشخاص و شرکت‌ها؛ هر مشتری چند گروه) +model CustomerGroup { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + color String @default("teal") + position Int @default(0) + /// گروه متناظر در ته‌حساب (GID) روی همان اتصال + tahesabConnectionId String? + tahesabGid String? + people PersonGroupLink[] + companies CompanyGroupLink[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, name]) + @@index([organizationId, position]) + @@index([tahesabConnectionId, tahesabGid]) +} + +model PersonGroupLink { + personId String + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + groupId String + group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + /// manual | tahesab — گروهِ ته‌حسابی با همگام‌سازی جایگزین می‌شود؛ دستی دست نمی‌خورد + source String @default("manual") + createdAt DateTime @default(now()) + + @@id([personId, groupId]) + @@index([groupId]) +} + +model CompanyGroupLink { + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + groupId String + group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@id([companyId, groupId]) + @@index([groupId]) +} + model Person { id String @id @default(cuid()) organizationId String @@ -541,6 +589,7 @@ model Person { mood PersonMood @default(newcomer) tags String[] @default([]) customFields Json @default("{}") + groupLinks PersonGroupLink[] loyaltyPoints Int @default(0) loyaltyTierId String? loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a99fc5b..6898095 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -54,6 +54,7 @@ import { DeletionsModule } from './deletions/deletions.module'; import { OutboundSmsModule } from './outbound-sms/outbound-sms.module'; import { BehaviorModule } from './behavior/behavior.module'; import { SmsBillingModule } from './sms-billing/sms-billing.module'; +import { CustomerGroupsModule } from './customer-groups/customer-groups.module'; @Module({ imports: [ @@ -65,6 +66,7 @@ import { SmsBillingModule } from './sms-billing/sms-billing.module'; AiModule, PrismaModule, SmsBillingModule, + CustomerGroupsModule, OutboundSmsModule, StorageModule, HealthModule, diff --git a/apps/api/src/contacts/companies.controller.ts b/apps/api/src/contacts/companies.controller.ts index 893a4c8..32dd2e7 100644 --- a/apps/api/src/contacts/companies.controller.ts +++ b/apps/api/src/contacts/companies.controller.ts @@ -47,6 +47,7 @@ export class CompaniesController { query.q, query.page ?? 1, query.limit ?? 20, + { groupId: query.groupId }, ); } diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index efdeb3f..70d6b3a 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -27,6 +27,10 @@ import { normalizeTags } from '../common/tags'; import { normalizePersonImportSource } from './person-source'; import { buildSearchWhere, ci } from '../common/search'; import { GamificationService } from '../gamification/gamification.service'; +import { + CustomerGroupsService, + groupSummarySelect, +} from '../customer-groups/customer-groups.service'; function parseDateOrNull(raw?: string | null): Date | null { if (!raw?.trim()) return null; @@ -37,6 +41,10 @@ function parseDateOrNull(raw?: string | null): Date | null { const companyInclude = { _count: { select: { people: true } }, owner: { select: { id: true, fullName: true } }, + groupLinks: { + select: { group: { select: groupSummarySelect } }, + orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }], + }, } satisfies Prisma.CompanyInclude; type CompanyRow = { @@ -67,6 +75,7 @@ type CompanyRow = { updatedAt: Date; _count: { people: number }; owner: { id: string; fullName: string } | null; + groupLinks: Array<{ group: { id: string; name: string; color: string } }>; }; @Injectable() @@ -74,6 +83,7 @@ export class CompaniesService { constructor( private readonly prisma: PrismaService, private readonly game: GamificationService, + private readonly groups: CustomerGroupsService, ) {} async list( @@ -81,6 +91,7 @@ export class CompaniesService { q?: string, page = 1, limit = 20, + opts?: { groupId?: string }, ): Promise> { const search = buildSearchWhere(q, (t) => [ { name: ci(t) }, @@ -94,10 +105,15 @@ export class CompaniesService { { city: ci(t) }, { province: ci(t) }, ]); + const groupId = opts?.groupId?.trim(); + const and: Prisma.CompanyWhereInput[] = [ + ...(search ? [search] : []), + ...(groupId ? [{ groupLinks: { some: { groupId } } }] : []), + ]; const where: Prisma.CompanyWhereInput = { organizationId: actor.organizationId, ...ownerWhere(actor), - ...(search ? { AND: [search] } : {}), + ...(and.length ? { AND: and } : {}), }; const [rows, total] = await this.prisma.$transaction([ @@ -156,11 +172,19 @@ export class CompaniesService { customFields: toJsonCustomFields(dto.customFields), }; await this.assertNoDuplicate(actor.organizationId, data); + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); - const row = await this.prisma.company.create({ + let row = await this.prisma.company.create({ data, include: companyInclude, }); + if (groupIds.length) { + await this.groups.addCompanyGroups(row.id, groupIds); + row = await this.prisma.company.findUniqueOrThrow({ + where: { id: row.id }, + include: companyInclude, + }); + } if (row.importSource === 'manual') { this.game.emit({ organizationId: actor.organizationId, @@ -214,6 +238,10 @@ export class CompaniesService { } await this.assertNoDuplicate(actor.organizationId, data, id); + if (dto.groupIds !== undefined) { + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); + await this.groups.setCompanyGroups(id, groupIds); + } const row = await this.prisma.company.update({ where: { id }, data, @@ -315,6 +343,7 @@ export class CompaniesService { registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null, tags: row.tags ?? [], customFields: asCustomFields(row.customFields), + groups: (row.groupLinks ?? []).map((l) => l.group), peopleCount: row._count.people, owner: row.owner, createdAt: row.createdAt.toISOString(), diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index 56870ca..5a2670b 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -25,6 +25,10 @@ import { CompaniesService } from './companies.service'; import { PeopleService } from './people.service'; import { normalizeEmail, normalizePhone } from './normalize'; import { normalizePersonImportSource } from './person-source'; +import { + CustomerGroupsService, + splitGroupNames, +} from '../customer-groups/customer-groups.service'; type ImportChunkOpts = { sessionId?: string; @@ -39,6 +43,16 @@ function parseMood(raw?: string): PersonMood | undefined { return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value); } +/** ستون‌های گروه اکسل → نام گروه‌ها (برچسب‌های وضعیت مشتری گروه حساب نمی‌شوند) */ +function groupNamesFrom(...raws: Array): string[] { + const names = raws.flatMap((raw) => splitGroupNames(raw)); + return [...new Set(names.filter((name) => !parseMood(name)))]; +} + +function groupNamesText(groups: Array<{ name: string }> | undefined, fallback?: string) { + return groups?.length ? groups.map((g) => g.name).join('، ') : (fallback ?? ''); +} + function splitDidarName(firstName: string, lastName: string, title?: string) { const first = firstName.trim(); const last = lastName.trim(); @@ -369,6 +383,7 @@ export class ContactsExcelService { private readonly prisma: PrismaService, private readonly people: PeopleService, private readonly companies: CompaniesService, + private readonly groups: CustomerGroupsService, ) {} async exportPeople(actor: Actor, q?: string): Promise { @@ -378,6 +393,7 @@ export class ContactsExcelService { CONTACT_COLUMNS, items.map((p) => { const extra = (p.customFields ?? {}) as Record; + const groupText = groupNamesText(p.groups, extra.group); return { _type: 'Contact', didarId: extra.didarId ?? p.id, @@ -402,7 +418,7 @@ export class ContactsExcelService { nationalId: extra.nationalId ?? '', registeredAt: formatJalaliDate(p.createdAt), birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), - mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'], + mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'], otherPhones: extra.otherPhones ?? '', address: p.addresses[0] ?? extra.address ?? '', website: p.website ?? extra.website ?? '', @@ -410,7 +426,7 @@ export class ContactsExcelService { position2: p.position ?? extra.position ?? '', city2: extra.city ?? '', code2: extra.customerCode ?? '', - group2: extra.group ?? '', + group2: groupText, address2: p.addresses[1] ?? extra.address ?? '', phone2: extra.phone2 ?? p.phone ?? '', postalCode2: extra.postalCode ?? '', @@ -418,7 +434,7 @@ export class ContactsExcelService { homeAddress: p.addresses[2] ?? extra.homeAddress ?? '', birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), instagram: p.instagram ?? extra.instagram ?? '', - group: extra.group ?? '', + group: groupText, colleagueContact: extra.colleagueContact ?? '', previousCompany: extra.previousCompany ?? '', customerType: extra.customerType ?? '', @@ -456,7 +472,7 @@ export class ContactsExcelService { registeredAt: formatJalaliDate( c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt), ), - groupName: c.groupName ?? '', + groupName: groupNamesText(c.groups, c.groupName ?? ''), otherPhones: c.otherPhones ?? '', address: c.address ?? '', website: c.website ?? '', @@ -551,6 +567,8 @@ export class ContactsExcelService { const value = row[key]?.trim(); if (value) customFields[key] = value; } + const groupNames = groupNamesFrom(row.group, parseMood(row.mood) ? undefined : row.mood); + if (groupNames.length) delete customFields.group; const addresses = collectAddresses( row.address, @@ -576,6 +594,7 @@ export class ContactsExcelService { ? normalizePersonImportSource(sourceCol) : 'excel'; const sourceRef = didarId || customerCode || null; + const groupIds = await this.groups.idsForNames(actor.organizationId, groupNames); await this.people.create(actor, { firstName: names.firstName, @@ -596,6 +615,7 @@ export class ContactsExcelService { importSource, sourceRef, registeredAt: registeredAt?.toISOString() ?? null, + groupIds, }); result.created += 1; if (ownerMissing) { @@ -701,8 +721,13 @@ export class ContactsExcelService { }; try { + const groupIds = await this.groups.idsForNames( + actor.organizationId, + groupNamesFrom(row.groupName), + ); const existing = await this.findImportedCompany(actor, didarId, name); if (existing) { + if (groupIds.length) await this.groups.addCompanyGroups(existing.id, groupIds); const merged: Record = {}; for (const [k, v] of Object.entries(payload)) { if (k === 'customFields') continue; @@ -719,6 +744,7 @@ export class ContactsExcelService { await this.companies.create(actor, { ...payload, ownerId: resolveOwnerId(actor, ownerId ?? null), + groupIds, }); result.created += 1; } diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index c374471..0c21610 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -9,6 +9,7 @@ import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; import { BehaviorModule } from '../behavior/behavior.module'; +import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { BehaviorModule } from '../behavior/behavior.module'; MessageAutomationsModule, PublicApiModule, BehaviorModule, + CustomerGroupsModule, ], controllers: [PeopleController, CompaniesController], providers: [PeopleService, CompaniesService, ContactsExcelService], diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index 9e33be2..d03b8fd 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -86,6 +86,12 @@ export class ListQueryDto { @IsString() @MaxLength(12) disc?: string; + + /** فیلتر گروه مشتری */ + @IsOptional() + @IsString() + @MaxLength(40) + groupId?: string; } class CompanyExtraFieldsDto { @@ -153,6 +159,11 @@ class CompanyExtraFieldsDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() registeredAt?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + groupIds?: string[]; } export class CreateCompanyDto extends CompanyExtraFieldsDto { @@ -370,6 +381,11 @@ export class CreatePersonDto { @IsOptional() @IsString() registeredAt?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + groupIds?: string[]; } export class UpdatePersonDto { @@ -478,4 +494,9 @@ export class UpdatePersonDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() ownerId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + groupIds?: string[]; } diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index c8fad31..cf7c878 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -62,6 +62,7 @@ export class PeopleController { city: query.city, hasMobile, disc: query.disc, + groupId: query.groupId, }, ); } @@ -79,6 +80,7 @@ export class PeopleController { city: query.city, hasMobile, disc: query.disc, + groupId: query.groupId, }); } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 3967729..0e768e8 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -51,6 +51,10 @@ import { personImportSourceLabel, } from './person-source'; import { BehaviorAccessService } from '../behavior/behavior-access.service'; +import { + CustomerGroupsService, + groupSummarySelect, +} from '../customer-groups/customer-groups.service'; const personInclude = { company: { select: { id: true, name: true, province: true, city: true } }, @@ -58,6 +62,10 @@ const personInclude = { loyaltyTier: { select: { id: true, name: true, minPoints: true, color: true }, }, + groupLinks: { + select: { source: true, group: { select: groupSummarySelect } }, + orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }], + }, } satisfies Prisma.PersonInclude; type PersonRow = { @@ -102,6 +110,7 @@ type PersonRow = { discLabel: string | null; discReliable: boolean; discAssessedAt: Date | null; + groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>; }; function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null { @@ -134,6 +143,7 @@ export class PeopleService implements OnModuleInit { private readonly sms: SmsGatewayService, private readonly outboundSms: OutboundSmsLogService, private readonly behaviorAccess: BehaviorAccessService, + private readonly groups: CustomerGroupsService, ) {} private personListWhere( @@ -146,6 +156,7 @@ export class PeopleService implements OnModuleInit { city?: string; hasMobile?: boolean; disc?: string; + groupId?: string; }, ): Prisma.PersonWhereInput { const sourceFilter = opts?.importSource?.trim() @@ -190,6 +201,8 @@ export class PeopleService implements OnModuleInit { } const disc = discFilter(opts?.disc); if (disc) and.push(disc); + const groupId = opts?.groupId?.trim(); + if (groupId) and.push({ groupLinks: { some: { groupId } } }); return { organizationId: actor.organizationId, ...ownerWhere(actor), @@ -212,6 +225,7 @@ export class PeopleService implements OnModuleInit { city?: string; hasMobile?: boolean; disc?: string; + groupId?: string; }, ): Promise> { const seeDisc = await this.behaviorAccess.canView(actor); @@ -249,6 +263,7 @@ export class PeopleService implements OnModuleInit { city?: string; hasMobile?: boolean; disc?: string; + groupId?: string; }, max = FILTERED_IDS_MAX, ): Promise { @@ -396,11 +411,13 @@ export class PeopleService implements OnModuleInit { : {}), }; await this.assertNoDuplicate(actor.organizationId, data); + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); const row = await this.prisma.person.create({ data, include: personInclude, }); + if (groupIds.length) await this.groups.addPersonGroups(row.id, groupIds); await this.loyalty.applyWelcomePoints(actor.organizationId, row.id); const createdCtx = { organizationId: actor.organizationId, @@ -489,6 +506,10 @@ export class PeopleService implements OnModuleInit { } await this.assertNoDuplicate(actor.organizationId, data, id); + if (dto.groupIds !== undefined) { + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); + await this.groups.setPersonGroups(id, groupIds); + } const row = await this.prisma.person.update({ where: { id }, data, @@ -776,6 +797,10 @@ export class PeopleService implements OnModuleInit { mood: row.mood, tags: row.tags ?? [], customFields: asCustomFields(row.customFields), + groups: (row.groupLinks ?? []).map((l) => ({ + ...l.group, + source: l.source === 'tahesab' ? ('tahesab' as const) : ('manual' as const), + })), loyaltyPoints: row.loyaltyPoints ?? 0, loyaltyTier: row.loyaltyTier, discLabel: includeDisc && row.discReliable ? row.discLabel : null, diff --git a/apps/api/src/customer-groups/customer-groups.controller.ts b/apps/api/src/customer-groups/customer-groups.controller.ts new file mode 100644 index 0000000..fac7798 --- /dev/null +++ b/apps/api/src/customer-groups/customer-groups.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; +import { CustomerGroupsService } from './customer-groups.service'; + +class CreateCustomerGroupDto { + @IsString() + @MinLength(1) + @MaxLength(80) + name!: string; + + @IsOptional() + @IsIn([...CUSTOMER_GROUP_COLORS]) + color?: CustomerGroupColor; +} + +class UpdateCustomerGroupDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(80) + name?: string; + + @IsOptional() + @IsIn([...CUSTOMER_GROUP_COLORS]) + color?: CustomerGroupColor; +} + +@Controller('customer-groups') +@UseGuards(JwtAuthGuard, RolesGuard) +export class CustomerGroupsController { + constructor(private readonly groups: CustomerGroupsService) {} + + @Get() + list(@CurrentUser() user: Actor) { + return this.groups.list(user.organizationId); + } + + @Post() + @Roles('owner', 'admin') + create(@CurrentUser() user: Actor, @Body() dto: CreateCustomerGroupDto) { + return this.groups.create(user.organizationId, dto); + } + + @Patch(':id') + @Roles('owner', 'admin') + update( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: UpdateCustomerGroupDto, + ) { + return this.groups.update(user.organizationId, id, dto); + } + + @Delete(':id') + @Roles('owner', 'admin') + async remove(@CurrentUser() user: Actor, @Param('id') id: string) { + await this.groups.remove(user.organizationId, id); + return { ok: true }; + } +} diff --git a/apps/api/src/customer-groups/customer-groups.module.ts b/apps/api/src/customer-groups/customer-groups.module.ts new file mode 100644 index 0000000..ae0b712 --- /dev/null +++ b/apps/api/src/customer-groups/customer-groups.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CustomerGroupsController } from './customer-groups.controller'; +import { CustomerGroupsService } from './customer-groups.service'; + +@Module({ + imports: [AuthModule], + controllers: [CustomerGroupsController], + providers: [CustomerGroupsService], + exports: [CustomerGroupsService], +}) +export class CustomerGroupsModule {} diff --git a/apps/api/src/customer-groups/customer-groups.service.ts b/apps/api/src/customer-groups/customer-groups.service.ts new file mode 100644 index 0000000..3140d50 --- /dev/null +++ b/apps/api/src/customer-groups/customer-groups.service.ts @@ -0,0 +1,309 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared'; +import { CUSTOMER_GROUP_COLORS } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +const NAME_MAX = 80; + +/** نام گروه برای مقایسه: فاصله‌های اضافه و ی/ک عربی یکسان می‌شوند */ +function normalizeGroupName(raw: string): string { + return raw + .replace(/\u064A/g, '\u06CC') + .replace(/\u0643/g, '\u06A9') + .replace(/\s+/g, ' ') + .trim() + .slice(0, NAME_MAX); +} + +/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */ +export function splitGroupNames(raw: string | null | undefined): string[] { + if (!raw) return []; + return [ + ...new Set( + raw + .split(/\s*[،,/]\s*/) + .map(normalizeGroupName) + .filter(Boolean), + ), + ]; +} + +export const groupSummarySelect = { + id: true, + name: true, + color: true, +} satisfies Prisma.CustomerGroupSelect; + +@Injectable() +export class CustomerGroupsService { + constructor(private readonly prisma: PrismaService) {} + + async list(organizationId: string): Promise { + const rows = await this.prisma.customerGroup.findMany({ + where: { organizationId }, + orderBy: [{ position: 'asc' }, { name: 'asc' }], + include: { _count: { select: { people: true, companies: true } } }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + color: r.color, + position: r.position, + fromTahesab: Boolean(r.tahesabGid), + personCount: r._count.people, + companyCount: r._count.companies, + })); + } + + async create( + organizationId: string, + dto: { name: string; color?: CustomerGroupColor }, + ): Promise { + const name = this.cleanName(dto.name); + const max = await this.prisma.customerGroup.aggregate({ + where: { organizationId }, + _max: { position: true }, + }); + try { + await this.prisma.customerGroup.create({ + data: { + organizationId, + name, + color: this.cleanColor(dto.color), + position: (max._max.position ?? -1) + 1, + }, + }); + } catch (e) { + throw this.mapError(e); + } + return (await this.list(organizationId)).find((g) => g.name === name)!; + } + + async update( + organizationId: string, + id: string, + dto: { name?: string; color?: CustomerGroupColor }, + ): Promise { + await this.require(organizationId, id); + try { + await this.prisma.customerGroup.update({ + where: { id }, + data: { + ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}), + ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), + }, + }); + } catch (e) { + throw this.mapError(e); + } + return (await this.list(organizationId)).find((g) => g.id === id)!; + } + + /** مشتری‌ها حذف نمی‌شوند؛ فقط این گروه از رویشان برداشته می‌شود */ + async remove(organizationId: string, id: string): Promise { + await this.require(organizationId, id); + await this.prisma.customerGroup.delete({ where: { id } }); + } + + /** شناسه‌های معتبر همین محیط (بقیه نادیده گرفته می‌شوند) */ + async validIds(organizationId: string, ids: string[] | undefined): Promise { + const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))]; + if (!unique.length) return []; + const rows = await this.prisma.customerGroup.findMany({ + where: { organizationId, id: { in: unique } }, + select: { id: true }, + }); + if (rows.length !== unique.length) { + throw new BadRequestException('یکی از گروه‌های انتخاب‌شده معتبر نیست'); + } + return rows.map((r) => r.id); + } + + /** گروه‌ها با نام (برای اکسل)؛ نبود → ساخته می‌شود */ + async idsForNames(organizationId: string, names: string[]): Promise { + const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))]; + if (!clean.length) return []; + const ids: string[] = []; + for (const name of clean) { + ids.push((await this.findOrCreateByName(organizationId, name)).id); + } + return ids; + } + + private async findOrCreateByName(organizationId: string, name: string) { + const existing = await this.prisma.customerGroup.findFirst({ + where: { organizationId, name: { equals: name, mode: 'insensitive' } }, + select: { id: true, tahesabGid: true }, + }); + if (existing) return existing; + const max = await this.prisma.customerGroup.aggregate({ + where: { organizationId }, + _max: { position: true }, + }); + try { + return await this.prisma.customerGroup.create({ + data: { organizationId, name, position: (max._max.position ?? -1) + 1 }, + select: { id: true, tahesabGid: true }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + return this.prisma.customerGroup.findFirstOrThrow({ + where: { organizationId, name }, + select: { id: true, tahesabGid: true }, + }); + } + throw e; + } + } + + /** + * گروه متناظر یک گروه ته‌حساب: اول با GID روی همان اتصال، بعد با نام. + * با تغییر نام در ته‌حساب گروه تکراری ساخته نمی‌شود. + */ + async ensureTahesabGroup( + organizationId: string, + connectionId: string, + gid: string | null, + rawName: string, + ): Promise { + const name = normalizeGroupName(rawName); + const cleanGid = gid?.trim() || null; + if (!name && !cleanGid) return null; + if (cleanGid) { + const byGid = await this.prisma.customerGroup.findFirst({ + where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid }, + select: { id: true, name: true }, + }); + if (byGid) { + if (name && byGid.name !== name) { + await this.prisma.customerGroup + .update({ where: { id: byGid.id }, data: { name } }) + .catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی می‌ماند + } + return byGid.id; + } + } + if (!name) return null; + const group = await this.findOrCreateByName(organizationId, name); + if (cleanGid && !group.tahesabGid) { + await this.prisma.customerGroup.update({ + where: { id: group.id }, + data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid }, + }); + } + return group.id; + } + + /** گروه‌های دستی شخص (جایگزین کامل). منبع لینک‌هایی که می‌مانند حفظ می‌شود. */ + async setPersonGroups(personId: string, groupIds: string[]): Promise { + await this.prisma.$transaction([ + this.prisma.personGroupLink.deleteMany({ + where: { personId, groupId: { notIn: groupIds } }, + }), + this.prisma.personGroupLink.createMany({ + data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })), + skipDuplicates: true, + }), + ]); + } + + async setCompanyGroups(companyId: string, groupIds: string[]): Promise { + await this.prisma.$transaction([ + this.prisma.companyGroupLink.deleteMany({ + where: { companyId, groupId: { notIn: groupIds } }, + }), + this.prisma.companyGroupLink.createMany({ + data: groupIds.map((groupId) => ({ companyId, groupId })), + skipDuplicates: true, + }), + ]); + } + + /** فقط اضافه می‌کند (ایمپورت اکسل) */ + async addPersonGroups(personId: string, groupIds: string[]): Promise { + if (!groupIds.length) return; + await this.prisma.personGroupLink.createMany({ + data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })), + skipDuplicates: true, + }); + } + + async addCompanyGroups(companyId: string, groupIds: string[]): Promise { + if (!groupIds.length) return; + await this.prisma.companyGroupLink.createMany({ + data: groupIds.map((groupId) => ({ companyId, groupId })), + skipDuplicates: true, + }); + } + + /** + * گروهِ آمده از ته‌حساب: گروه ته‌حسابی قبلی برداشته و این یکی گذاشته می‌شود. + * گروه‌های دستی دست نمی‌خورند. اگر همین گروه از قبل دستی بوده، دستی می‌ماند. + * @returns آیا چیزی عوض شد + */ + async setPersonTahesabGroup(personId: string, groupId: string | null): Promise { + const current = await this.prisma.personGroupLink.findMany({ + where: { personId }, + select: { groupId: true, source: true }, + }); + const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId); + const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true; + if (!staleTahesab.length && alreadyLinked) return false; + await this.prisma.$transaction([ + ...(staleTahesab.length + ? [ + this.prisma.personGroupLink.deleteMany({ + where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } }, + }), + ] + : []), + ...(groupId && !alreadyLinked + ? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })] + : []), + ]); + return true; + } + + /** گروهی که موقع «ثبت در ته‌حساب» فرستاده می‌شود: اولین گروه (به ترتیب گروه‌ها) */ + async firstGroupName(personId: string): Promise { + const link = await this.prisma.personGroupLink.findFirst({ + where: { personId }, + orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }], + select: { group: { select: { name: true } } }, + }); + return link?.group.name ?? null; + } + + private async require(organizationId: string, id: string) { + const row = await this.prisma.customerGroup.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + if (!row) throw new NotFoundException('گروه پیدا نشد'); + } + + private cleanName(raw: string): string { + const name = normalizeGroupName(raw ?? ''); + if (!name) throw new BadRequestException('نام گروه لازم است'); + return name; + } + + private cleanColor(raw?: string): CustomerGroupColor { + return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '') + ? (raw as CustomerGroupColor) + : 'teal'; + } + + private mapError(e: unknown) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + return new ConflictException('گروهی با این نام وجود دارد'); + } + return e; + } +} diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 9481a75..63e8206 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -1,4 +1,5 @@ import { + ArrayMaxSize, IsArray, IsBoolean, IsIn, @@ -176,6 +177,35 @@ export class ImportBulkFromTahesabDto { pageSize?: number; } +export class SyncGroupsPageDto { + @IsString() + connectionId!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(100) + @Max(2000) + pageSize?: number; +} + +export class ExportPeopleToTahesabDto { + @IsString() + connectionId!: string; + + /** هر درخواست چند نفر؛ هر نفر چند تماس با ته‌حساب دارد */ + @IsArray() + @ArrayMaxSize(25) + @IsString({ each: true }) + personIds!: string[]; +} + export class SearchTahesabDto { @IsString() connectionId!: string; diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index efcc169..703cbc7 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -12,13 +12,16 @@ import { carryPreviousSource } from '../../contacts/person-source'; import { TahesabConnectionsService } from './connections.service'; import { CreateInTahesabDto, + ExportPeopleToTahesabDto, ImportBulkFromTahesabDto, ImportFromTahesabDto, ManualLinkDto, MatchPersonDto, SearchTahesabDto, + SyncGroupsPageDto, } from './dto'; -import { parseBirthDate } from '../../common/excel'; +import { formatJalaliDate, parseBirthDate } from '../../common/excel'; +import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ @@ -71,6 +74,23 @@ function pickBirthDate(row: Record): Date | null { return parseBirthDate(String(raw)); } +function firstAddress(raw: unknown): string | undefined { + if (!Array.isArray(raw)) return undefined; + const first = raw.find((a) => typeof a === 'string' && a.trim()); + return typeof first === 'string' ? first.trim() : undefined; +} + +/** گروه مشتری در ته‌حساب (فیلد نام در پاسخ ته‌حساب «GoroupName» غلط‌املایی دارد) */ +function pickGroup(row: Record): { gid: string | null; name: string } | null { + const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId; + const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup; + if (rawGid == null && rawName == null) return null; + const gid = rawGid == null ? null : String(rawGid).trim() || null; + const name = rawName == null ? '' : String(rawName).trim(); + if (!gid && !name) return null; + return { gid, name }; +} + @Injectable() export class TahesabPersonLinkService { private readonly logger = new Logger(TahesabPersonLinkService.name); @@ -78,8 +98,37 @@ export class TahesabPersonLinkService { constructor( private readonly prisma: PrismaService, private readonly connections: TahesabConnectionsService, + private readonly groups: CustomerGroupsService, ) {} + /** + * گروه ته‌حساب روی شخص: فقط گروهِ آمده از ته‌حساب عوض می‌شود و گروه‌های دستی می‌مانند. + * @returns آیا گروه شخص عوض شد + */ + private async applyTahesabGroup( + organizationId: string, + connectionId: string, + personId: string, + row: Record | undefined, + ): Promise { + const group = row ? pickGroup(row) : null; + if (!group) return false; + try { + const groupId = await this.groups.ensureTahesabGroup( + organizationId, + connectionId, + group.gid, + group.name, + ); + return await this.groups.setPersonTahesabGroup(personId, groupId); + } catch (err) { + this.logger.warn( + `گروه ته‌حساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`, + ); + return false; + } + } + private async getPerson(organizationId: string, personId: string) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, @@ -305,10 +354,17 @@ export class TahesabPersonLinkService { person.mobile ?? person.phone ?? ''; + const extra = (person.customFields ?? {}) as Record; + const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : ''; + const groupName = + dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM'; const args = SanadBuilders.newMoshtari({ name: `${person.firstName} ${person.lastName}`.trim(), - groupName: dto.groupName ?? 'CRM', + groupName, tel, + address: firstAddress(person.addresses), + codeMeli: nationalId || undefined, + tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined, jensFelez: dto.jensFelez ?? 0, }); const res = await api.doNewMoshtari(args); @@ -628,6 +684,7 @@ export class TahesabPersonLinkService { connection: { select: { label: true, dbName: true, isDefault: true } }, }, }); + await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); await this.prisma.accountingSyncJob.create({ data: { @@ -645,6 +702,7 @@ export class TahesabPersonLinkService { async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1); + const rowsByCode = new Map>(); if (dto.listAll) { const { api } = await this.connections.getApi( @@ -656,10 +714,11 @@ export class TahesabPersonLinkService { dto.page ?? 1, dto.pageSize, ); - const listedCodes = listed.rows - .map((row) => pickCode(row)) - .filter((c): c is number => c != null); - codes = [...new Set([...codes, ...listedCodes])]; + for (const row of listed.rows) { + const code = pickCode(row); + if (code != null) rowsByCode.set(code, row); + } + codes = [...new Set([...codes, ...rowsByCode.keys()])]; } else if (dto.fromCode != null && dto.toCode != null) { if (dto.toCode < dto.fromCode) { throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); @@ -674,10 +733,11 @@ export class TahesabPersonLinkService { dto.connectionId, ); const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode); - const listed = asMoshtariList(raw) - .map((row) => pickCode(row)) - .filter((c): c is number => c != null); - codes = [...new Set([...codes, ...listed])]; + for (const row of asMoshtariList(raw)) { + const code = pickCode(row); + if (code != null) rowsByCode.set(code, row); + } + codes = [...new Set([...codes, ...rowsByCode.keys()])]; } if (!codes.length) { @@ -708,11 +768,13 @@ export class TahesabPersonLinkService { const pending = codes.filter((c) => !linkedByCode.has(c)); let bulkApi: TahesabApi | undefined; - const rowsByCode = new Map>(); + const missingRows = pending.filter((c) => !rowsByCode.has(c)); if (pending.length) { bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api; - const minCode = Math.min(...pending); - const maxCode = Math.max(...pending); + } + if (bulkApi && missingRows.length) { + const minCode = Math.min(...missingRows); + const maxCode = Math.max(...missingRows); if (maxCode - minCode <= 2000) { try { const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode); @@ -737,6 +799,12 @@ export class TahesabPersonLinkService { data: { birthDate }, }); } + await this.applyTahesabGroup( + organizationId, + dto.connectionId, + linkedPersonId, + rowsByCode.get(moshtariCode), + ); items.push({ moshtariCode, ok: true, @@ -776,6 +844,146 @@ export class TahesabPersonLinkService { return { imported, skipped, failed, items }; } + /** یک صفحه (بازهٔ کد) از ته‌حساب: گروه مشتری‌های لینک‌شده به‌روز می‌شود */ + async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) { + const { api } = await this.connections.getApi(organizationId, dto.connectionId); + const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize); + const rowsByCode = new Map>(); + for (const row of listed.rows) { + const code = pickCode(row); + if (code != null) rowsByCode.set(code, row); + } + const links = rowsByCode.size + ? await this.prisma.personAccountingLink.findMany({ + where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } }, + select: { moshtariCode: true, personId: true }, + }) + : []; + let updated = 0; + for (const link of links) { + const changed = await this.applyTahesabGroup( + organizationId, + dto.connectionId, + link.personId, + rowsByCode.get(link.moshtariCode), + ); + if (changed) updated += 1; + } + return { + page: listed.page, + fromCode: listed.fromCode, + toCode: listed.toCode, + rows: listed.rows.length, + linked: links.length, + updated, + }; + } + + /** اشخاصی که روی این اتصال به ته‌حساب لینک نیستند */ + async unlinkedPeople(organizationId: string, connectionId: string) { + await this.connections.getOrThrow(organizationId, connectionId); + const rows = await this.prisma.person.findMany({ + where: { organizationId, accountingLinks: { none: { connectionId } } }, + select: { id: true }, + orderBy: { createdAt: 'asc' }, + }); + return { ids: rows.map((r) => r.id), total: rows.length }; + } + + /** + * ثبت گروهی در ته‌حساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛ + * تطبیق یکتا → لینک، چند نتیجه → رد می‌شود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری. + */ + async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) { + await this.connections.getOrThrow(organizationId, dto.connectionId); + const ids = [...new Set(dto.personIds)]; + const people = await this.prisma.person.findMany({ + where: { organizationId, id: { in: ids } }, + select: { + id: true, + firstName: true, + lastName: true, + accountingLinks: { + where: { connectionId: dto.connectionId }, + select: { moshtariCode: true }, + }, + }, + }); + const byId = new Map(people.map((p) => [p.id, p])); + + const items: Array<{ + personId: string; + name: string; + status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error'; + moshtariCode?: number; + error?: string; + }> = []; + + for (const personId of ids) { + const person = byId.get(personId); + if (!person) { + items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' }); + continue; + } + const name = `${person.firstName} ${person.lastName}`.trim(); + const existing = person.accountingLinks[0]; + if (existing) { + items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode }); + continue; + } + try { + const matched = await this.match(organizationId, personId, { + connectionId: dto.connectionId, + autoLinkIfUnique: true, + }); + if (matched.status === 'linked' && matched.link) { + items.push({ + personId, + name, + status: 'matched', + moshtariCode: matched.link.moshtariCode, + }); + } else if (matched.status === 'none') { + const link = await this.createInTahesab(organizationId, personId, { + connectionId: dto.connectionId, + }); + items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode }); + } else { + items.push({ personId, name, status: 'ambiguous' }); + } + } catch (e) { + items.push({ + personId, + name, + status: 'error', + error: e instanceof Error ? e.message : 'ثبت ناموفق بود', + }); + } + } + + const count = (s: (typeof items)[number]['status']) => + items.filter((i) => i.status === s).length; + const summary = { + matched: count('matched'), + created: count('created'), + ambiguous: count('ambiguous'), + skipped: count('skipped'), + failed: count('error'), + }; + await this.prisma.accountingSyncJob.create({ + data: { + organizationId, + connectionId: dto.connectionId, + kind: 'export', + status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success', + request: { count: ids.length } as Prisma.InputJsonValue, + result: summary as Prisma.InputJsonValue, + }, + }); + + return { ...summary, items }; + } + /** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */ async fillMissingBirthDates() { const connections = await this.prisma.accountingConnection.findMany({ diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index 5d09d6a..4d76861 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -21,6 +21,7 @@ import { TahesabDocumentsService } from './documents.service'; import { CreateInTahesabDto, CreateTahesabConnectionDto, + ExportPeopleToTahesabDto, ImportBulkFromTahesabDto, ImportBulkProductsFromTahesabDto, ImportFromTahesabDto, @@ -30,6 +31,7 @@ import { PushSanadDto, SearchTahesabDto, SearchTahesabProductsDto, + SyncGroupsPageDto, UpdateTahesabConnectionDto, } from './dto'; @@ -216,6 +218,33 @@ export class TahesabController { return this.links.importBulk(user.organizationId, dto); } + @Post('sync/groups') + @Roles('owner', 'admin') + syncGroups( + @CurrentUser() user: { organizationId: string }, + @Body() dto: SyncGroupsPageDto, + ) { + return this.links.syncGroupsPage(user.organizationId, dto); + } + + @Get('sync/unlinked-people') + @Roles('owner', 'admin') + unlinkedPeople( + @CurrentUser() user: { organizationId: string }, + @Query('connectionId') connectionId: string, + ) { + return this.links.unlinkedPeople(user.organizationId, connectionId); + } + + @Post('sync/export-people') + @Roles('owner', 'admin') + exportPeople( + @CurrentUser() user: { organizationId: string }, + @Body() dto: ExportPeopleToTahesabDto, + ) { + return this.links.exportPeople(user.organizationId, dto); + } + @Post('sync/products/search') @Roles('owner', 'admin', 'sales') searchProducts( diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index b1dbee8..54c714a 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -11,9 +11,10 @@ import { TahesabProductLinkService } from './product-link.service'; import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; import { TahesabController } from './tahesab.controller'; +import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, CustomerGroupsModule], controllers: [TahesabController], providers: [ TahesabCryptoService, diff --git a/apps/web/src/components/CustomerGroupChips.vue b/apps/web/src/components/CustomerGroupChips.vue new file mode 100644 index 0000000..5b66dbe --- /dev/null +++ b/apps/web/src/components/CustomerGroupChips.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/web/src/components/CustomerGroupPicker.vue b/apps/web/src/components/CustomerGroupPicker.vue new file mode 100644 index 0000000..43a4afe --- /dev/null +++ b/apps/web/src/components/CustomerGroupPicker.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/web/src/components/CustomerGroupsManager.vue b/apps/web/src/components/CustomerGroupsManager.vue new file mode 100644 index 0000000..bdeb5d5 --- /dev/null +++ b/apps/web/src/components/CustomerGroupsManager.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/apps/web/src/lib/customer-groups.ts b/apps/web/src/lib/customer-groups.ts new file mode 100644 index 0000000..f6b4d4e --- /dev/null +++ b/apps/web/src/lib/customer-groups.ts @@ -0,0 +1,53 @@ +import { ref } from 'vue'; +import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared'; +import { api } from '@/lib/api'; + +export const CUSTOMER_GROUP_COLOR_LABELS: Record = { + teal: 'سبزآبی', + sky: 'آبی', + violet: 'بنفش', + rose: 'قرمز', + amber: 'نارنجی', + pink: 'صورتی', + slate: 'خاکستری', +}; + +/** رنگ متن/پس‌زمینهٔ چیپ گروه (پالت داده، نه رنگ‌های قالب) */ +export const CUSTOMER_GROUP_SWATCH: Record = { + teal: { fg: '#0f766e', bg: '#ccfbf1' }, + sky: { fg: '#0369a1', bg: '#e0f2fe' }, + violet: { fg: '#6d28d9', bg: '#ede9fe' }, + rose: { fg: '#be123c', bg: '#ffe4e6' }, + amber: { fg: '#b45309', bg: '#fef3c7' }, + pink: { fg: '#be185d', bg: '#fce7f3' }, + slate: { fg: '#334155', bg: '#e2e8f0' }, +}; + +export function groupSwatch(color: string | null | undefined) { + return CUSTOMER_GROUP_SWATCH[(color ?? 'teal') as CustomerGroupColor] ?? CUSTOMER_GROUP_SWATCH.teal; +} + +const groups = ref([]); +const loaded = ref(false); +let pending: Promise | null = null; + +/** فهرست گروه‌های مشتری محیط (مشترک بین صفحه‌ها) */ +export function useCustomerGroups() { + async function load(force = false): Promise { + if (loaded.value && !force) return groups.value; + if (pending && !force) return pending; + pending = api + .get('/customer-groups') + .then(({ data }) => { + groups.value = data; + loaded.value = true; + return data; + }) + .finally(() => { + pending = null; + }); + return pending; + } + + return { groups, loaded, load }; +} diff --git a/apps/web/src/views/CompanyFormView.vue b/apps/web/src/views/CompanyFormView.vue index 2812d48..e5833c2 100644 --- a/apps/web/src/views/CompanyFormView.vue +++ b/apps/web/src/views/CompanyFormView.vue @@ -8,6 +8,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'; import EntityTimeline from '@/components/EntityTimeline.vue'; import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; +import CustomerGroupPicker from '@/components/CustomerGroupPicker.vue'; import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali'; import { UiButton, @@ -59,6 +60,7 @@ const city = ref(''); const postalCode = ref(''); const address = ref(''); const groupName = ref(''); +const groupIds = ref([]); const registeredAt = ref(''); const notes = ref(''); const tagsRaw = ref(''); @@ -94,6 +96,7 @@ function fill(c: CompanyDto) { postalCode.value = c.postalCode ?? ''; address.value = c.address ?? ''; groupName.value = c.groupName ?? ''; + groupIds.value = (c.groups ?? []).map((g) => g.id); registeredAt.value = c.registeredAt ? formatLocalDateInput(new Date(c.registeredAt)) : ''; notes.value = c.notes ?? ''; tagsRaw.value = (c.tags ?? []).join('، '); @@ -134,6 +137,7 @@ function payload(): CreateCompanyRequest { postalCode: postalCode.value || null, address: address.value || undefined, groupName: groupName.value || null, + groupIds: groupIds.value, registeredAt: parseLocalInput(registeredAt.value)?.toISOString() ?? null, notes: notes.value || undefined, tags: parseTags(tagsRaw.value), @@ -254,8 +258,8 @@ onMounted(load); - - + + diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index cf09817..903091f 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -40,6 +40,10 @@ import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; import { canUseBehavior } from '@/lib/behavior-access'; import DiscBadge from '@/components/behavior/DiscBadge.vue'; import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize'; +import CustomerGroupChips from '@/components/CustomerGroupChips.vue'; +import CustomerGroupsManager from '@/components/CustomerGroupsManager.vue'; +import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups'; +import { canManageSettings } from '@/lib/roles'; type Tab = 'people' | 'companies'; type SortKey = 'name' | 'newest'; @@ -73,6 +77,19 @@ const sourceFilter = computed(() => { ? (value as PersonImportSource) : 'all'; }); +const groupFilter = computed(() => + typeof route.query.group === 'string' ? route.query.group : '', +); +const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups(); +const canManageGroups = computed(() => canManageSettings(auth.user?.role)); +const groupsManagerOpen = ref(false); +const visibleGroups = computed(() => + customerGroups.value.filter( + (g) => + g.id === groupFilter.value || + (tab.value === 'people' ? g.personCount : g.companyCount) > 0, + ), +); const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const loading = ref(false); const busy = ref(false); @@ -145,6 +162,7 @@ const peopleSavedFilters = computed(() => ({ mood: moodFilter.value === 'all' ? undefined : moodFilter.value, source: sourceFilter.value === 'all' ? undefined : sourceFilter.value, disc: discFilter.value || undefined, + group: groupFilter.value || undefined, })); function applyPeopleSavedFilters(filters: Record) { @@ -166,6 +184,7 @@ function applyPeopleSavedFilters(filters: Record) { sort: nextSort === 'name' ? undefined : nextSort, mood: nextMood === 'all' ? undefined : nextMood, source: nextSource === 'all' ? undefined : nextSource, + group: typeof filters.group === 'string' && filters.group ? filters.group : undefined, page: undefined, tab: undefined, }); @@ -221,6 +240,7 @@ async function load() { loading.value = true; error.value = ''; closePopups(); + void loadCustomerGroups(true).catch(() => undefined); try { if (tab.value === 'people') { const [peopleRes, statsRes] = await Promise.all([ @@ -234,6 +254,7 @@ async function load() { importSource: sourceFilter.value === 'all' ? undefined : sourceFilter.value, disc: discFilter.value || undefined, + groupId: groupFilter.value || undefined, }, }), api.get('/people/source-stats').catch(() => ({ @@ -246,7 +267,12 @@ async function load() { selectedIds.value = selectedIds.value.filter((id) => visible.has(id)); } else { const { data } = await api.get>('/companies', { - params: { q: q.value || undefined, page: page.value, limit: pageSize.value }, + params: { + q: q.value || undefined, + page: page.value, + limit: pageSize.value, + groupId: groupFilter.value || undefined, + }, }); companies.value = data; } @@ -295,6 +321,14 @@ function setDisc(next: string) { patchQuery({ disc: next || undefined, page: undefined }); } +function setGroup(next: string) { + patchQuery({ group: next || undefined, page: undefined }); +} + +async function onGroupsChanged() { + await load(); +} + function setSource(next: PersonImportSource | 'all') { patchQuery({ source: next === 'all' ? undefined : next, @@ -409,7 +443,7 @@ function onImportSettled() { } watch( - () => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source], + () => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source, route.query.group], () => { q.value = typeof route.query.q === 'string' ? route.query.q : ''; void load(); @@ -556,6 +590,33 @@ onMounted(load); +
+ + + +
+
+ {{ sourceLabel(p.importSource || 'manual') }}
@@ -767,6 +833,9 @@ onMounted(load); {{ c.code ? `کد: ${toFaDigits(c.code)}` : '' }}

+

+ +

{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }} {{ c.city || c.owner ? ' • ' : '' }}افراد: {{ toFaDigits(c.peopleCount) }} @@ -806,7 +875,10 @@ onMounted(load); {{ c.sourceRef ? toFaDigits(c.sourceRef) : '—' }} {{ c.code ? toFaDigits(c.code) : '—' }} {{ sourceLabel(c.importSource || 'manual') }} - {{ c.groupName || '—' }} + + + {{ c.groupName || '—' }} + {{ c.province || '—' }} {{ c.city || '—' }} {{ c.phone || '—' }} @@ -856,6 +928,13 @@ onMounted(load); @cancel="contactModal = null" /> + + @@ -997,6 +1076,10 @@ h1 { font-style: normal; font-weight: 700; } +.manage-chip { + border-style: dashed; + color: #64748b; +} .tiny { font-size: 0.75rem; margin-top: 0.15rem; diff --git a/apps/web/src/views/PersonFormView.vue b/apps/web/src/views/PersonFormView.vue index 0c2df10..4466074 100644 --- a/apps/web/src/views/PersonFormView.vue +++ b/apps/web/src/views/PersonFormView.vue @@ -38,6 +38,7 @@ import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@me import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; import { formatLocalDateInput, toFaDigits } from '@/lib/jalali'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; +import { useCustomerGroups } from '@/lib/customer-groups'; import { filterPhoneContacts, isContactPickerSupported, @@ -97,7 +98,13 @@ const notes = ref(''); const tagsRaw = ref(''); const companyId = ref(''); const mood = ref('newcomer'); +const groupIds = ref([]); const customFields = ref>({}); +const { groups: customerGroups, loaded: groupsLoaded, load: loadCustomerGroups } = + useCustomerGroups(); +const groupOptions = computed(() => + customerGroups.value.map((g) => ({ value: g.id, label: g.name })), +); const loyaltyPoints = ref(0); const loyaltyTierName = ref(''); @@ -257,6 +264,7 @@ function fill(p: PersonDto) { tagsRaw.value = (p.tags ?? []).join('، '); companyId.value = p.company?.id ?? ''; mood.value = p.mood || 'newcomer'; + groupIds.value = (p.groups ?? []).map((g) => g.id); customFields.value = { ...(p.customFields ?? {}) }; loyaltyPoints.value = p.loyaltyPoints ?? 0; loyaltyTierName.value = p.loyaltyTier?.name ?? ''; @@ -277,6 +285,7 @@ function removeAddress(index: number) { async function load() { loading.value = true; error.value = ''; + void loadCustomerGroups().catch(() => undefined); try { const companiesRes = await api.get<{ items: CompanyDto[] }>('/companies', { params: { limit: 100 }, @@ -334,6 +343,7 @@ function payload(): CreatePersonRequest { tags: parseTags(tagsRaw.value), companyId: companyId.value || null, mood: mood.value, + groupIds: groupIds.value, customFields: customFields.value, }; } @@ -558,6 +568,20 @@ onMounted(load); /> + + + + + >(() => { { key: 'tasks', label: 'کارهای مرتبط' }, { key: 'deals', label: 'معاملات' }, { key: 'cards', label: 'کارت‌ها' }, - { key: 'assets', label: 'دارایی' }, + { key: 'assets', label: 'ته‌حساب و دارایی' }, { key: 'info', label: 'اطلاعات' }, ]; if (canBehavior.value) { @@ -759,6 +760,10 @@ watch(() => route.query.tab, readTab, { immediate: true });

+
+
گروه‌ها
+
+
شرکت
{{ person.company?.name || '—' }}
@@ -809,6 +814,7 @@ watch(() => route.query.tab, readTab, { immediate: true });

{{ subtitle }}

+
diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index e9e07f7..f8cb47f 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -496,6 +496,128 @@ async function importBulk() { } } +/** همهٔ بازه‌های کد خوانده می‌شود و گروه ته‌حساب روی مشتری‌های لینک‌شده نشانده می‌شود */ +async function syncGroups() { + error.value = ''; + ok.value = ''; + if (!sync.connectionId) { + error.value = 'ابتدا اتصال را انتخاب کنید.'; + return; + } + syncBusy.value = true; + importOverlay.open = true; + importOverlay.progress = null; + const totals = { rows: 0, linked: 0, updated: 0 }; + try { + let page = 1; + let emptyStreak = 0; + while (emptyStreak < LIST_ALL_EMPTY_STOP) { + importOverlay.message = `در حال همگام‌سازی گروه‌ها… ${toFaDigits(totals.rows)} مشتری بررسی شد · ${toFaDigits(totals.updated)} تغییر`; + const { data } = await api.post<{ rows: number; linked: number; updated: number }>( + '/integrations/tahesab/sync/groups', + { connectionId: sync.connectionId, page, pageSize: LIST_ALL_CODE_PAGE }, + { timeout: 120_000 }, + ); + totals.rows += data.rows; + totals.linked += data.linked; + totals.updated += data.updated; + emptyStreak = data.rows ? 0 : emptyStreak + 1; + page += 1; + } + ok.value = `گروه‌ها همگام شد: ${toFaDigits(totals.linked)} مشتری لینک‌شده بررسی شد · گروه ${toFaDigits(totals.updated)} نفر به‌روز شد.`; + } catch (e) { + error.value = apiErr(e, 'همگام‌سازی گروه‌ها ناموفق بود'); + } finally { + importOverlay.open = false; + syncBusy.value = false; + } +} + +type ExportItem = { + personId: string; + name: string; + status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error'; + moshtariCode?: number; + error?: string; +}; +const EXPORT_CHUNK = 10; +const exportIssues = ref([]); + +/** شخص‌های لینک‌نشده: اول تطبیق، بعد ساخت در ته‌حساب (بدون ساخت تکراری) */ +async function exportUnlinked() { + error.value = ''; + ok.value = ''; + exportIssues.value = []; + if (!sync.connectionId) { + error.value = 'ابتدا اتصال را انتخاب کنید.'; + return; + } + syncBusy.value = true; + try { + const { data: unlinked } = await api.get<{ ids: string[]; total: number }>( + '/integrations/tahesab/sync/unlinked-people', + { params: { connectionId: sync.connectionId } }, + ); + if (!unlinked.total) { + ok.value = 'همهٔ اشخاص به ته‌حساب لینک هستند.'; + return; + } + if ( + !confirm( + `${toFaDigits(unlinked.total)} شخص به ته‌حساب لینک نیستند.\n` + + 'برای هر نفر اول با موبایل/نام در ته‌حساب جستجو می‌شود: اگر دقیقاً یک نفر پیدا شد فقط لینک می‌شود، ' + + 'اگر چند نفر پیدا شد رد می‌شود تا دستی بررسی کنید، و اگر پیدا نشد در ته‌حساب ساخته می‌شود. ادامه؟', + ) + ) { + return; + } + importOverlay.open = true; + importOverlay.progress = 0; + const totals = { matched: 0, created: 0, ambiguous: 0, skipped: 0, failed: 0 }; + const ids = unlinked.ids; + for (let i = 0; i < ids.length; i += EXPORT_CHUNK) { + const chunk = ids.slice(i, i + EXPORT_CHUNK); + importOverlay.message = `در حال ثبت در ته‌حساب… ${toFaDigits(Math.min(i + chunk.length, ids.length))} از ${toFaDigits(ids.length)}`; + try { + const { data } = await api.post( + '/integrations/tahesab/sync/export-people', + { connectionId: sync.connectionId, personIds: chunk }, + { timeout: 180_000 }, + ); + totals.matched += data.matched; + totals.created += data.created; + totals.ambiguous += data.ambiguous; + totals.skipped += data.skipped; + totals.failed += data.failed; + exportIssues.value.push( + ...data.items.filter((it) => it.status === 'ambiguous' || it.status === 'error'), + ); + } catch (e) { + totals.failed += chunk.length; + exportIssues.value.push( + ...chunk.map((personId) => ({ + personId, + name: '', + status: 'error' as const, + error: apiErr(e, 'خطا در ارسال'), + })), + ); + } + importOverlay.progress = ((i + chunk.length) / ids.length) * 100; + } + ok.value = + `ثبت در ته‌حساب تمام شد: لینک با مشتری موجود ${toFaDigits(totals.matched)} · ` + + `ساخته‌شده ${toFaDigits(totals.created)} · چند نتیجه (نیاز به بررسی) ${toFaDigits(totals.ambiguous)} · ` + + `ناموفق ${toFaDigits(totals.failed)}`; + } catch (e) { + error.value = apiErr(e, 'ثبت گروهی در ته‌حساب ناموفق بود'); + } finally { + importOverlay.open = false; + importOverlay.progress = null; + syncBusy.value = false; + } +} + async function searchProducts() { error.value = ''; ok.value = ''; @@ -807,6 +929,43 @@ onMounted(load);
+
+

۳) گروه‌ها و ثبت مشتری‌های منتکت در ته‌حساب

+

+ «همگام‌سازی گروه‌ها» گروه هر مشتری لینک‌شده را از ته‌حساب می‌خواند؛ فقط گروهِ آمده از ته‌حساب عوض + می‌شود و گروه‌هایی که دستی داده‌اید می‌مانند. + «ثبت لینک‌نشده‌ها» برای هر شخصی که به ته‌حساب لینک نیست اول تطبیق (موبایل، بعد نام) را امتحان + می‌کند تا مشتری تکراری ساخته نشود؛ اگر پیدا نشد با گروه، آدرس، کد ملی و تاریخ تولد ساخته می‌شود. +

+
+ + همگام‌سازی گروه‌ها از ته‌حساب + + + ثبت مشتری‌های لینک‌نشده در ته‌حساب + +
+
+

+ این اشخاص ثبت نشدند ({{ toFaDigits(exportIssues.length) }}). از پرونده، بخش «ته‌حساب و دارایی» + دستی لینک یا ثبت کنید: +

+
    +
  • + + {{ it.name || 'بدون نام' }} + + — + {{ it.status === 'ambiguous' ? 'چند مشتری مشابه در ته‌حساب پیدا شد' : it.error || 'خطا' }} +
  • +
+
+
+

نتایج جستجو ({{ syncVisibleResults.length }}) @@ -1176,6 +1335,14 @@ onMounted(load); flex-wrap: wrap; } +.export-issues ul { + margin: var(--sp-2) 0 0; + padding-inline-start: 1.2rem; + font-size: var(--fs-xs); + max-height: 14rem; + overflow-y: auto; +} + .sync-table-pagination { margin-top: var(--sp-3); } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 16f22d3..71f65cd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -774,6 +774,48 @@ export interface HealthResponse { timestamp: string; } +/* ───────── گروه مشتری (اشخاص و شرکت‌ها) ───────── */ + +export const CUSTOMER_GROUP_COLORS = [ + "teal", + "sky", + "violet", + "rose", + "amber", + "pink", + "slate", +] as const; +export type CustomerGroupColor = (typeof CUSTOMER_GROUP_COLORS)[number]; + +export interface CustomerGroupSummaryDto { + id: string; + name: string; + color: CustomerGroupColor | string; + /** manual | tahesab — فقط برای اشخاص */ + source?: "manual" | "tahesab"; +} + +export interface CustomerGroupDto { + id: string; + name: string; + color: CustomerGroupColor | string; + position: number; + /** گروه متناظر در ته‌حساب دارد */ + fromTahesab: boolean; + personCount: number; + companyCount: number; +} + +export interface CreateCustomerGroupRequest { + name: string; + color?: CustomerGroupColor; +} + +export interface UpdateCustomerGroupRequest { + name?: string; + color?: CustomerGroupColor; +} + export interface CompanySummaryDto { id: string; name: string; @@ -807,6 +849,7 @@ export interface CompanyDto { registeredAt: string | null; tags: string[]; customFields: Record; + groups: CustomerGroupSummaryDto[]; peopleCount: number; owner: UserSummaryDto | null; createdAt: string; @@ -837,6 +880,8 @@ export interface CreateCompanyRequest { tags?: string[]; customFields?: Record; ownerId?: string | null; + /** همهٔ گروه‌های شرکت (جایگزین کامل) */ + groupIds?: string[]; } export type UpdateCompanyRequest = Partial; @@ -914,6 +959,7 @@ export interface PersonDto { mood: PersonMood; tags: string[]; customFields: Record; + groups: CustomerGroupSummaryDto[]; loyaltyPoints: number; loyaltyTier: LoyaltyTierSummaryDto | null; /** تیپ غالب یا ترکیبی تأییدشده؛ بدون مجوز رفتار خالی برمی‌گردد */ @@ -950,6 +996,8 @@ export interface CreatePersonRequest { ownerId?: string | null; /** فقط برای ایمپورت اکسل — تاریخ ثبت مبدأ */ registeredAt?: string | null; + /** همهٔ گروه‌های شخص (جایگزین کامل؛ منبع گروه‌های ته‌حسابیِ باقی‌مانده حفظ می‌شود) */ + groupIds?: string[]; } export type UpdatePersonRequest = Partial;