feat: customer groups for people and companies with tahesab group sync and bulk register

Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export
This commit is contained in:
2026-10-21 17:16:31 +03:30
parent ce27df20ca
commit 29d99cbded
27 changed files with 1728 additions and 26 deletions
@@ -0,0 +1,85 @@
-- CreateTable
CREATE TABLE "CustomerGroup" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'teal',
"position" INTEGER NOT NULL DEFAULT 0,
"tahesabConnectionId" TEXT,
"tahesabGid" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PersonGroupLink" (
"personId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'manual',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PersonGroupLink_pkey" PRIMARY KEY ("personId","groupId")
);
-- CreateTable
CREATE TABLE "CompanyGroupLink" (
"companyId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CompanyGroupLink_pkey" PRIMARY KEY ("companyId","groupId")
);
-- CreateIndex
CREATE UNIQUE INDEX "CustomerGroup_organizationId_name_key" ON "CustomerGroup"("organizationId", "name");
CREATE INDEX "CustomerGroup_organizationId_position_idx" ON "CustomerGroup"("organizationId", "position");
CREATE INDEX "CustomerGroup_tahesabConnectionId_tahesabGid_idx" ON "CustomerGroup"("tahesabConnectionId", "tahesabGid");
CREATE INDEX "PersonGroupLink_groupId_idx" ON "PersonGroupLink"("groupId");
CREATE INDEX "CompanyGroupLink_groupId_idx" ON "CompanyGroupLink"("groupId");
-- AddForeignKey
ALTER TABLE "CustomerGroup" ADD CONSTRAINT "CustomerGroup_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- تبدیل داده‌های قبلی: «گروه مشتری» اکسل (customFields.group) و «گروه‌بندی شرکت» (groupName).
-- مقدارهای چندتایی با «،» «,» «/» جدا می‌شوند. برچسب‌های «وضعیت مشتری» که دیدار در همان ستون می‌گذارد گروه حساب نمی‌شوند.
CREATE TEMP TABLE "_cg_src" AS
SELECT p."organizationId" AS org, 'person' AS kind, p."id" AS ref, left(btrim(x), 80) AS name
FROM "Person" p
CROSS JOIN LATERAL regexp_split_to_table(p."customFields"->>'group', '\s*[،,/]\s*') AS x
WHERE COALESCE(p."customFields"->>'group', '') <> ''
UNION ALL
SELECT c."organizationId", 'company', c."id", left(btrim(x), 80)
FROM "Company" c
CROSS JOIN LATERAL regexp_split_to_table(c."groupName", '\s*[،,/]\s*') AS x
WHERE COALESCE(c."groupName", '') <> '';
DELETE FROM "_cg_src"
WHERE name = ''
OR name IN ('مشتری تازه وارد', 'مشتری خوشحال', 'مشتری ناراضی', 'مشتری خاص');
INSERT INTO "CustomerGroup" ("id", "organizationId", "name", "color", "position", "createdAt", "updatedAt")
SELECT 'cgrp_' || md5(d.org || '|' || d.name), d.org, d.name, 'teal', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (SELECT DISTINCT org, name FROM "_cg_src") AS d
ON CONFLICT ("organizationId", "name") DO NOTHING;
INSERT INTO "PersonGroupLink" ("personId", "groupId", "source", "createdAt")
SELECT DISTINCT s.ref, g."id", 'manual', CURRENT_TIMESTAMP
FROM "_cg_src" s
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
WHERE s.kind = 'person'
ON CONFLICT DO NOTHING;
INSERT INTO "CompanyGroupLink" ("companyId", "groupId", "createdAt")
SELECT DISTINCT s.ref, g."id", CURRENT_TIMESTAMP
FROM "_cg_src" s
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
WHERE s.kind = 'company'
ON CONFLICT DO NOTHING;
DROP TABLE "_cg_src";
+49
View File
@@ -160,6 +160,7 @@ model Organization {
orgRoles OrgRole[]
teams Team[]
companies Company[]
customerGroups CustomerGroup[]
people Person[]
attendanceEvents AttendanceEvent[]
discAssessments DiscAssessment[]
@@ -477,11 +478,13 @@ model Company {
city String?
postalCode String?
address String?
/// قدیمی؛ از نسخهٔ گروه‌بندی، گروه‌ها در CompanyGroupLink هستند
groupName String?
notes String?
registeredAt DateTime?
tags String[] @default([])
customFields Json @default("{}")
groupLinks CompanyGroupLink[]
people Person[]
deals Deal[]
activities Activity[]
@@ -505,6 +508,51 @@ model Company {
@@index([ownerId])
}
/// گروه مشتری (مشترک بین اشخاص و شرکت‌ها؛ هر مشتری چند گروه)
model CustomerGroup {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
color String @default("teal")
position Int @default(0)
/// گروه متناظر در ته‌حساب (GID) روی همان اتصال
tahesabConnectionId String?
tahesabGid String?
people PersonGroupLink[]
companies CompanyGroupLink[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId, position])
@@index([tahesabConnectionId, tahesabGid])
}
model PersonGroupLink {
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
groupId String
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
/// manual | tahesab — گروهِ ته‌حسابی با همگام‌سازی جایگزین می‌شود؛ دستی دست نمی‌خورد
source String @default("manual")
createdAt DateTime @default(now())
@@id([personId, groupId])
@@index([groupId])
}
model CompanyGroupLink {
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
groupId String
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([companyId, groupId])
@@index([groupId])
}
model Person {
id String @id @default(cuid())
organizationId String
@@ -541,6 +589,7 @@ model Person {
mood PersonMood @default(newcomer)
tags String[] @default([])
customFields Json @default("{}")
groupLinks PersonGroupLink[]
loyaltyPoints Int @default(0)
loyaltyTierId String?
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)