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
This commit is contained in:
2026-10-21 17:16:31 +03:30
parent ce27df20ca
commit 29d99cbded
27 changed files with 1728 additions and 26 deletions
@@ -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";
+49
View File
@@ -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)
+2
View File
@@ -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,
@@ -47,6 +47,7 @@ export class CompaniesController {
query.q,
query.page ?? 1,
query.limit ?? 20,
{ groupId: query.groupId },
);
}
+31 -2
View File
@@ -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<PaginatedResponse<CompanyDto>> {
const search = buildSearchWhere<Prisma.CompanyWhereInput>(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(),
@@ -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 | undefined>): 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<Buffer> {
@@ -378,6 +393,7 @@ export class ContactsExcelService {
CONTACT_COLUMNS,
items.map((p) => {
const extra = (p.customFields ?? {}) as Record<string, string>;
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<string, unknown> = {};
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;
}
+2
View File
@@ -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],
+21
View File
@@ -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[];
}
@@ -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,
});
}
+25
View File
@@ -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<PaginatedResponse<PersonDto>> {
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<FilteredIdsResponse> {
@@ -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,
@@ -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 };
}
}
@@ -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 {}
@@ -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<CustomerGroupDto[]> {
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<CustomerGroupDto> {
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<CustomerGroupDto> {
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<void> {
await this.require(organizationId, id);
await this.prisma.customerGroup.delete({ where: { id } });
}
/** شناسه‌های معتبر همین محیط (بقیه نادیده گرفته می‌شوند) */
async validIds(organizationId: string, ids: string[] | undefined): Promise<string[]> {
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<string[]> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<string | null> {
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;
}
}
+30
View File
@@ -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;
@@ -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<string, unknown>): 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<string, unknown>): { 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<string, unknown> | undefined,
): Promise<boolean> {
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<string, unknown>;
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<number, Record<string, unknown>>();
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<number, Record<string, unknown>>();
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<number, Record<string, unknown>>();
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({
@@ -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(
@@ -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,
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { CustomerGroupSummaryDto } from '@mento/shared';
import { groupSwatch } from '@/lib/customer-groups';
defineProps<{
groups: CustomerGroupSummaryDto[] | undefined;
/** متن وقتی گروهی نیست */
empty?: string;
}>();
</script>
<template>
<span v-if="groups?.length" class="group-chips">
<span
v-for="g in groups"
:key="g.id"
class="group-chip"
:style="{ color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg }"
:title="g.source === 'tahesab' ? `${g.name} (از ته‌حساب)` : g.name"
>
{{ g.name }}
</span>
</span>
<span v-else-if="empty" class="group-empty">{{ empty }}</span>
</template>
<style scoped>
.group-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
vertical-align: middle;
}
.group-chip {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.group-empty {
color: #94a3b8;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups';
const model = defineModel<string[]>({ default: () => [] });
const { groups, loaded, load } = useCustomerGroups();
function toggle(id: string) {
model.value = model.value.includes(id)
? model.value.filter((x) => x !== id)
: [...model.value, id];
}
onMounted(() => {
void load().catch(() => undefined);
});
</script>
<template>
<div class="picker">
<button
v-for="g in groups"
:key="g.id"
type="button"
class="opt"
:class="{ on: model.includes(g.id) }"
:style="
model.includes(g.id)
? { color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg, borderColor: groupSwatch(g.color).fg }
: undefined
"
@click="toggle(g.id)"
>
{{ g.name }}
</button>
<span v-if="loaded && !groups.length" class="hint">
هنوز گروهی تعریف نشده؛ از صفحهٔ مخاطبین «مدیریت گروه‌ها» را بزنید.
</span>
</div>
</template>
<style scoped>
.picker {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.opt {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 999px;
padding: 0.25rem 0.75rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.opt.on {
font-weight: 600;
}
.hint {
color: #94a3b8;
font-size: 0.82rem;
}
</style>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import axios from 'axios';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
CUSTOMER_GROUP_COLOR_LABELS,
groupSwatch,
useCustomerGroups,
} from '@/lib/customer-groups';
import { UiButton, UiInput, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const { groups, load } = useCustomerGroups();
const newName = ref('');
const newColor = ref<CustomerGroupColor>('teal');
const editingId = ref<string | null>(null);
const editName = ref('');
const busy = ref(false);
const error = ref('');
function errorText(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (msg) return msg;
}
return fallback;
}
async function run(task: () => Promise<unknown>, fallback: string) {
busy.value = true;
error.value = '';
try {
await task();
await load(true);
emit('changed');
return true;
} catch (e) {
error.value = errorText(e, fallback);
return false;
} finally {
busy.value = false;
}
}
async function create() {
const name = newName.value.trim();
if (!name) return;
const ok = await run(
() => api.post('/customer-groups', { name, color: newColor.value }),
'ساخت گروه ناموفق بود.',
);
if (ok) newName.value = '';
}
function startEdit(g: CustomerGroupDto) {
editingId.value = g.id;
editName.value = g.name;
}
async function saveName(g: CustomerGroupDto) {
const name = editName.value.trim();
if (!name || name === g.name) {
editingId.value = null;
return;
}
const ok = await run(
() => api.patch(`/customer-groups/${g.id}`, { name }),
'تغییر نام ناموفق بود.',
);
if (ok) editingId.value = null;
}
async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
if (g.color === color) return;
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
}
async function remove(g: CustomerGroupDto) {
const used = g.personCount + g.companyCount;
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : '';
if (!confirm(`گروه «${g.name}» حذف شود؟${note}`)) return;
await run(() => api.delete(`/customer-groups/${g.id}`), 'حذف گروه ناموفق بود.');
}
watch(
() => props.open,
(open) => {
if (!open) return;
error.value = '';
editingId.value = null;
void load(true).catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
},
{ immediate: true },
);
</script>
<template>
<UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')">
<div class="manager">
<p class="lead">
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. گروه‌هایی که از ته‌حساب می‌آیند با برچسب
«ته‌حساب» مشخص شده‌اند.
</p>
<form class="new-row" @submit.prevent="create">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch"
:class="{ on: newColor === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
@click="newColor = c"
/>
</div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</form>
<p v-if="error" class="error">{{ error }}</p>
<ul v-if="groups.length" class="list">
<li v-for="g in groups" :key="g.id" class="row">
<div class="name-cell">
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
<UiButton type="submit" variant="primary" size="sm" :disabled="busy">ذخیره</UiButton>
<UiButton type="button" variant="ghost" size="sm" @click="editingId = null">انصراف</UiButton>
</form>
<template v-else>
<span
class="chip"
:style="{ color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg }"
>
{{ g.name }}
</span>
<span v-if="g.fromTahesab" class="tag">ته‌حساب</span>
<span class="count">
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
</span>
</template>
</div>
<div v-if="editingId !== g.id" class="row-actions">
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch small"
:class="{ on: g.color === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
:disabled="busy"
@click="setColor(g, c)"
/>
</div>
<UiButton variant="ghost" size="sm" :disabled="busy" @click="startEdit(g)">تغییر نام</UiButton>
<UiButton variant="ghost" size="sm" :disabled="busy" @click="remove(g)">
<UiIcon name="trash" :size="14" />
</UiButton>
</div>
</li>
</ul>
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
</div>
</UiModal>
</template>
<style scoped>
.manager {
display: grid;
gap: 0.85rem;
min-width: min(34rem, 100%);
}
.lead,
.muted {
margin: 0;
color: #64748b;
font-size: 0.85rem;
}
.error {
margin: 0;
color: #dc2626;
font-size: 0.85rem;
}
.new-row,
.edit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.new-row > :first-child,
.edit > :first-child {
flex: 1 1 12rem;
}
.swatches {
display: inline-flex;
gap: 0.3rem;
}
.swatch {
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e2e8f0;
cursor: pointer;
padding: 0;
}
.swatch.small {
width: 1rem;
height: 1rem;
}
.swatch.on {
box-shadow: 0 0 0 2px #0f172a;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
max-height: 55vh;
overflow-y: auto;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.name-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
min-width: 0;
flex: 1 1 14rem;
}
.chip {
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-weight: 600;
font-size: 0.85rem;
}
.tag {
font-size: 0.7rem;
color: #0369a1;
background: #e0f2fe;
border-radius: 6px;
padding: 0.05rem 0.4rem;
}
.count {
color: #94a3b8;
font-size: 0.78rem;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
</style>
+53
View File
@@ -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<CustomerGroupColor, string> = {
teal: 'سبزآبی',
sky: 'آبی',
violet: 'بنفش',
rose: 'قرمز',
amber: 'نارنجی',
pink: 'صورتی',
slate: 'خاکستری',
};
/** رنگ متن/پس‌زمینهٔ چیپ گروه (پالت داده، نه رنگ‌های قالب) */
export const CUSTOMER_GROUP_SWATCH: Record<CustomerGroupColor, { fg: string; bg: string }> = {
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<CustomerGroupDto[]>([]);
const loaded = ref(false);
let pending: Promise<CustomerGroupDto[]> | null = null;
/** فهرست گروه‌های مشتری محیط (مشترک بین صفحه‌ها) */
export function useCustomerGroups() {
async function load(force = false): Promise<CustomerGroupDto[]> {
if (loaded.value && !force) return groups.value;
if (pending && !force) return pending;
pending = api
.get<CustomerGroupDto[]>('/customer-groups')
.then(({ data }) => {
groups.value = data;
loaded.value = true;
return data;
})
.finally(() => {
pending = null;
});
return pending;
}
return { groups, loaded, load };
}
+6 -2
View File
@@ -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<string[]>([]);
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);
<UiInput id="sourceRef" v-model="sourceRef" placeholder="مثال: 5277" dir="ltr" />
</UiField>
<UiField label="گروه بندی شرکت">
<UiInput id="groupName" v-model="groupName" placeholder="مثال: همکار، عمده‌فروش" />
<UiField label="گروه‌های شرکت" class="col-span-2">
<CustomerGroupPicker v-model="groupIds" />
</UiField>
<UiField label="تاریخ ثبت شرکت">
+86 -3
View File
@@ -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<PersonImportSource | 'all'>(() => {
? (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<string, unknown>) {
@@ -166,6 +184,7 @@ function applyPeopleSavedFilters(filters: Record<string, unknown>) {
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<PersonImportSourceStatDto[]>('/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<PaginatedResponse<CompanyDto>>('/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);
</button>
</div>
<div v-if="visibleGroups.length || canManageGroups" class="source-stats">
<button type="button" class="source-chip" :class="{ on: !groupFilter }" @click="setGroup('')">
همه گروه‌ها
</button>
<button
v-for="g in visibleGroups"
:key="g.id"
type="button"
class="source-chip"
:class="{ on: groupFilter === g.id }"
:style="groupFilter === g.id ? { color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg } : undefined"
@click="setGroup(g.id)"
>
{{ g.name }}
<em>{{ toFaDigits(tab === 'people' ? g.personCount : g.companyCount) }}</em>
</button>
<button
v-if="canManageGroups"
type="button"
class="source-chip manage-chip"
@click="groupsManagerOpen = true"
>
<UiIcon name="settings" :size="13" />
مدیریت گروه‌ها
</button>
</div>
<div v-if="tab === 'people' && canBehavior" class="source-stats">
<button type="button" class="source-chip" :class="{ on: !discFilter }" @click="setDisc('')">همه تیپ‌ها</button>
<button v-for="type in ['D', 'I', 'S', 'C']" :key="type" type="button" class="source-chip" :class="{ on: discFilter === type }" @click="setDisc(type)">
@@ -606,6 +667,9 @@ onMounted(load);
<span>منبع: {{ sourceLabel(p.importSource || 'manual') }}</span>
<span v-if="externalCode(p)"> · کد: {{ externalCodeLabel(p) }}</span>
</p>
<p v-if="p.groups?.length" class="line">
<CustomerGroupChips :groups="p.groups" />
</p>
<p v-if="p.previousFullName" class="line muted">
نام قبلی: {{ p.previousFullName }}
</p>
@@ -647,6 +711,7 @@ onMounted(load);
<th class="num-col">#</th>
<th>وضعیت</th>
<th>نام شخص</th>
<th>گروه</th>
<th>منبع</th>
<th>کد مبدأ</th>
<th>سمت</th>
@@ -682,6 +747,7 @@ onMounted(load);
قبلی: {{ p.previousFullName }}
</div>
</td>
<td><CustomerGroupChips :groups="p.groups" empty="—" /></td>
<td>
{{ sourceLabel(p.importSource || 'manual') }}
<div v-if="p.previousImportSource" class="muted tiny">
@@ -767,6 +833,9 @@ onMounted(load);
{{ c.code ? `کد: ${toFaDigits(c.code)}` : '' }}
</span>
</p>
<p v-if="c.groups?.length" class="line">
<CustomerGroupChips :groups="c.groups" />
</p>
<p class="role">
{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }}
{{ c.city || c.owner ? ' • ' : '' }}افراد: {{ toFaDigits(c.peopleCount) }}
@@ -806,7 +875,10 @@ onMounted(load);
<td dir="ltr">{{ c.sourceRef ? toFaDigits(c.sourceRef) : '—' }}</td>
<td dir="ltr">{{ c.code ? toFaDigits(c.code) : '—' }}</td>
<td>{{ sourceLabel(c.importSource || 'manual') }}</td>
<td>{{ c.groupName || '—' }}</td>
<td>
<CustomerGroupChips v-if="c.groups?.length" :groups="c.groups" />
<span v-else>{{ c.groupName || '—' }}</span>
</td>
<td>{{ c.province || '—' }}</td>
<td>{{ c.city || '—' }}</td>
<td dir="ltr">{{ c.phone || '—' }}</td>
@@ -856,6 +928,13 @@ onMounted(load);
@cancel="contactModal = null"
/>
</UiModal>
<CustomerGroupsManager
v-if="canManageGroups"
:open="groupsManagerOpen"
@close="groupsManagerOpen = false"
@changed="onGroupsChanged"
/>
</section>
</template>
@@ -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;
+24
View File
@@ -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<PersonMood>('newcomer');
const groupIds = ref<string[]>([]);
const customFields = ref<Record<string, unknown>>({});
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);
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="گروه‌ها">
<a-select
v-model:value="groupIds"
mode="multiple"
allow-clear
option-filter-prop="label"
placeholder="بدون گروه"
:options="groupOptions"
:not-found-content="groupsLoaded ? 'گروهی تعریف نشده' : undefined"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="برچسب‌ها">
<a-input
+7 -1
View File
@@ -50,6 +50,7 @@ import type {
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
import DiscPanel from '@/components/behavior/DiscPanel.vue';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
@@ -83,7 +84,7 @@ const TABS = computed<Array<{ key: TabKey; label: string }>>(() => {
{ 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 });
</div>
<dl class="facts-grid">
<div class="fact">
<dt>گروه‌ها</dt>
<dd><CustomerGroupChips :groups="person.groups" empty="—" /></dd>
</div>
<div class="fact">
<dt>شرکت</dt>
<dd>{{ person.company?.name || '—' }}</dd>
@@ -809,6 +814,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
<CustomerGroupChips :groups="person.groups" />
</div>
</div>
<div class="contact-rows">
+167
View File
@@ -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<ExportItem[]>([]);
/** شخص‌های لینک‌نشده: اول تطبیق، بعد ساخت در ته‌حساب (بدون ساخت تکراری) */
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<typeof totals & { items: ExportItem[] }>(
'/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);
</div>
</div>
<div class="sync-sub-section">
<h4 class="sync-subhead">۳) گروه‌ها و ثبت مشتری‌های منتکت در ته‌حساب</h4>
<p class="sync-sub-desc">
«همگام‌سازی گروه‌ها» گروه هر مشتری لینک‌شده را از ته‌حساب می‌خواند؛ فقط گروهِ آمده از ته‌حساب عوض
می‌شود و گروه‌هایی که دستی داده‌اید می‌مانند.
«ثبت لینک‌نشده‌ها» برای هر شخصی که به ته‌حساب لینک نیست اول تطبیق (موبایل، بعد نام) را امتحان
می‌کند تا مشتری تکراری ساخته نشود؛ اگر پیدا نشد با گروه، آدرس، کد ملی و تاریخ تولد ساخته می‌شود.
</p>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="syncGroups"
>
همگام‌سازی گروه‌ها از ته‌حساب
</UiButton>
<UiButton :disabled="!sync.connectionId || syncBusy" @click="exportUnlinked">
ثبت مشتری‌های لینک‌نشده در ته‌حساب
</UiButton>
</div>
<div v-if="exportIssues.length" class="export-issues">
<p class="sync-sub-desc">
این اشخاص ثبت نشدند ({{ toFaDigits(exportIssues.length) }}). از پرونده، بخش «ته‌حساب و دارایی»
دستی لینک یا ثبت کنید:
</p>
<ul>
<li v-for="it in exportIssues" :key="it.personId">
<RouterLink :to="{ name: 'person-detail', params: { id: it.personId } }">
{{ it.name || 'بدون نام' }}
</RouterLink>
—
{{ it.status === 'ambiguous' ? 'چند مشتری مشابه در ته‌حساب پیدا شد' : it.error || 'خطا' }}
</li>
</ul>
</div>
</div>
<div v-if="syncResults.length" class="sync-results-box">
<h4 class="sync-results-title">
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
@@ -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);
}
+48
View File
@@ -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<string, unknown>;
groups: CustomerGroupSummaryDto[];
peopleCount: number;
owner: UserSummaryDto | null;
createdAt: string;
@@ -837,6 +880,8 @@ export interface CreateCompanyRequest {
tags?: string[];
customFields?: Record<string, unknown>;
ownerId?: string | null;
/** همهٔ گروه‌های شرکت (جایگزین کامل) */
groupIds?: string[];
}
export type UpdateCompanyRequest = Partial<CreateCompanyRequest>;
@@ -914,6 +959,7 @@ export interface PersonDto {
mood: PersonMood;
tags: string[];
customFields: Record<string, unknown>;
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<CreatePersonRequest>;