merge: bring latest origin/main into dev (design local, logic main)
Keep the local Ant screens and icon pack. Take treasury parties, routine self-service, Tahesab auto-sync, public lead registration, and contact merge from main.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
|
||||
|
||||
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
|
||||
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
|
||||
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE "DataFix" (
|
||||
"key" TEXT NOT NULL,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "AccountingConnection"
|
||||
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "customerSyncLastSlot" TEXT,
|
||||
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
|
||||
ADD COLUMN "customerSyncLastSummary" TEXT,
|
||||
ADD COLUMN "customerSyncLastError" TEXT;
|
||||
@@ -295,6 +295,7 @@ model User {
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
assignedActivities Activity[] @relation("ActivityAssignees")
|
||||
@@ -524,6 +525,7 @@ model Company {
|
||||
customFields Json @default("{}")
|
||||
groupLinks CompanyGroupLink[]
|
||||
people Person[]
|
||||
financialDocuments FinancialDocument[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
@@ -1841,6 +1843,13 @@ model AccountingConnection {
|
||||
lastHealthAt DateTime?
|
||||
lastHealthOk Boolean?
|
||||
lastError String?
|
||||
/// ساعتهای بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
|
||||
customerSyncHours Int[] @default([])
|
||||
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
|
||||
customerSyncLastSlot String?
|
||||
customerSyncLastRunAt DateTime?
|
||||
customerSyncLastSummary String?
|
||||
customerSyncLastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
personLinks PersonAccountingLink[]
|
||||
@@ -2330,6 +2339,12 @@ model FinancialDocument {
|
||||
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
userId String?
|
||||
partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull)
|
||||
/// person | company | user | other
|
||||
partyKind String?
|
||||
partyName String?
|
||||
accountId String?
|
||||
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
|
||||
@@ -2350,6 +2365,8 @@ model FinancialDocument {
|
||||
@@index([organizationId, kind, bookedAt])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
||||
@@ -3769,3 +3786,9 @@ model AttendanceEvent {
|
||||
@@index([organizationId, userId, at])
|
||||
@@unique([organizationId, userId, kind, sessionStartedAt])
|
||||
}
|
||||
|
||||
/// علامت یکبار اجرای اصلاح داده
|
||||
model DataFix {
|
||||
key String @id
|
||||
doneAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
|
||||
return id;
|
||||
};
|
||||
const resolvePerson = async (row: Record<string, string>) => {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const name = (row.personName ?? '').trim().toLowerCase();
|
||||
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||
if (!key) return null;
|
||||
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { SameMobileMergeService } from './same-mobile-merge.service';
|
||||
import { BehaviorModule } from '../behavior/behavior.module';
|
||||
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
||||
CustomerGroupsModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
|
||||
|
||||
const personSelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
gender: true,
|
||||
showGenderInName: true,
|
||||
previousFirstName: true,
|
||||
previousLastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
position: true,
|
||||
website: true,
|
||||
instagram: true,
|
||||
birthDate: true,
|
||||
referrer: true,
|
||||
addresses: true,
|
||||
notes: true,
|
||||
tags: true,
|
||||
companyId: true,
|
||||
customFields: true,
|
||||
importSource: true,
|
||||
sourceRef: true,
|
||||
previousImportSource: true,
|
||||
previousSourceRef: true,
|
||||
loyaltyPoints: true,
|
||||
discLabel: true,
|
||||
discReliable: true,
|
||||
discAssessedAt: true,
|
||||
} satisfies Prisma.PersonSelect;
|
||||
|
||||
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
|
||||
|
||||
function blank(value: string | null | undefined) {
|
||||
return !value?.trim();
|
||||
}
|
||||
|
||||
function asStrings(value: Prisma.JsonValue): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return { ...(value as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
|
||||
const notes = [keeper.notes?.trim() || ''];
|
||||
let email = keeper.email;
|
||||
let phone = storedPhoneNumber(keeper.phone);
|
||||
let position = keeper.position;
|
||||
let website = keeper.website;
|
||||
let instagram = keeper.instagram;
|
||||
let birthDate = keeper.birthDate;
|
||||
let referrer = keeper.referrer;
|
||||
let companyId = keeper.companyId;
|
||||
let gender = keeper.gender;
|
||||
let showGenderInName = keeper.showGenderInName;
|
||||
let previousFirstName = keeper.previousFirstName;
|
||||
let previousLastName = keeper.previousLastName;
|
||||
let previousImportSource = keeper.previousImportSource;
|
||||
let previousSourceRef = keeper.previousSourceRef;
|
||||
let discLabel = keeper.discLabel;
|
||||
let discReliable = keeper.discReliable;
|
||||
let discAssessedAt = keeper.discAssessedAt;
|
||||
let loyaltyPoints = keeper.loyaltyPoints;
|
||||
const addresses = asStrings(keeper.addresses);
|
||||
const tags = new Set(keeper.tags);
|
||||
const customFields = asObject(keeper.customFields);
|
||||
|
||||
for (const extra of extras) {
|
||||
const name = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
notes.push(
|
||||
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
|
||||
);
|
||||
if (blank(email) && extra.email) email = extra.email;
|
||||
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
|
||||
if (blank(position) && extra.position) position = extra.position;
|
||||
if (blank(website) && extra.website) website = extra.website;
|
||||
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
|
||||
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
|
||||
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
|
||||
if (!companyId && extra.companyId) companyId = extra.companyId;
|
||||
if (gender === 'none' && extra.gender !== 'none') {
|
||||
gender = extra.gender;
|
||||
showGenderInName = extra.showGenderInName;
|
||||
}
|
||||
if (blank(previousFirstName) && extra.previousFirstName) {
|
||||
previousFirstName = extra.previousFirstName;
|
||||
}
|
||||
if (blank(previousLastName) && extra.previousLastName) {
|
||||
previousLastName = extra.previousLastName;
|
||||
}
|
||||
if (
|
||||
blank(previousImportSource) &&
|
||||
extra.importSource &&
|
||||
extra.importSource !== keeper.importSource
|
||||
) {
|
||||
previousImportSource = extra.importSource;
|
||||
previousSourceRef = extra.sourceRef;
|
||||
}
|
||||
if (
|
||||
keeper.importSource === 'tahesab' &&
|
||||
blank(previousFirstName) &&
|
||||
blank(previousLastName)
|
||||
) {
|
||||
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
|
||||
if (extraName && extraName !== keeperName) {
|
||||
previousFirstName = extra.firstName || null;
|
||||
previousLastName = extra.lastName || null;
|
||||
}
|
||||
}
|
||||
if (!discLabel && extra.discLabel) {
|
||||
discLabel = extra.discLabel;
|
||||
discReliable = extra.discReliable;
|
||||
discAssessedAt = extra.discAssessedAt;
|
||||
}
|
||||
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
|
||||
for (const address of asStrings(extra.addresses)) {
|
||||
if (!addresses.includes(address)) addresses.push(address);
|
||||
}
|
||||
for (const tag of extra.tags) tags.add(tag);
|
||||
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
|
||||
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mobile: canon,
|
||||
email,
|
||||
phone,
|
||||
position,
|
||||
website,
|
||||
instagram,
|
||||
birthDate,
|
||||
referrer,
|
||||
companyId,
|
||||
gender,
|
||||
showGenderInName,
|
||||
previousFirstName,
|
||||
previousLastName,
|
||||
previousImportSource,
|
||||
previousSourceRef,
|
||||
discLabel,
|
||||
discReliable,
|
||||
discAssessedAt,
|
||||
loyaltyPoints,
|
||||
addresses,
|
||||
tags: [...tags],
|
||||
customFields: customFields as Prisma.InputJsonValue,
|
||||
notes: notes.filter(Boolean).join('\n') || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
|
||||
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
|
||||
SELECT table_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND column_name = 'personId'
|
||||
`;
|
||||
return rows
|
||||
.map((row) => row.table_name)
|
||||
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
|
||||
}
|
||||
|
||||
async function moveLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
tables: string[],
|
||||
keeperId: string,
|
||||
dupId: string,
|
||||
) {
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonGroupLink" AS d
|
||||
USING "PersonGroupLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."groupId" = k."groupId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonAccountingLink" AS d
|
||||
USING "PersonAccountingLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."connectionId" = k."connectionId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PartyOpeningBalance"
|
||||
WHERE "personId" = ${dupId}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
|
||||
)
|
||||
`;
|
||||
|
||||
for (const table of tables) {
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
|
||||
keeperId,
|
||||
dupId,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "MontifyLink"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Notification"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "GamificationTransaction"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "FinancialAuditLog"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function mergeSameMobilePeople(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
apply: boolean,
|
||||
) {
|
||||
const people = await prisma.person.findMany({
|
||||
where: { organizationId },
|
||||
select: personSelect,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const groups = new Map<string, PersonRow[]>();
|
||||
for (const person of people) {
|
||||
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
if (!key) continue;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(person);
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
|
||||
const preview = duplicateGroups.map(([mobile, list]) => ({
|
||||
mobile,
|
||||
people: list.map((person) => ({
|
||||
id: person.id,
|
||||
stored: person.mobile,
|
||||
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||
})),
|
||||
}));
|
||||
|
||||
if (!apply) {
|
||||
const needsRewrite = people.filter((person) => {
|
||||
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
|
||||
}).length;
|
||||
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
|
||||
}
|
||||
|
||||
const tables = await personIdTables(prisma);
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [canon, list] of groups) {
|
||||
const ordered = [...list].sort((a, b) => {
|
||||
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
|
||||
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
|
||||
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
|
||||
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
|
||||
});
|
||||
const [keeper, ...extras] = ordered;
|
||||
if (!keeper) continue;
|
||||
if (!extras.length) {
|
||||
const phoneCanon = canonicalIranMobile(keeper.phone);
|
||||
const data: Prisma.PersonUpdateInput = {};
|
||||
if (keeper.mobile !== canon) data.mobile = canon;
|
||||
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
|
||||
if (Object.keys(data).length) {
|
||||
await prisma.person.update({ where: { id: keeper.id }, data });
|
||||
rewritten += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.person.update({
|
||||
where: { id: keeper.id },
|
||||
data: mergedFields(keeper, extras, canon),
|
||||
});
|
||||
for (const extra of extras) {
|
||||
await moveLinks(tx, tables, keeper.id, extra.id);
|
||||
await tx.person.delete({ where: { id: extra.id } });
|
||||
merged += 1;
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(
|
||||
`merge mobile ${canon} failed`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
|
||||
}
|
||||
|
||||
function loadEnv(file: string) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
|
||||
if (!match || process.env[match[1]]) continue;
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[match[1]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
|
||||
if (direct) {
|
||||
loadEnv(resolve(__dirname, '../../.env'));
|
||||
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
|
||||
const apply = process.argv.includes('--apply');
|
||||
if (!orgId) {
|
||||
console.error('org id required: --org=<id>');
|
||||
process.exit(1);
|
||||
}
|
||||
const prisma = new PrismaClient();
|
||||
mergeSameMobilePeople(prisma, orgId, apply)
|
||||
.then((result) => {
|
||||
console.log(JSON.stringify({
|
||||
apply,
|
||||
merged: result.merged,
|
||||
rewritten: result.rewritten,
|
||||
needsRewrite: result.needsRewrite,
|
||||
groupCount: result.groups.length,
|
||||
groups: result.groups,
|
||||
}, null, 2));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
|
||||
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شمارهاند. */
|
||||
export function canonicalIranMobile(value?: string | null): string | null {
|
||||
const n = normalizePhone(value);
|
||||
if (!n) return null;
|
||||
let d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** شکلهایی که ممکن است همین موبایل با آنها ذخیره شده باشد. */
|
||||
export function iranMobileLookupKeys(value?: string | null): string[] {
|
||||
const canon = canonicalIranMobile(value);
|
||||
if (!canon) return [];
|
||||
const core = canon.slice(1);
|
||||
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
|
||||
}
|
||||
|
||||
/** برای تطبیق: اگر موبایل ایران است همه شکلها، وگرنه همان متن نرمالشده. */
|
||||
export function phoneIdentityKeys(value?: string | null): string[] {
|
||||
const keys = iranMobileLookupKeys(value);
|
||||
if (keys.length) return keys;
|
||||
const n = normalizePhone(value);
|
||||
return n ? [n] : [];
|
||||
}
|
||||
|
||||
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شمارهها فقط تمیز میشوند. */
|
||||
export function storedPhoneNumber(value?: string | null): string | null {
|
||||
return canonicalIranMobile(value) ?? normalizePhone(value);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from './normalize';
|
||||
import {
|
||||
asAddresses,
|
||||
asCustomFields,
|
||||
@@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit {
|
||||
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
||||
) {
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (input.mobile) or.push({ mobile: input.mobile });
|
||||
const mobileKeys = phoneIdentityKeys(input.mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (input.email) or.push({ email: input.email });
|
||||
if (!or.length) return;
|
||||
await this.prisma.person.updateMany({
|
||||
@@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit {
|
||||
importSource: normalizePersonImportSource(dto.importSource),
|
||||
sourceRef: emptyToNull(dto.sourceRef),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
phone: storedPhoneNumber(dto.phone),
|
||||
mobile: storedPhoneNumber(dto.mobile),
|
||||
position: emptyToNull(dto.position),
|
||||
website: emptyToNull(dto.website),
|
||||
instagram: emptyToNull(dto.instagram),
|
||||
@@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
|
||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
||||
@@ -641,10 +649,13 @@ export class PeopleService implements OnModuleInit {
|
||||
) {
|
||||
const email = typeof data.email === 'string' ? data.email : null;
|
||||
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (!or.length) return;
|
||||
|
||||
const clash = await this.prisma.person.findFirst({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { mergeSameMobilePeople } from './merge-same-mobile';
|
||||
|
||||
const FIX_KEY = 'person-mobile-canonical-v3';
|
||||
|
||||
@Injectable()
|
||||
export class SameMobileMergeService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SameMobileMergeService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
void this.run();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
try {
|
||||
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
|
||||
SELECT 1 AS ok
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND lower(table_name) = 'person'
|
||||
AND lower(column_name) = 'gender'
|
||||
`;
|
||||
if (!ready.length) {
|
||||
this.logger.warn('یکسانسازی موبایل رد شد: ستون gender هنوز نیست');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS "DataFix" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
const done = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (done.length) return;
|
||||
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
|
||||
try {
|
||||
const again = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (again.length) return;
|
||||
|
||||
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
|
||||
merged += result.merged;
|
||||
rewritten += result.rewritten;
|
||||
failed += result.failed;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
this.logger.error(
|
||||
`یکسانسازی موبایل برای محیط ${org.id} انجام نشد`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failed) return;
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
|
||||
ON CONFLICT ("key") DO NOTHING
|
||||
`;
|
||||
this.logger.log(`شمارههای تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
|
||||
} finally {
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'یکسانسازی موبایل مشتریها انجام نشد',
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,11 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { ownerWhere, type Actor } from '../../common/record-scope';
|
||||
import { MontifyConnectionsService } from './connections.service';
|
||||
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
|
||||
}
|
||||
|
||||
function mobileVariants(mobile: string): string[] {
|
||||
const digits = mobile.replace(/\D/g, '');
|
||||
let core = digits;
|
||||
if (core.startsWith('0098')) core = core.slice(4);
|
||||
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
|
||||
else if (core.startsWith('0')) core = core.slice(1);
|
||||
if (!core) return [mobile];
|
||||
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
|
||||
return phoneIdentityKeys(mobile);
|
||||
}
|
||||
|
||||
function formatAddress(a: MontifyAddress): string | null {
|
||||
@@ -417,7 +415,7 @@ export class MontifySyncService {
|
||||
}> {
|
||||
const externalId = String(c.id);
|
||||
const { firstName, lastName } = splitName(c);
|
||||
const mobile = normalizePhone(c.phone ?? null);
|
||||
const mobile = storedPhoneNumber(c.phone ?? null);
|
||||
const email = normalizeEmail(c.email ?? null);
|
||||
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
|
||||
const profile: CustomerProfile = {
|
||||
|
||||
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: Date | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
|
||||
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
|
||||
customerSyncLastSummary: row.customerSyncLastSummary,
|
||||
customerSyncLastError: row.customerSyncLastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let customerSyncHours: number[] | undefined;
|
||||
if (dto.customerSyncHours !== undefined) {
|
||||
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
throw new BadRequestException('ساعتهای بروزرسانی نباید تکراری باشند.');
|
||||
}
|
||||
customerSyncHours = [...hours].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
|
||||
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یکبار در هر ساعت. */
|
||||
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
let hour = Number(read('hour'));
|
||||
if (!Number.isFinite(hour) || hour === 24) hour = 0;
|
||||
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
|
||||
return { hour, slot };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabCustomerSyncScheduler {
|
||||
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async tick() {
|
||||
if (process.env.NODE_ENV === 'test' || this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
const { hour, slot } = tehranSyncSlot();
|
||||
const connections = await this.prisma.accountingConnection.findMany({
|
||||
where: {
|
||||
provider: 'tahesab',
|
||||
enabled: true,
|
||||
customerSyncHours: { has: hour },
|
||||
},
|
||||
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
|
||||
});
|
||||
for (const connection of connections) {
|
||||
if (connection.customerSyncLastSlot === slot) continue;
|
||||
const claimed = await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
id: connection.id,
|
||||
OR: [
|
||||
{ customerSyncLastSlot: null },
|
||||
{ customerSyncLastSlot: { not: slot } },
|
||||
],
|
||||
},
|
||||
data: { customerSyncLastSlot: slot },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
await this.run(connection.organizationId, connection.id, connection.label);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`بررسی زمانبندی مشتریان تهحساب ناموفق بود: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(organizationId: string, connectionId: string, label: string) {
|
||||
try {
|
||||
const result = await this.links.syncAllCustomers(organizationId, connectionId);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: result.summary,
|
||||
customerSyncLastError: result.error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error ? err.message : 'همگامسازی ناموفق بود').slice(0, 500);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: null,
|
||||
customerSyncLastError: message,
|
||||
},
|
||||
});
|
||||
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
|
||||
/** ساعتهای ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(23, { each: true })
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export class ManualLinkDto {
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { applyPersonGenderTitle } from '@mento/shared';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||
|
||||
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
||||
function canonicalMobileUpdate(
|
||||
incoming: string | null,
|
||||
current: { mobile: string | null; phone: string | null },
|
||||
): { mobile?: string; phone?: string } {
|
||||
const mobile =
|
||||
canonicalIranMobile(incoming) ??
|
||||
canonicalIranMobile(current.mobile) ??
|
||||
canonicalIranMobile(current.phone);
|
||||
if (!mobile) return {};
|
||||
const phone = canonicalIranMobile(current.phone);
|
||||
return phone ? { mobile, phone } : { mobile };
|
||||
}
|
||||
|
||||
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
||||
const LIST_PAGE_SIZE = 2000;
|
||||
const LIST_TIMEOUT_MS = 55_000;
|
||||
@@ -539,6 +558,7 @@ export class TahesabPersonLinkService {
|
||||
organizationId: string,
|
||||
dto: ImportFromTahesabDto,
|
||||
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
|
||||
options?: { skipJob?: boolean },
|
||||
) {
|
||||
const clash = await this.prisma.personAccountingLink.findFirst({
|
||||
where: {
|
||||
@@ -577,10 +597,11 @@ export class TahesabPersonLinkService {
|
||||
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
||||
const firstName = named.firstName;
|
||||
const lastName = named.lastName;
|
||||
const mobile = tel || null;
|
||||
const mobile = storedPhoneNumber(tel);
|
||||
const sourceRef = String(dto.moshtariCode);
|
||||
|
||||
let personId = dto.personId;
|
||||
let preserveExistingLink = false;
|
||||
if (personId) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const prevFirst = existing.firstName;
|
||||
@@ -604,28 +625,33 @@ export class TahesabPersonLinkService {
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...(mobile
|
||||
? {
|
||||
mobile: existing.mobile || mobile,
|
||||
phone: existing.phone || mobile,
|
||||
}
|
||||
: {}),
|
||||
...canonicalMobileUpdate(mobile, existing),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
||||
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته میشود؛
|
||||
// وگرنه upsert لینک، کد قبلی را جابهجا و آن مشتری را دوباره «واردنشده» میکند.
|
||||
const byMobile = mobile
|
||||
? await this.prisma.person.findFirst({
|
||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابهجا نمیشود
|
||||
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const matches = mobileKeys.length
|
||||
? await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
accountingLinks: { none: { connectionId: dto.connectionId } },
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
include: {
|
||||
accountingLinks: {
|
||||
where: { connectionId: dto.connectionId },
|
||||
select: { moshtariCode: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
: [];
|
||||
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
||||
if (byMobile) {
|
||||
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
||||
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
|
||||
const nameChanged =
|
||||
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
||||
`${firstName} ${lastName}`.trim();
|
||||
@@ -644,8 +670,9 @@ export class TahesabPersonLinkService {
|
||||
: {}),
|
||||
...carryPreviousSource(byMobile, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
|
||||
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...canonicalMobileUpdate(mobile, byMobile),
|
||||
},
|
||||
});
|
||||
personId = byMobile.id;
|
||||
@@ -669,6 +696,32 @@ export class TahesabPersonLinkService {
|
||||
}
|
||||
}
|
||||
|
||||
if (preserveExistingLink) {
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
const existing = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId, connectionId: dto.connectionId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
||||
}
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { personId, link: this.linkDto(existing) };
|
||||
}
|
||||
|
||||
const link = await this.prisma.personAccountingLink.upsert({
|
||||
where: {
|
||||
personId_connectionId: {
|
||||
@@ -693,18 +746,169 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
}
|
||||
|
||||
/** نام، موبایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
private async refreshLinkedCustomer(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
personId: string,
|
||||
moshtariCode: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const picked = pickName(row).trim();
|
||||
const named = picked
|
||||
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
|
||||
: null;
|
||||
const nameChanged =
|
||||
named != null &&
|
||||
namesDiffer(
|
||||
existing.firstName,
|
||||
existing.lastName,
|
||||
named.firstName,
|
||||
named.lastName,
|
||||
);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
...(named
|
||||
? {
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: existing.firstName || null,
|
||||
previousLastName: existing.lastName || null,
|
||||
}
|
||||
: {}),
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: String(moshtariCode),
|
||||
...(!existing.birthDate && pickBirthDate(row)
|
||||
? { birthDate: pickBirthDate(row) }
|
||||
: {}),
|
||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||
},
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ مشتریان تهحساب را صفحه به صفحه میخواند.
|
||||
* کد تازه وارد میشود؛ کد لینکشده نام و شماره و گروهش تازه میشود.
|
||||
*/
|
||||
async syncAllCustomers(organizationId: string, connectionId: string) {
|
||||
const { api } = await this.connections.getApi(organizationId, connectionId);
|
||||
let page = 1;
|
||||
let emptyStreak = 0;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
const maxPages = 300;
|
||||
|
||||
while (emptyStreak < 2 && page <= maxPages) {
|
||||
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
|
||||
try {
|
||||
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'خواندن تهحساب ناموفق بود';
|
||||
errors.push(message);
|
||||
break;
|
||||
}
|
||||
if (!listed.rows.length) {
|
||||
emptyStreak += 1;
|
||||
page += 1;
|
||||
continue;
|
||||
}
|
||||
emptyStreak = 0;
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
for (const row of listed.rows) {
|
||||
const code = pickCode(row);
|
||||
if (code != null && code >= 1) rowsByCode.set(code, row);
|
||||
}
|
||||
const links = rowsByCode.size
|
||||
? await this.prisma.personAccountingLink.findMany({
|
||||
where: {
|
||||
connectionId,
|
||||
moshtariCode: { in: [...rowsByCode.keys()] },
|
||||
},
|
||||
select: { moshtariCode: true, personId: true },
|
||||
})
|
||||
: [];
|
||||
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
|
||||
for (const [code, row] of rowsByCode) {
|
||||
try {
|
||||
const personId = linkedByCode.get(code);
|
||||
if (personId) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
connectionId,
|
||||
personId,
|
||||
code,
|
||||
row,
|
||||
);
|
||||
updated += 1;
|
||||
} else {
|
||||
await this.importFromTahesab(
|
||||
organizationId,
|
||||
{ connectionId, moshtariCode: code },
|
||||
{ api, row },
|
||||
{ skipJob: true },
|
||||
);
|
||||
imported += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
if (errors.length < 5) {
|
||||
errors.push(err instanceof Error ? err.message : 'خطا');
|
||||
}
|
||||
}
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if (page > maxPages && emptyStreak < 2) {
|
||||
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
||||
}
|
||||
|
||||
const summary = `${imported} وارد شد، ${updated} بهروز شد، ${failed} خطا`;
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
connectionId,
|
||||
kind: 'customer-sync',
|
||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
||||
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||
return {
|
||||
imported,
|
||||
updated,
|
||||
failed,
|
||||
summary,
|
||||
error: errors.length ? errors.join('؛ ') : null,
|
||||
};
|
||||
}
|
||||
|
||||
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
TahesabCustomerSyncScheduler,
|
||||
],
|
||||
exports: [
|
||||
TahesabCryptoService,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { NotificationsService } from '../../notifications/notifications.service';
|
||||
import { normalizePhone } from '../../contacts/normalize';
|
||||
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
|
||||
function digitsOnly(value: string | null | undefined): string {
|
||||
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
|
||||
variants.add(`0${d}`);
|
||||
variants.add(`98${d}`);
|
||||
}
|
||||
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
WooCommerceSyncResultDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { WooConnectionsService } from './connections.service';
|
||||
import type { WooOrder } from './woo-client';
|
||||
|
||||
@@ -374,7 +378,8 @@ export class WooSyncService {
|
||||
): Promise<string | null> {
|
||||
const billing = order.billing ?? {};
|
||||
const email = normalizeEmail(billing.email ?? null);
|
||||
const mobile = normalizePhone(billing.phone ?? null);
|
||||
const mobile = storedPhoneNumber(billing.phone ?? null);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
|
||||
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
|
||||
|
||||
@@ -387,7 +392,9 @@ export class WooSyncService {
|
||||
organizationId,
|
||||
OR: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(mobile ? [{ mobile }, { phone: mobile }] : []),
|
||||
...(mobileKeys.length
|
||||
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
|
||||
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../contacts/normalize';
|
||||
import { LeadStagesService } from '../leads/lead-stages.service';
|
||||
|
||||
@@ -458,7 +461,10 @@ export class LeadFormsService {
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: form.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
OR: [
|
||||
{ mobile: { in: phoneIdentityKeys(mobile) } },
|
||||
{ phone: { in: phoneIdentityKeys(mobile) } },
|
||||
],
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
@@ -641,13 +647,16 @@ export class LeadFormsService {
|
||||
ownerId: string,
|
||||
): Promise<string> {
|
||||
const email = normalizeEmail(mapped.email);
|
||||
const mobile = normalizePhone(mapped.mobile);
|
||||
const phone = normalizePhone(mapped.phone);
|
||||
const mobile = storedPhoneNumber(mapped.mobile);
|
||||
const phone = storedPhoneNumber(mapped.phone);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
const existing = or.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { organizationId, OR: or },
|
||||
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
|
||||
}
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function normalizeDuplicateMode(raw?: string | null): string {
|
||||
|
||||
@@ -38,7 +38,12 @@ import {
|
||||
collectAddresses,
|
||||
toJsonAddresses,
|
||||
} from '../common/custom-fields';
|
||||
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
} from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
@@ -55,13 +60,20 @@ const LOGO_EXT: Record<string, string> = {
|
||||
};
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
|
||||
const keys = phoneIdentityKeys(mobile);
|
||||
return {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
|
||||
};
|
||||
}
|
||||
|
||||
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
|
||||
@@ -323,10 +335,7 @@ export class LeadsService {
|
||||
};
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, mobile),
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (person) {
|
||||
@@ -966,10 +975,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, lead.mobile),
|
||||
});
|
||||
let createdPerson = false;
|
||||
|
||||
@@ -996,7 +1002,7 @@ export class LeadsService {
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile: lead.mobile,
|
||||
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
|
||||
phone: lead.phone,
|
||||
instagram: lead.instagram,
|
||||
birthDate: lead.birthDate,
|
||||
@@ -1170,6 +1176,95 @@ export class LeadsService {
|
||||
};
|
||||
}
|
||||
|
||||
async createFromExternal(input: {
|
||||
organizationId: string;
|
||||
siteName: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
mobile: string;
|
||||
storeName?: string | null;
|
||||
source?: string | null;
|
||||
}): Promise<{
|
||||
ok: true;
|
||||
created: boolean;
|
||||
duplicate: boolean;
|
||||
leadId: string | null;
|
||||
}> {
|
||||
const mobile = mobileKey(input.mobile);
|
||||
if (!mobile || !/^09\d{9}$/.test(mobile)) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const contactName =
|
||||
`${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null;
|
||||
const storeName = input.storeName?.trim() ?? '';
|
||||
const source = emptyToNull(input.source);
|
||||
const title = storeName || contactName || mobile;
|
||||
const siteName = input.siteName.trim() || 'سایت بیرونی';
|
||||
|
||||
const existing = await this.prisma.lead.findFirst({
|
||||
where: { organizationId: input.organizationId, mobile, archivedAt: null },
|
||||
select: { id: true, source: true, contactName: true },
|
||||
});
|
||||
if (existing) {
|
||||
const patch: Prisma.LeadUpdateInput = {};
|
||||
if (!existing.source && source) patch.source = source;
|
||||
if (!existing.contactName && contactName) patch.contactName = contactName;
|
||||
if (Object.keys(patch).length) {
|
||||
await this.prisma.lead.update({ where: { id: existing.id }, data: patch });
|
||||
}
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: existing.id,
|
||||
type: 'note',
|
||||
title: 'درخواست دوباره از سایت',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: false, duplicate: true, leadId: existing.id };
|
||||
}
|
||||
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: personWithMobile(input.organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (person) {
|
||||
return { ok: true, created: false, duplicate: true, leadId: null };
|
||||
}
|
||||
|
||||
await this.stages.ensureDefaults(input.organizationId);
|
||||
const stages = await this.stages.list(input.organizationId);
|
||||
const stageId = stages[0]?.id;
|
||||
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
||||
|
||||
const lead = await this.prisma.lead.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
title,
|
||||
contactName,
|
||||
mobile,
|
||||
source,
|
||||
notes: `ثبت از سایت: ${siteName}`,
|
||||
stageId,
|
||||
},
|
||||
});
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: lead.id,
|
||||
type: 'created',
|
||||
title: 'لید از سایت ثبت شد',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: true, duplicate: false, leadId: lead.id };
|
||||
}
|
||||
|
||||
async createFromImportRow(
|
||||
organizationId: string,
|
||||
createdById: string | null,
|
||||
@@ -1212,10 +1307,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
const existingPerson = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingPerson) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
|
||||
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { dealPriorWhere } from '../common/prior-archive';
|
||||
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
@@ -708,9 +708,9 @@ export class DealsExcelService {
|
||||
): Promise<string | null> {
|
||||
const orgId = actor.organizationId;
|
||||
const code = cleanCode(row.personCode);
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
|
||||
const validMobile = mobileCore.length >= 8 ? mobile : null;
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
|
||||
const first = (row.personFirstName ?? '').trim();
|
||||
const last = (row.personName ?? '').trim();
|
||||
const fullName = [first, last].filter(Boolean).join(' ');
|
||||
@@ -731,7 +731,10 @@ export class DealsExcelService {
|
||||
}
|
||||
if (!found && validMobile) {
|
||||
found = await this.prisma.person.findFirst({
|
||||
where: { organizationId: orgId, mobile: { contains: mobileCore } },
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { TICKET_SLA_HOURS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import type { PortalActor } from './portal-auth';
|
||||
|
||||
@@ -62,7 +63,7 @@ export class PortalService {
|
||||
mobileRaw: string,
|
||||
): Promise<PortalRequestOtpResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
@@ -123,7 +124,8 @@ export class PortalService {
|
||||
codeRaw: string,
|
||||
): Promise<PortalLoginResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const code = codeRaw.trim();
|
||||
if (!mobile || !code) {
|
||||
throw new BadRequestException('موبایل یا کد ناقص است');
|
||||
@@ -148,10 +150,15 @@ export class PortalService {
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: { organizationId: org.id, mobile },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
let person = mobileKeys.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
if (!person) {
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { projectCardAccessWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const needle = mobile.replace(/^\+98|^98|^0/, '');
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: needle } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: string;
|
||||
role: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyName?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: auth.organizationId,
|
||||
role: auth.role,
|
||||
apiKeyId: auth.apiKeyId,
|
||||
apiKeyName: auth.name,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class ApiKeysService {
|
||||
|
||||
async authenticate(
|
||||
rawKey: string,
|
||||
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
|
||||
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
|
||||
const key = rawKey.trim();
|
||||
if (!key.startsWith('mk_') || key.length < 20) {
|
||||
throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
@@ -71,7 +71,7 @@ export class ApiKeysService {
|
||||
const keyHash = this.hashKey(key);
|
||||
const row = await this.prisma.organizationApiKey.findFirst({
|
||||
where: { keyHash, revokedAt: null },
|
||||
select: { id: true, organizationId: true },
|
||||
select: { id: true, organizationId: true, name: true },
|
||||
});
|
||||
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
|
||||
@@ -83,6 +83,7 @@ export class ApiKeysService {
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
apiKeyId: row.id,
|
||||
name: row.name,
|
||||
role: 'owner',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
|
||||
class CreateExternalLeadDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(20)
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
storeName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const LEAD_RATE_WINDOW_MS = 60_000;
|
||||
const LEAD_RATE_MAX = 60;
|
||||
const leadHits = new Map<string, number[]>();
|
||||
|
||||
class ListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -35,7 +72,36 @@ class ListQueryDto {
|
||||
@Controller('public/v1')
|
||||
@UseGuards(ApiKeyAuthGuard)
|
||||
export class PublicApiV1Controller {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly leads: LeadsService,
|
||||
) {}
|
||||
|
||||
@Post('leads')
|
||||
@HttpCode(200)
|
||||
createLead(
|
||||
@CurrentUser()
|
||||
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
|
||||
@Body() body: CreateExternalLeadDto,
|
||||
) {
|
||||
const bucket = user.apiKeyId || user.organizationId;
|
||||
const now = Date.now();
|
||||
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
|
||||
if (prev.length >= LEAD_RATE_MAX) {
|
||||
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
prev.push(now);
|
||||
leadHits.set(bucket, prev);
|
||||
return this.leads.createFromExternal({
|
||||
organizationId: user.organizationId,
|
||||
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
mobile: body.mobile,
|
||||
storeName: body.storeName,
|
||||
source: body.source,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('people')
|
||||
async listPeople(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
imports: [AuthModule, BillingModule, LeadsModule],
|
||||
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
|
||||
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
|
||||
exports: [ApiKeysService, WebhooksService],
|
||||
|
||||
@@ -185,6 +185,7 @@ export function toRoutineDto(
|
||||
entityLink: normalizeEntityLink(row.entityLink),
|
||||
reminderMinutesBefore: row.reminderMinutesBefore,
|
||||
isActive: row.isActive,
|
||||
createdById: row.createdById,
|
||||
recurrenceLabel: describeRecurrence(rule),
|
||||
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
|
||||
executionTotal: execution.total,
|
||||
|
||||
@@ -200,8 +200,7 @@ export class RoutinesService {
|
||||
|
||||
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -216,10 +215,9 @@ export class RoutinesService {
|
||||
|
||||
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoutineUncheckedUpdateInput,
|
||||
@@ -232,9 +230,8 @@ export class RoutinesService {
|
||||
|
||||
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
@@ -251,9 +248,8 @@ export class RoutinesService {
|
||||
dto: ChangeRoutineAssigneeDto,
|
||||
): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
|
||||
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
|
||||
const row = await this.prisma.routine.update({
|
||||
@@ -268,9 +264,8 @@ export class RoutinesService {
|
||||
|
||||
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const src = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, src);
|
||||
this.assertCanWriteRoutine(scope, src);
|
||||
const {
|
||||
id: _id,
|
||||
createdAt: _c,
|
||||
@@ -295,9 +290,8 @@ export class RoutinesService {
|
||||
|
||||
async remove(actor: Actor, id: string) {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), isActive: false },
|
||||
@@ -345,6 +339,27 @@ export class RoutinesService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** عضو عادی فقط روتینی را مینویسد که خودش برای خودش ساخته است. */
|
||||
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
|
||||
if (scope.isManager) return dto;
|
||||
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
|
||||
throw new ForbiddenException('فقط میتوانید برای خودتان روتین بسازید');
|
||||
}
|
||||
return { ...dto, assigneeId: actor.userId, teamId: null };
|
||||
}
|
||||
|
||||
private assertCanWriteRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
) {
|
||||
if (scope.isManager) {
|
||||
this.assertCanManageRoutine(scope, row);
|
||||
return;
|
||||
}
|
||||
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
|
||||
throw new ForbiddenException('فقط روتینی که برای خودتان ساختهاید را میتوانید تغییر دهید');
|
||||
}
|
||||
|
||||
private assertCanManageRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
|
||||
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
||||
}
|
||||
|
||||
async reportExcel(actor: Actor, from?: string, to?: string) {
|
||||
async reportExcel(
|
||||
actor: Actor,
|
||||
from?: string,
|
||||
to?: string,
|
||||
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
|
||||
) {
|
||||
const data = await this.report(actor, from, to);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('گزارش مالی');
|
||||
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
|
||||
sheet.addRow([]);
|
||||
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
|
||||
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
|
||||
if (parties) {
|
||||
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافتشده');
|
||||
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداختشده');
|
||||
}
|
||||
const buffer = await book.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private addPartySheet(
|
||||
book: ExcelJS.Workbook,
|
||||
title: string,
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
settledLabel: string,
|
||||
) {
|
||||
const sheet = book.addWorksheet(title);
|
||||
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
|
||||
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
|
||||
let amount = new Prisma.Decimal(0);
|
||||
let settled = new Prisma.Decimal(0);
|
||||
let remaining = new Prisma.Decimal(0);
|
||||
for (const row of rows) {
|
||||
sheet.addRow([
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
row.amount,
|
||||
row.settledAmount,
|
||||
row.remaining,
|
||||
]);
|
||||
amount = amount.plus(row.amount);
|
||||
settled = settled.plus(row.settledAmount);
|
||||
remaining = remaining.plus(row.remaining);
|
||||
}
|
||||
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
|
||||
}
|
||||
|
||||
private async period(organizationId: string, from: Date, to: Date) {
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -43,6 +43,20 @@ export class TreasuryController {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
updateCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; isActive?: boolean },
|
||||
) {
|
||||
return this.treasury.updateCategory(user, id, body);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.deleteCategory(user, id);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -92,6 +106,29 @@ export class TreasuryController {
|
||||
return this.treasury.transfer(user, body);
|
||||
}
|
||||
|
||||
@Get('parties')
|
||||
parties(@CurrentUser() user: Actor, @Query('q') q?: string) {
|
||||
return this.treasury.searchParties(user, q);
|
||||
}
|
||||
|
||||
@Post('parties/person')
|
||||
createPartyPerson(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { firstName?: string; lastName?: string },
|
||||
) {
|
||||
return this.treasury.createPartyPerson(user, body);
|
||||
}
|
||||
|
||||
@Post('parties/company')
|
||||
createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) {
|
||||
return this.treasury.createPartyCompany(user, body);
|
||||
}
|
||||
|
||||
@Get('party-balances')
|
||||
partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
|
||||
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
documents(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -232,7 +269,11 @@ export class TreasuryController {
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
const buffer = await this.ops.reportExcel(user, from, to);
|
||||
const [debtors, creditors] = await Promise.all([
|
||||
this.treasury.partyBalances(user, 'income'),
|
||||
this.treasury.partyBalances(user, 'expense'),
|
||||
]);
|
||||
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-report.xlsx"',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ContactsModule } from '../contacts/contacts.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
imports: [AuthModule, NotificationsModule, ContactsModule],
|
||||
controllers: [TreasuryController],
|
||||
providers: [
|
||||
TreasuryService,
|
||||
|
||||
@@ -13,8 +13,14 @@ import type {
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialMovementDto,
|
||||
FinancialPartyKind,
|
||||
TreasuryDashboardDto,
|
||||
TreasuryPartyBalanceDto,
|
||||
TreasuryPartySearchDto,
|
||||
} from '@mento/shared';
|
||||
import { CompaniesService } from '../contacts/companies.service';
|
||||
import { PeopleService } from '../contacts/people.service';
|
||||
import { dateToJalali } from '../common/jalali';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
@@ -65,6 +71,8 @@ export class TreasuryService {
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly storage: StorageQuotaService,
|
||||
private readonly people: PeopleService,
|
||||
private readonly companies: CompaniesService,
|
||||
) {}
|
||||
|
||||
async ensureCategories(organizationId: string) {
|
||||
@@ -112,19 +120,68 @@ export class TreasuryService {
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCategory(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { name?: string; isActive?: boolean },
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
const name = input.name == null ? current.name : input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
name,
|
||||
...(input.isActive == null ? {} : { isActive: input.isActive }),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
await this.prisma.financialCategory.delete({ where: { id: current.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listAccounts(
|
||||
@@ -462,7 +519,10 @@ export class TreasuryService {
|
||||
bookedAt?: string;
|
||||
dueAt?: string | null;
|
||||
categoryId?: string | null;
|
||||
partyKind?: FinancialPartyKind | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
userId?: string | null;
|
||||
partyName?: string | null;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
@@ -476,9 +536,13 @@ export class TreasuryService {
|
||||
const amount = dec(input.amount);
|
||||
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const party = await this.resolveParty(actor, input);
|
||||
const settle = input.settleAmount == null || input.settleAmount === ''
|
||||
? new Prisma.Decimal(0)
|
||||
: dec(input.settleAmount);
|
||||
if (party.partyKind === 'other' && !settle.equals(amount)) {
|
||||
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
|
||||
}
|
||||
if (settle.isNegative() || settle.greaterThan(amount)) {
|
||||
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
|
||||
}
|
||||
@@ -497,6 +561,13 @@ export class TreasuryService {
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
const reference = await this.nextDocumentReference(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
input.kind,
|
||||
input.bookedAt,
|
||||
bookedAt,
|
||||
);
|
||||
const created = await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
@@ -506,12 +577,15 @@ export class TreasuryService {
|
||||
bookedAt,
|
||||
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
||||
categoryId: input.categoryId || null,
|
||||
personId: input.personId || null,
|
||||
partyName: input.partyName?.trim() || null,
|
||||
personId: party.personId,
|
||||
companyId: party.companyId,
|
||||
userId: party.userId,
|
||||
partyKind: party.partyKind,
|
||||
partyName: party.partyName,
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
@@ -527,7 +601,7 @@ export class TreasuryService {
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
@@ -540,6 +614,221 @@ export class TreasuryService {
|
||||
return this.toDocumentDto(doc);
|
||||
}
|
||||
|
||||
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
|
||||
private async nextDocumentReference(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
kind: 'income' | 'expense',
|
||||
bookedAtInput: string | undefined,
|
||||
bookedAt: Date,
|
||||
) {
|
||||
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
const when = match
|
||||
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
: bookedAt;
|
||||
const { jy, jm, jd } = dateToJalali(when);
|
||||
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
|
||||
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
|
||||
const latest = await tx.financialDocument.findFirst({
|
||||
where: { organizationId, reference: { startsWith: prefix } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const current = Number(latest?.reference?.slice(prefix.length));
|
||||
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
|
||||
return `${prefix}${String(next).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
|
||||
const term = q?.trim() ?? '';
|
||||
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
|
||||
const [people, companies, users] = await Promise.all([
|
||||
this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}),
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.company.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(contains ? { name: contains } : {}),
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
...(contains ? { fullName: contains } : {}),
|
||||
},
|
||||
select: { id: true, fullName: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
people: people.map((row) => ({
|
||||
id: row.id,
|
||||
name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری',
|
||||
})),
|
||||
companies,
|
||||
users: users.map((row) => ({ id: row.id, name: row.fullName })),
|
||||
};
|
||||
}
|
||||
|
||||
async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) {
|
||||
const firstName = input.firstName?.trim() ?? '';
|
||||
const lastName = input.lastName?.trim() ?? '';
|
||||
if (`${firstName} ${lastName}`.trim().length < 2) {
|
||||
throw new BadRequestException('نام شخص را وارد کنید.');
|
||||
}
|
||||
const person = await this.people.create(actor, { firstName, lastName });
|
||||
return { id: person.id, name: person.fullName, kind: 'person' as const };
|
||||
}
|
||||
|
||||
async createPartyCompany(actor: Actor, input: { name?: string }) {
|
||||
const name = input.name?.trim() ?? '';
|
||||
if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.');
|
||||
const company = await this.companies.create(actor, { name });
|
||||
return { id: company.id, name: company.name, kind: 'company' as const };
|
||||
}
|
||||
|
||||
async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise<TreasuryPartyBalanceDto[]> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const rows = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind,
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
partyKind: true,
|
||||
personId: true,
|
||||
companyId: true,
|
||||
userId: true,
|
||||
partyName: true,
|
||||
movements: { select: { amount: true, voidedAt: true } },
|
||||
},
|
||||
});
|
||||
const buckets = new Map<string, {
|
||||
key: string;
|
||||
partyKind: FinancialPartyKind | null;
|
||||
partyName: string;
|
||||
amount: Prisma.Decimal;
|
||||
settled: Prisma.Decimal;
|
||||
remaining: Prisma.Decimal;
|
||||
}>();
|
||||
for (const row of rows) {
|
||||
if (row.partyKind === 'other') continue;
|
||||
const settled = this.settledOf(row.movements);
|
||||
const rawRemaining = row.amount.minus(settled);
|
||||
if (!rawRemaining.greaterThan(0)) continue;
|
||||
const linkedId = row.personId || row.companyId || row.userId;
|
||||
const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user'
|
||||
? row.partyKind
|
||||
: null;
|
||||
const key = partyKind && linkedId
|
||||
? `${partyKind}:${linkedId}`
|
||||
: `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`;
|
||||
const current = buckets.get(key) ?? {
|
||||
key,
|
||||
partyKind,
|
||||
partyName: row.partyName?.trim() || 'بدون طرف حساب',
|
||||
amount: new Prisma.Decimal(0),
|
||||
settled: new Prisma.Decimal(0),
|
||||
remaining: new Prisma.Decimal(0),
|
||||
};
|
||||
current.amount = current.amount.plus(row.amount);
|
||||
current.settled = current.settled.plus(settled);
|
||||
current.remaining = current.remaining.plus(rawRemaining);
|
||||
buckets.set(key, current);
|
||||
}
|
||||
return [...buckets.values()]
|
||||
.sort((a, b) => b.remaining.comparedTo(a.remaining))
|
||||
.map((row) => ({
|
||||
key: row.key,
|
||||
partyKind: row.partyKind,
|
||||
partyName: row.partyName,
|
||||
amount: money(row.amount),
|
||||
settledAmount: money(row.settled),
|
||||
remaining: money(row.remaining),
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveParty(
|
||||
actor: Actor,
|
||||
input: {
|
||||
partyKind?: FinancialPartyKind | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
userId?: string | null;
|
||||
},
|
||||
) {
|
||||
const kind = input.partyKind;
|
||||
if (kind === 'other') {
|
||||
return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' };
|
||||
}
|
||||
if (kind === 'person') {
|
||||
const id = input.personId?.trim();
|
||||
if (!id) throw new BadRequestException('مشتری را انتخاب کنید.');
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('مشتری پیدا نشد.');
|
||||
return {
|
||||
partyKind: 'person' as const,
|
||||
personId: person.id,
|
||||
companyId: null,
|
||||
userId: null,
|
||||
partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری',
|
||||
};
|
||||
}
|
||||
if (kind === 'company') {
|
||||
const id = input.companyId?.trim();
|
||||
if (!id) throw new BadRequestException('شرکت را انتخاب کنید.');
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت پیدا نشد.');
|
||||
return {
|
||||
partyKind: 'company' as const,
|
||||
personId: null,
|
||||
companyId: company.id,
|
||||
userId: null,
|
||||
partyName: company.name,
|
||||
};
|
||||
}
|
||||
if (kind === 'user') {
|
||||
const id = input.userId?.trim();
|
||||
if (!id) throw new BadRequestException('کاربر را انتخاب کنید.');
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { id: true, fullName: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException('کاربر پیدا نشد.');
|
||||
return {
|
||||
partyKind: 'user' as const,
|
||||
personId: null,
|
||||
companyId: null,
|
||||
userId: user.id,
|
||||
partyName: user.fullName,
|
||||
};
|
||||
}
|
||||
throw new BadRequestException('طرف حساب را انتخاب کنید.');
|
||||
}
|
||||
|
||||
async settleDocument(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
@@ -948,7 +1237,10 @@ export class TreasuryService {
|
||||
bookedAt: Date;
|
||||
dueAt: Date | null;
|
||||
categoryId: string | null;
|
||||
partyKind?: string | null;
|
||||
personId: string | null;
|
||||
companyId?: string | null;
|
||||
userId?: string | null;
|
||||
partyName: string | null;
|
||||
accountId: string | null;
|
||||
method: string | null;
|
||||
@@ -975,7 +1267,10 @@ export class TreasuryService {
|
||||
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.category?.name ?? null,
|
||||
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
|
||||
personId: row.personId,
|
||||
companyId: row.companyId ?? null,
|
||||
userId: row.userId ?? null,
|
||||
partyName: row.partyName,
|
||||
accountId: row.accountId,
|
||||
accountName: row.account?.name ?? null,
|
||||
|
||||
Reference in New Issue
Block a user