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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-29 18:46:10 +03:30
co-authored by Cursor
parent 0d5e1034e5
commit b9bb2ff9fa
11 changed files with 350 additions and 14 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts", "prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"", "lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", "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": { "dependencies": {
"@mento/shared": "*", "@mento/shared": "*",
@@ -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:]]|$)';
+4
View File
@@ -579,6 +579,10 @@ model Person {
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
firstName String firstName String
lastName String lastName String
/// male | female | none
gender String @default("none")
/// اگر روشن باشد، مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره می‌شود
showGenderInName Boolean @default(false)
/// نام قبل از همگام‌سازی مبدأ (مثلاً ته‌حساب) /// نام قبل از همگام‌سازی مبدأ (مثلاً ته‌حساب)
previousFirstName String? previousFirstName String?
previousLastName String? previousLastName String?
+18 -1
View File
@@ -3,6 +3,7 @@ import {
ArrayMaxSize, ArrayMaxSize,
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsBoolean,
IsEmail, IsEmail,
IsIn, IsIn,
IsInt, IsInt,
@@ -15,7 +16,7 @@ import {
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } 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 { export class BirthdaySmsDto {
@IsString() @IsString()
@@ -282,6 +283,14 @@ export class CreatePersonDto {
@MaxLength(80) @MaxLength(80)
lastName?: string; lastName?: string;
@IsOptional()
@IsIn(PERSON_GENDERS)
gender?: (typeof PERSON_GENDERS)[number];
@IsOptional()
@IsBoolean()
showGenderInName?: boolean;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -401,6 +410,14 @@ export class UpdatePersonDto {
@MaxLength(80) @MaxLength(80)
lastName?: string; lastName?: string;
@IsOptional()
@IsIn(PERSON_GENDERS)
gender?: (typeof PERSON_GENDERS)[number];
@IsOptional()
@IsBoolean()
showGenderInName?: boolean;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
+46 -5
View File
@@ -18,7 +18,12 @@ import type {
PersonMood, PersonMood,
UpdatePersonRequest, UpdatePersonRequest,
} from '@mento/shared'; } 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 { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
@@ -73,6 +78,8 @@ type PersonRow = {
id: string; id: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
gender: string;
showGenderInName: boolean;
previousFirstName: string | null; previousFirstName: string | null;
previousLastName: string | null; previousLastName: string | null;
importSource: string; importSource: string;
@@ -417,10 +424,18 @@ export class PeopleService implements OnModuleInit {
} }
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null; 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 = { const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
firstName: (dto.firstName ?? '').trim(), firstName: named.firstName,
lastName: (dto.lastName ?? '').trim(), lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
previousFirstName: emptyToNull(dto.previousFirstName), previousFirstName: emptyToNull(dto.previousFirstName),
previousLastName: emptyToNull(dto.previousLastName), previousLastName: emptyToNull(dto.previousLastName),
importSource: normalizePersonImportSource(dto.importSource), importSource: normalizePersonImportSource(dto.importSource),
@@ -497,8 +512,32 @@ export class PeopleService implements OnModuleInit {
await this.ensureExists(actor, id); await this.ensureExists(actor, id);
const data: Prisma.PersonUncheckedUpdateInput = {}; const data: Prisma.PersonUncheckedUpdateInput = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); const touchesName =
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); 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) { if ('previousFirstName' in dto) {
data.previousFirstName = emptyToNull(dto.previousFirstName); data.previousFirstName = emptyToNull(dto.previousFirstName);
} }
@@ -809,6 +848,8 @@ export class PeopleService implements OnModuleInit {
previousImportSource: row.previousImportSource, previousImportSource: row.previousImportSource,
previousSourceRef: row.previousSourceRef, previousSourceRef: row.previousSourceRef,
fullName: `${row.firstName} ${row.lastName}`.trim(), fullName: `${row.firstName} ${row.lastName}`.trim(),
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
showGenderInName: row.showGenderInName,
email: row.email, email: row.email,
phone: row.phone, phone: row.phone,
mobile: row.mobile, mobile: row.mobile,
@@ -9,6 +9,7 @@ import {
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource } from '../../contacts/person-source'; import { carryPreviousSource } from '../../contacts/person-source';
import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { import {
CreateInTahesabDto, CreateInTahesabDto,
@@ -573,9 +574,9 @@ export class TahesabPersonLinkService {
} }
const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`; const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`;
const tel = pickTel(row); const tel = pickTel(row);
const parts = name.trim().split(/\s+/); const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = parts[0] || name; const firstName = named.firstName;
const lastName = parts.slice(1).join(' ') || '—'; const lastName = named.lastName;
const mobile = tel || null; const mobile = tel || null;
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
@@ -591,6 +592,8 @@ export class TahesabPersonLinkService {
data: { data: {
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged ...(nameChanged
? { ? {
previousFirstName: prevFirst || null, previousFirstName: prevFirst || null,
@@ -631,6 +634,8 @@ export class TahesabPersonLinkService {
data: { data: {
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged ...(nameChanged
? { ? {
previousFirstName: byMobile.firstName || null, previousFirstName: byMobile.firstName || null,
@@ -650,6 +655,8 @@ export class TahesabPersonLinkService {
organizationId, organizationId,
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile, mobile,
phone: mobile, phone: mobile,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
+101
View File
@@ -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}`, 'جناب آقای محمد انتظاری');
});
});
+38 -4
View File
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'; import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
Checkbox as ACheckbox,
Col as ACol, Col as ACol,
Divider as ADivider, Divider as ADivider,
Flex as AFlex, Flex as AFlex,
@@ -34,7 +35,8 @@ import {
SearchOutlined, SearchOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared'; import type { CompanyDto, CreatePersonRequest, PersonDto, PersonGender, PersonMood } from '@mento/shared';
import { PERSON_GENDER_LABELS, PERSON_GENDERS, splitPersonHonorific } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali'; import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
@@ -85,6 +87,8 @@ const companies = ref<CompanyDto[]>([]);
const firstName = ref(''); const firstName = ref('');
const lastName = ref(''); const lastName = ref('');
const gender = ref<PersonGender>('none');
const showGenderInName = ref(false);
const email = ref(''); const email = ref('');
const phone = ref(''); const phone = ref('');
const mobile = ref(''); const mobile = ref('');
@@ -125,6 +129,15 @@ const companyOptions = computed(() => [
...companies.value.map((c) => ({ value: c.id, label: c.name })), ...companies.value.map((c) => ({ value: c.id, label: c.name })),
]); ]);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
watch(gender, (value) => {
if (value === 'none') showGenderInName.value = false;
});
const backTo = computed(() => const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } }, isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
); );
@@ -249,8 +262,12 @@ function pickFromVcf() {
} }
function fill(p: PersonDto) { function fill(p: PersonDto) {
firstName.value = p.firstName; const bare = splitPersonHonorific(p.firstName, p.lastName);
lastName.value = p.lastName; firstName.value = bare.firstName;
lastName.value = bare.lastName;
gender.value = p.gender && p.gender !== 'none' ? p.gender : (bare.gender ?? 'none');
showGenderInName.value =
gender.value !== 'none' && (p.showGenderInName || bare.gender != null);
email.value = p.email ?? ''; email.value = p.email ?? '';
phone.value = p.phone ?? ''; phone.value = p.phone ?? '';
mobile.value = p.mobile ?? ''; mobile.value = p.mobile ?? '';
@@ -330,6 +347,8 @@ function payload(): CreatePersonRequest {
return { return {
firstName: firstName.value, firstName: firstName.value,
lastName: lastName.value, lastName: lastName.value,
gender: gender.value,
showGenderInName: gender.value !== 'none' && showGenderInName.value,
email: email.value || undefined, email: email.value || undefined,
phone: phone.value || undefined, phone: phone.value || undefined,
mobile: mobile.value || undefined, mobile: mobile.value || undefined,
@@ -462,6 +481,21 @@ onMounted(load);
<a-input v-model:value="lastName" /> <a-input v-model:value="lastName" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره می‌شود و به ته‌حساب هم همین‌طور می‌رود."
>
<a-checkbox v-model:checked="showGenderInName" :disabled="gender === 'none'">
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item label="موبایل"> <a-form-item label="موبایل">
<a-space-compact block style="width: 100%"> <a-space-compact block style="width: 100%">
+11
View File
@@ -47,6 +47,7 @@ import type {
QuoteDto, QuoteDto,
TicketDto, TicketDto,
} from '@mento/shared'; } from '@mento/shared';
import { PERSON_GENDER_LABELS } from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood'; import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access'; import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue'; import DiscBadge from '@/components/behavior/DiscBadge.vue';
@@ -167,6 +168,11 @@ const registeredAt = computed(() =>
const birthDateLabel = computed(() => const birthDateLabel = computed(() =>
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—', person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
); );
const genderLabel = computed(() => {
const key = person.value?.gender;
if (key === 'male' || key === 'female' || key === 'none') return PERSON_GENDER_LABELS[key];
return '—';
});
const externalCode = computed(() => { const externalCode = computed(() => {
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim(); if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
const fields = person.value?.customFields ?? {}; const fields = person.value?.customFields ?? {};
@@ -772,6 +778,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
<dt>سمت</dt> <dt>سمت</dt>
<dd>{{ person.position || '—' }}</dd> <dd>{{ person.position || '—' }}</dd>
</div> </div>
<div class="fact">
<dt>جنسیت</dt>
<dd>{{ genderLabel }}</dd>
</div>
<div class="fact"> <div class="fact">
<dt>تاریخ تولد</dt> <dt>تاریخ تولد</dt>
<dd>{{ birthDateLabel }}</dd> <dd>{{ birthDateLabel }}</dd>
@@ -1102,6 +1112,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div> <div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div> <div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div> <div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
<div><dt>جنسیت</dt><dd>{{ genderLabel }}</dd></div>
<div><dt>منبع ورود</dt><dd>{{ importSourceLabel }}</dd></div> <div><dt>منبع ورود</dt><dd>{{ importSourceLabel }}</dd></div>
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div> <div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
<div><dt>نام قبلی</dt><dd>{{ person.previousFullName || '—' }}</dd></div> <div><dt>نام قبلی</dt><dd>{{ person.previousFullName || '—' }}</dd></div>
+13
View File
@@ -1,3 +1,12 @@
import type { PersonGender } from "./person-name";
export type { PersonGender } from "./person-name";
export {
PERSON_GENDERS,
PERSON_GENDER_LABELS,
applyPersonGenderTitle,
splitPersonHonorific,
} from "./person-name";
export type UserRole = "owner" | "admin" | "sales" | "support"; export type UserRole = "owner" | "admin" | "sales" | "support";
export const USER_ROLES: UserRole[] = ["owner", "admin", "sales", "support"]; export const USER_ROLES: UserRole[] = ["owner", "admin", "sales", "support"];
@@ -1193,6 +1202,8 @@ export interface PersonDto {
previousImportSource: PersonImportSource | string | null; previousImportSource: PersonImportSource | string | null;
previousSourceRef: string | null; previousSourceRef: string | null;
fullName: string; fullName: string;
gender: PersonGender;
showGenderInName: boolean;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
mobile: string | null; mobile: string | null;
@@ -1222,6 +1233,8 @@ export interface PersonDto {
export interface CreatePersonRequest { export interface CreatePersonRequest {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
gender?: PersonGender;
showGenderInName?: boolean;
previousFirstName?: string | null; previousFirstName?: string | null;
previousLastName?: string | null; previousLastName?: string | null;
importSource?: PersonImportSource | string; importSource?: PersonImportSource | string;
+86
View File
@@ -0,0 +1,86 @@
/** مرد، زن، یا هیچکدام (مثلاً فروشگاه) */
export type PersonGender = "male" | "female" | "none";
export const PERSON_GENDERS: PersonGender[] = ["male", "female", "none"];
export const PERSON_GENDER_LABELS: Record<PersonGender, string> = {
male: "مرد",
female: "زن",
none: "هیچکدام",
};
const MALE_TITLE = "جناب آقای";
const FEMALE_TITLE = "سرکار خانم";
function normalizeFa(value: string) {
return value.replace(/ي/g, "ی").replace(/ك/g, "ک").replace(/\s+/g, " ").trim();
}
function splitWords(value: string) {
const parts = normalizeFa(value).split(" ").filter(Boolean);
return {
firstName: parts[0] ?? "",
lastName: parts.slice(1).join(" "),
};
}
/** عنوان ابتدای نام را برمی‌دارد تا فیلدهای نام، عنوان را دوباره ذخیره نکنند. */
export function splitPersonHonorific(firstName: string, lastName: string): {
firstName: string;
lastName: string;
gender: Exclude<PersonGender, "none"> | null;
} {
let full = normalizeFa(`${firstName} ${lastName}`);
let gender: Exclude<PersonGender, "none"> | null = null;
// فاصله اختیاری است: «سرکارخانم» و «جناب آقاي» هر دو عنوان‌اند.
for (;;) {
const male = full.match(/^جناب\s*آقای(?=\s|$)/);
const female = full.match(/^سرکار\s*خانم(?=\s|$)/);
const hit = male?.[0] ?? female?.[0];
if (!hit) break;
gender = male ? "male" : "female";
full = normalizeFa(full.slice(hit.length));
}
const plain = splitWords(full);
return { ...plain, gender };
}
/**
* نام ذخیره‌شده را با عنوان می‌سازد.
* اگر جنسیت یا تیک نفرستاده شود و خود نام عنوان داشته باشد، همان برداشت می‌شود.
*/
export function applyPersonGenderTitle(input: {
firstName: string;
lastName: string;
gender?: PersonGender | null;
showGenderInName?: boolean | null;
}): {
firstName: string;
lastName: string;
gender: PersonGender;
showGenderInName: boolean;
} {
const bare = splitPersonHonorific(input.firstName, input.lastName);
const gender: PersonGender =
input.gender === "male" || input.gender === "female" || input.gender === "none"
? input.gender
: (bare.gender ?? "none");
let show = input.showGenderInName;
if (typeof show !== "boolean") show = bare.gender != null && gender !== "none";
if (gender === "none") show = false;
const body = [bare.firstName, bare.lastName].filter(Boolean).join(" ");
const titled =
show && gender === "male"
? `${MALE_TITLE} ${body}`.trim()
: show && gender === "female"
? `${FEMALE_TITLE} ${body}`.trim()
: body;
const parts = splitWords(titled);
return {
firstName: parts.firstName || "—",
lastName: parts.lastName || "—",
gender,
showGenderInName: show,
};
}