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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user