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:
@@ -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";
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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="تاریخ ثبت شرکت">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user