From b9bb2ff9fa967fa597058f52bf862cec11d209b3 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 29 Oct 2026 18:46:10 +0330 Subject: [PATCH] feat(contacts): keep Tahesab honorifics with person gender Male and female names can store the title when requested, and Tahesab import and export use the same gender. Co-authored-by: Cursor --- apps/api/package.json | 2 +- .../migration.sql | 22 ++++ apps/api/prisma/schema.prisma | 4 + apps/api/src/contacts/dto.ts | 19 +++- apps/api/src/contacts/people.service.ts | 51 ++++++++- .../tahesab/person-link.service.ts | 13 ++- apps/api/test/person-name.test.ts | 101 ++++++++++++++++++ apps/web/src/views/PersonFormView.vue | 42 +++++++- apps/web/src/views/PersonProfileView.vue | 11 ++ packages/shared/src/index.ts | 13 +++ packages/shared/src/person-name.ts | 86 +++++++++++++++ 11 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 apps/api/prisma/migrations/20261029180000_person_gender/migration.sql create mode 100644 apps/api/test/person-name.test.ts create mode 100644 packages/shared/src/person-name.ts diff --git a/apps/api/package.json b/apps/api/package.json index 3019d81..60afd05 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "prisma:seed": "tsx prisma/seed.ts", "lint": "echo \"api lint placeholder\"", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", - "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts" + "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts test/person-name.test.ts" }, "dependencies": { "@mento/shared": "*", diff --git a/apps/api/prisma/migrations/20261029180000_person_gender/migration.sql b/apps/api/prisma/migrations/20261029180000_person_gender/migration.sql new file mode 100644 index 0000000..4ff8022 --- /dev/null +++ b/apps/api/prisma/migrations/20261029180000_person_gender/migration.sql @@ -0,0 +1,22 @@ +ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false; + +-- نام‌هایی که از ته‌حساب با عنوان آمده‌اند؛ خود متن نام دست نمی‌خورد +UPDATE "Person" +SET "gender" = 'male', "showGenderInName" = true +WHERE regexp_replace( + regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'), + 'ك', + 'ک', + 'g' +) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)'; + +UPDATE "Person" +SET "gender" = 'female', "showGenderInName" = true +WHERE "gender" = 'none' + AND regexp_replace( + regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'), + 'ك', + 'ک', + 'g' + ) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2f16c6b..1879be3 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -579,6 +579,10 @@ model Person { company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) firstName String lastName String + /// male | female | none + gender String @default("none") + /// اگر روشن باشد، مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره می‌شود + showGenderInName Boolean @default(false) /// نام قبل از همگام‌سازی مبدأ (مثلاً ته‌حساب) previousFirstName String? previousLastName String? diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index fd133ff..bda6b74 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -3,6 +3,7 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, + IsBoolean, IsEmail, IsIn, IsInt, @@ -15,7 +16,7 @@ import { MinLength, ValidateIf, } from 'class-validator'; -import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared'; +import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared'; export class BirthdaySmsDto { @IsString() @@ -282,6 +283,14 @@ export class CreatePersonDto { @MaxLength(80) lastName?: string; + @IsOptional() + @IsIn(PERSON_GENDERS) + gender?: (typeof PERSON_GENDERS)[number]; + + @IsOptional() + @IsBoolean() + showGenderInName?: boolean; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() @@ -401,6 +410,14 @@ export class UpdatePersonDto { @MaxLength(80) lastName?: string; + @IsOptional() + @IsIn(PERSON_GENDERS) + gender?: (typeof PERSON_GENDERS)[number]; + + @IsOptional() + @IsBoolean() + showGenderInName?: boolean; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index e918d9d..9509398 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -18,7 +18,12 @@ import type { PersonMood, UpdatePersonRequest, } from '@mento/shared'; -import { FILTERED_IDS_MAX, PERSON_MOODS } from '@mento/shared'; +import { + FILTERED_IDS_MAX, + PERSON_MOODS, + applyPersonGenderTitle, + splitPersonHonorific, +} from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; @@ -73,6 +78,8 @@ type PersonRow = { id: string; firstName: string; lastName: string; + gender: string; + showGenderInName: boolean; previousFirstName: string | null; previousLastName: string | null; importSource: string; @@ -417,10 +424,18 @@ export class PeopleService implements OnModuleInit { } const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null; + const named = applyPersonGenderTitle({ + firstName: dto.firstName ?? '', + lastName: dto.lastName ?? '', + gender: dto.gender, + showGenderInName: dto.showGenderInName, + }); const data: Prisma.PersonUncheckedCreateInput = { organizationId: actor.organizationId, - firstName: (dto.firstName ?? '').trim(), - lastName: (dto.lastName ?? '').trim(), + firstName: named.firstName, + lastName: named.lastName, + gender: named.gender, + showGenderInName: named.showGenderInName, previousFirstName: emptyToNull(dto.previousFirstName), previousLastName: emptyToNull(dto.previousLastName), importSource: normalizePersonImportSource(dto.importSource), @@ -497,8 +512,32 @@ export class PeopleService implements OnModuleInit { await this.ensureExists(actor, id); const data: Prisma.PersonUncheckedUpdateInput = {}; - if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); - if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); + const touchesName = + dto.firstName !== undefined || + dto.lastName !== undefined || + dto.gender !== undefined || + dto.showGenderInName !== undefined; + if (touchesName) { + const existing = await this.prisma.person.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { firstName: true, lastName: true, gender: true, showGenderInName: true }, + }); + if (!existing) throw new NotFoundException('شخص پیدا نشد'); + const bare = splitPersonHonorific(existing.firstName, existing.lastName); + const storedGender = + existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none'; + const named = applyPersonGenderTitle({ + firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName, + lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName, + gender: dto.gender !== undefined ? dto.gender : storedGender, + showGenderInName: + dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName, + }); + data.firstName = named.firstName; + data.lastName = named.lastName; + data.gender = named.gender; + data.showGenderInName = named.showGenderInName; + } if ('previousFirstName' in dto) { data.previousFirstName = emptyToNull(dto.previousFirstName); } @@ -809,6 +848,8 @@ export class PeopleService implements OnModuleInit { previousImportSource: row.previousImportSource, previousSourceRef: row.previousSourceRef, fullName: `${row.firstName} ${row.lastName}`.trim(), + gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none', + showGenderInName: row.showGenderInName, email: row.email, phone: row.phone, mobile: row.mobile, diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 703cbc7..a7fafca 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -9,6 +9,7 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { carryPreviousSource } from '../../contacts/person-source'; +import { applyPersonGenderTitle } from '@mento/shared'; import { TahesabConnectionsService } from './connections.service'; import { CreateInTahesabDto, @@ -573,9 +574,9 @@ export class TahesabPersonLinkService { } const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`; const tel = pickTel(row); - const parts = name.trim().split(/\s+/); - const firstName = parts[0] || name; - const lastName = parts.slice(1).join(' ') || '—'; + const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); + const firstName = named.firstName; + const lastName = named.lastName; const mobile = tel || null; const sourceRef = String(dto.moshtariCode); @@ -591,6 +592,8 @@ export class TahesabPersonLinkService { data: { firstName, lastName, + gender: named.gender, + showGenderInName: named.showGenderInName, ...(nameChanged ? { previousFirstName: prevFirst || null, @@ -631,6 +634,8 @@ export class TahesabPersonLinkService { data: { firstName, lastName, + gender: named.gender, + showGenderInName: named.showGenderInName, ...(nameChanged ? { previousFirstName: byMobile.firstName || null, @@ -650,6 +655,8 @@ export class TahesabPersonLinkService { organizationId, firstName, lastName, + gender: named.gender, + showGenderInName: named.showGenderInName, mobile, phone: mobile, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, diff --git a/apps/api/test/person-name.test.ts b/apps/api/test/person-name.test.ts new file mode 100644 index 0000000..c448e3d --- /dev/null +++ b/apps/api/test/person-name.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { applyPersonGenderTitle, splitPersonHonorific } from '@mento/shared'; + +describe('عنوان جنسیت در نام شخص', () => { + it('ی عربی جناب آقای را مرد می‌خواند و عنوان معیار را می‌گذارد', () => { + const named = applyPersonGenderTitle({ + firstName: 'جناب آقاي آرش انتظاري', + lastName: '', + }); + assert.equal(named.gender, 'male'); + assert.equal(named.showGenderInName, true); + assert.equal(`${named.firstName} ${named.lastName}`, 'جناب آقای آرش انتظاری'); + }); + + it('سرکار خانم را زن می‌خواند، با فاصله و بی‌فاصله', () => { + const spaced = applyPersonGenderTitle({ + firstName: 'سرکار خانم آبيدا', + lastName: 'افرندنژاد تهران', + }); + assert.equal(spaced.gender, 'female'); + assert.equal(spaced.showGenderInName, true); + assert.equal(`${spaced.firstName} ${spaced.lastName}`, 'سرکار خانم آبیدا افرندنژاد تهران'); + + const tight = applyPersonGenderTitle({ + firstName: 'سرکارخانم مریم رضایی', + lastName: '', + }); + assert.equal(tight.gender, 'female'); + assert.equal(`${tight.firstName} ${tight.lastName}`, 'سرکار خانم مریم رضایی'); + }); + + it('بدون عنوان، هیچکدام می‌ماند و چیزی اضافه نمی‌کند', () => { + const shop = applyPersonGenderTitle({ firstName: 'فروشگاه', lastName: 'الماس' }); + assert.equal(shop.gender, 'none'); + assert.equal(shop.showGenderInName, false); + assert.equal(shop.firstName, 'فروشگاه'); + assert.equal(shop.lastName, 'الماس'); + }); + + it('تیک خاموش جنسیت را نگه می‌دارد ولی عنوان را برمی‌دارد', () => { + const hidden = applyPersonGenderTitle({ + firstName: 'جناب آقای آرش', + lastName: 'انتظاری', + gender: 'male', + showGenderInName: false, + }); + assert.equal(hidden.gender, 'male'); + assert.equal(hidden.showGenderInName, false); + assert.equal(`${hidden.firstName} ${hidden.lastName}`, 'آرش انتظاری'); + }); + + it('هیچکدام عنوان را برمی‌دارد حتی اگر تیک روشن باشد', () => { + const cleared = applyPersonGenderTitle({ + firstName: 'جناب آقای آرش', + lastName: 'انتظاری', + gender: 'none', + showGenderInName: true, + }); + assert.equal(cleared.gender, 'none'); + assert.equal(cleared.showGenderInName, false); + assert.equal(`${cleared.firstName} ${cleared.lastName}`, 'آرش انتظاری'); + }); + + it('عنوان را فقط یک بار می‌گذارد', () => { + const once = applyPersonGenderTitle({ + firstName: 'آرش', + lastName: 'انتظاری', + gender: 'male', + showGenderInName: true, + }); + assert.equal(`${once.firstName} ${once.lastName}`, 'جناب آقای آرش انتظاری'); + + const again = applyPersonGenderTitle({ + firstName: once.firstName, + lastName: once.lastName, + gender: 'male', + showGenderInName: true, + }); + assert.equal(`${again.firstName} ${again.lastName}`, 'جناب آقای آرش انتظاری'); + }); + + it('ویرایش بخشی از نام، آقای را دوباره به ته نام نمی‌چسباند', () => { + const existing = applyPersonGenderTitle({ + firstName: 'آرش', + lastName: 'انتظاری', + gender: 'male', + showGenderInName: true, + }); + const bare = splitPersonHonorific(existing.firstName, existing.lastName); + const next = applyPersonGenderTitle({ + firstName: 'محمد', + lastName: bare.lastName, + gender: 'male', + showGenderInName: true, + }); + assert.equal(bare.firstName, 'آرش'); + assert.equal(bare.lastName, 'انتظاری'); + assert.equal(`${next.firstName} ${next.lastName}`, 'جناب آقای محمد انتظاری'); + }); +}); diff --git a/apps/web/src/views/PersonFormView.vue b/apps/web/src/views/PersonFormView.vue index 4466074..dffe9b9 100644 --- a/apps/web/src/views/PersonFormView.vue +++ b/apps/web/src/views/PersonFormView.vue @@ -1,11 +1,12 @@