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:
@@ -47,6 +47,7 @@ export class CompaniesController {
|
||||
query.q,
|
||||
query.page ?? 1,
|
||||
query.limit ?? 20,
|
||||
{ groupId: query.groupId },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user