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:
2026-11-05 15:10:48 +03:30
55 changed files with 2719 additions and 358 deletions
@@ -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;
+23
View File
@@ -295,6 +295,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner") ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner") ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner") ownedCompanies Company[] @relation("CompanyOwner")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee") activities Activity[] @relation("ActivityAssignee")
assignedActivities Activity[] @relation("ActivityAssignees") assignedActivities Activity[] @relation("ActivityAssignees")
@@ -524,6 +525,7 @@ model Company {
customFields Json @default("{}") customFields Json @default("{}")
groupLinks CompanyGroupLink[] groupLinks CompanyGroupLink[]
people Person[] people Person[]
financialDocuments FinancialDocument[]
deals Deal[] deals Deal[]
activities Activity[] activities Activity[]
smartNotes Note[] smartNotes Note[]
@@ -1841,6 +1843,13 @@ model AccountingConnection {
lastHealthAt DateTime? lastHealthAt DateTime?
lastHealthOk Boolean? lastHealthOk Boolean?
lastError String? lastError String?
/// ساعت‌های بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
customerSyncHours Int[] @default([])
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
customerSyncLastSlot String?
customerSyncLastRunAt DateTime?
customerSyncLastSummary String?
customerSyncLastError String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[] personLinks PersonAccountingLink[]
@@ -2330,6 +2339,12 @@ model FinancialDocument {
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) 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? partyName String?
accountId String? accountId String?
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull) account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
@@ -2350,6 +2365,8 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt]) @@index([organizationId, kind, bookedAt])
@@index([organizationId, status]) @@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([companyId])
@@index([userId])
} }
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -3769,3 +3786,9 @@ model AttendanceEvent {
@@index([organizationId, userId, at]) @@index([organizationId, userId, at])
@@unique([organizationId, userId, kind, sessionStartedAt]) @@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 { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope'; import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatClock, formatClock,
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
return id; return id;
}; };
const resolvePerson = async (row: Record<string, string>) => { const resolvePerson = async (row: Record<string, string>) => {
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const name = (row.personName ?? '').trim().toLowerCase(); const name = (row.personName ?? '').trim().toLowerCase();
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : ''; const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
if (!key) return null; if (!key) return null;
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile); const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobile) { if (mobileKeys.length) {
const byMobile = await this.prisma.person.findFirst({ 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 }, select: { id: true },
}); });
if (byMobile) return byMobile.id; if (byMobile) return byMobile.id;
+2 -1
View File
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller'; import { PeopleController } from './people.controller';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service'; import { ContactsExcelService } from './contacts-excel.service';
import { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module'; import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'; import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
CustomerGroupsModule, CustomerGroupsModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService], providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
exports: [PeopleService, CompaniesService], exports: [PeopleService, CompaniesService],
}) })
export class ContactsModule {} export class ContactsModule {}
+367
View File
@@ -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());
}
+33
View File
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
const cleaned = latinDigits(v).replace(/[^\d+]/g, ''); const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null; 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);
}
+18 -7
View File
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; import {
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import { import {
asAddresses, asAddresses,
asCustomFields, asCustomFields,
@@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit {
input: { mobile?: string | null; email?: string | null; birthDate: Date }, input: { mobile?: string | null; email?: string | null; birthDate: Date },
) { ) {
const or: Prisma.PersonWhereInput[] = []; 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 (input.email) or.push({ email: input.email });
if (!or.length) return; if (!or.length) return;
await this.prisma.person.updateMany({ await this.prisma.person.updateMany({
@@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit {
importSource: normalizePersonImportSource(dto.importSource), importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef), sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email), email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone), phone: storedPhoneNumber(dto.phone),
mobile: normalizePhone(dto.mobile), mobile: storedPhoneNumber(dto.mobile),
position: emptyToNull(dto.position), position: emptyToNull(dto.position),
website: emptyToNull(dto.website), website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram), instagram: emptyToNull(dto.instagram),
@@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit {
} }
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef); if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email); if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone); if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position); if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website); if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); 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 email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null; const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const mobileKeys = phoneIdentityKeys(mobile);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); 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; if (!or.length) return;
const clash = await this.prisma.person.findFirst({ 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'; } from '@mento/shared';
import { parseBirthDate } from '../../common/excel'; import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service'; 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 { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope'; import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service'; import { MontifyConnectionsService } from './connections.service';
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
} }
function mobileVariants(mobile: string): string[] { function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, ''); return phoneIdentityKeys(mobile);
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}`])];
} }
function formatAddress(a: MontifyAddress): string | null { function formatAddress(a: MontifyAddress): string | null {
@@ -417,7 +415,7 @@ export class MontifySyncService {
}> { }> {
const externalId = String(c.id); const externalId = String(c.id);
const { firstName, lastName } = splitName(c); const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null); const mobile = storedPhoneNumber(c.phone ?? null);
const email = normalizeEmail(c.email ?? null); const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a)); const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = { const profile: CustomerProfile = {
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
lastHealthAt: Date | null; lastHealthAt: Date | null;
lastHealthOk: boolean | null; lastHealthOk: boolean | null;
lastError: string | null; lastError: string | null;
customerSyncHours: number[];
customerSyncLastRunAt: Date | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}) { }) {
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
lastHealthAt: row.lastHealthAt?.toISOString() ?? null, lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk, lastHealthOk: row.lastHealthOk,
lastError: row.lastError, 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(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}; };
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id); 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 = { let enc = {
ciphertext: row.tokenCiphertext, ciphertext: row.tokenCiphertext,
iv: row.tokenIv, iv: row.tokenIv,
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
enabled: dto.enabled ?? row.enabled, enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault, isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls, allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
}, },
}); });
return this.toDto(updated); 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}`);
}
}
}
+10
View File
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
allowInsecureTls?: boolean; allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
} }
export class ManualLinkDto { export class ManualLinkDto {
@@ -8,7 +8,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource } from '../../contacts/person-source'; import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
import {
canonicalIranMobile,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { applyPersonGenderTitle } from '@mento/shared'; import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { import {
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; 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 */ /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000; const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000; const LIST_TIMEOUT_MS = 55_000;
@@ -539,6 +558,7 @@ export class TahesabPersonLinkService {
organizationId: string, organizationId: string,
dto: ImportFromTahesabDto, dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> }, prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
options?: { skipJob?: boolean },
) { ) {
const clash = await this.prisma.personAccountingLink.findFirst({ const clash = await this.prisma.personAccountingLink.findFirst({
where: { where: {
@@ -577,10 +597,11 @@ export class TahesabPersonLinkService {
const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName; const firstName = named.firstName;
const lastName = named.lastName; const lastName = named.lastName;
const mobile = tel || null; const mobile = storedPhoneNumber(tel);
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
let preserveExistingLink = false;
if (personId) { if (personId) {
const existing = await this.getPerson(organizationId, personId); const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName; const prevFirst = existing.firstName;
@@ -604,28 +625,33 @@ export class TahesabPersonLinkService {
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile ...canonicalMobileUpdate(mobile, existing),
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. // تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
const byMobile = mobile const mobileKeys = phoneIdentityKeys(mobile);
? await this.prisma.person.findFirst({ const matches = mobileKeys.length
? await this.prisma.person.findMany({
where: { where: {
organizationId, organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
accountingLinks: { none: { connectionId: dto.connectionId } },
}, },
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) { if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
const nameChanged = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -644,8 +670,9 @@ export class TahesabPersonLinkService {
: {}), : {}),
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
}, },
}); });
personId = byMobile.id; 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({ const link = await this.prisma.personAccountingLink.upsert({
where: { where: {
personId_connectionId: { personId_connectionId: {
@@ -693,18 +746,169 @@ export class TahesabPersonLinkService {
}); });
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); 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({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
connectionId: dto.connectionId, connectionId,
kind: 'import', kind: 'customer-sync',
status: 'success', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
request: dto as unknown as Prisma.InputJsonValue, result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
}, },
}); });
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return { personId, link: this.linkDto(link) }; return {
imported,
updated,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
} }
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service'; import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service'; import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
import { TahesabController } from './tahesab.controller'; import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService, TahesabAsnadReportService,
TahesabInvoiceSettingsService, TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
], ],
exports: [ exports: [
TahesabCryptoService, TahesabCryptoService,
@@ -11,7 +11,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
variants.add(`0${d}`); variants.add(`0${d}`);
variants.add(`98${d}`); variants.add(`98${d}`);
} }
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
return [...variants]; return [...variants];
} }
@@ -6,7 +6,11 @@ import type {
WooCommerceSyncResultDto, WooCommerceSyncResultDto,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; 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 { WooConnectionsService } from './connections.service';
import type { WooOrder } from './woo-client'; import type { WooOrder } from './woo-client';
@@ -374,7 +378,8 @@ export class WooSyncService {
): Promise<string | null> { ): Promise<string | null> {
const billing = order.billing ?? {}; const billing = order.billing ?? {};
const email = normalizeEmail(billing.email ?? null); 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 firstName = (billing.first_name ?? '').trim() || 'مشتری';
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس'; const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
@@ -387,7 +392,9 @@ export class WooSyncService {
organizationId, organizationId,
OR: [ OR: [
...(email ? [{ email }] : []), ...(email ? [{ email }] : []),
...(mobile ? [{ mobile }, { phone: mobile }] : []), ...(mobileKeys.length
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
: []),
], ],
}, },
select: { id: true }, select: { id: true },
+16 -8
View File
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { AutomationsService } from '../automations/automations.service'; import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { import {
canonicalIranMobile,
emptyToNull, emptyToNull,
normalizeEmail, normalizeEmail,
normalizePhone, normalizePhone,
phoneIdentityKeys,
storedPhoneNumber,
} from '../contacts/normalize'; } from '../contacts/normalize';
import { LeadStagesService } from '../leads/lead-stages.service'; import { LeadStagesService } from '../leads/lead-stages.service';
@@ -458,7 +461,10 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { where: {
organizationId: form.organizationId, organizationId: form.organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
}, },
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}) })
@@ -641,13 +647,16 @@ export class LeadFormsService {
ownerId: string, ownerId: string,
): Promise<string> { ): Promise<string> {
const email = normalizeEmail(mapped.email); const email = normalizeEmail(mapped.email);
const mobile = normalizePhone(mapped.mobile); const mobile = storedPhoneNumber(mapped.mobile);
const phone = normalizePhone(mapped.phone); const phone = storedPhoneNumber(mapped.phone);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); 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 const existing = or.length
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { organizationId, OR: or }, where: { organizationId, OR: or },
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
} }
function mobileKey(raw: string | null | undefined): string | null { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || 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;
} }
function normalizeDuplicateMode(raw?: string | null): string { function normalizeDuplicateMode(raw?: string | null): string {
+110 -18
View File
@@ -38,7 +38,12 @@ import {
collectAddresses, collectAddresses,
toJsonAddresses, toJsonAddresses,
} from '../common/custom-fields'; } 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 { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.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 { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || 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; 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 { function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
@@ -323,10 +335,7 @@ export class LeadsService {
}; };
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, mobile),
organizationId: actor.organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}); });
if (person) { if (person) {
@@ -966,10 +975,7 @@ export class LeadsService {
} }
let person = await this.prisma.person.findFirst({ let person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, lead.mobile),
organizationId: actor.organizationId,
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
},
}); });
let createdPerson = false; let createdPerson = false;
@@ -996,7 +1002,7 @@ export class LeadsService {
lastName: named.lastName, lastName: named.lastName,
gender: named.gender, gender: named.gender,
showGenderInName: named.showGenderInName, showGenderInName: named.showGenderInName,
mobile: lead.mobile, mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
phone: lead.phone, phone: lead.phone,
instagram: lead.instagram, instagram: lead.instagram,
birthDate: lead.birthDate, 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( async createFromImportRow(
organizationId: string, organizationId: string,
createdById: string | null, createdById: string | null,
@@ -1212,10 +1307,7 @@ export class LeadsService {
} }
const existingPerson = await this.prisma.person.findFirst({ const existingPerson = await this.prisma.person.findFirst({
where: { where: personWithMobile(organizationId, mobile),
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true }, select: { id: true },
}); });
if (existingPerson) { if (existingPerson) {
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope'; import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive'; import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -708,9 +708,9 @@ export class DealsExcelService {
): Promise<string | null> { ): Promise<string | null> {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const code = cleanCode(row.personCode); const code = cleanCode(row.personCode);
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : ''; const mobileKeys = phoneIdentityKeys(mobile);
const validMobile = mobileCore.length >= 8 ? mobile : null; const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim(); const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim(); const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' '); const fullName = [first, last].filter(Boolean).join(' ');
@@ -731,7 +731,10 @@ export class DealsExcelService {
} }
if (!found && validMobile) { if (!found && validMobile) {
found = await this.prisma.person.findFirst({ 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 }, select: { id: true },
}); });
} }
+13 -6
View File
@@ -19,6 +19,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth'; import type { PortalActor } from './portal-auth';
@@ -62,7 +63,7 @@ export class PortalService {
mobileRaw: string, mobileRaw: string,
): Promise<PortalRequestOtpResponse> { ): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
if (!mobile) { if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست'); throw new BadRequestException('شماره موبایل معتبر نیست');
} }
@@ -123,7 +124,8 @@ export class PortalService {
codeRaw: string, codeRaw: string,
): Promise<PortalLoginResponse> { ): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobileKeys = phoneIdentityKeys(mobile);
const code = codeRaw.trim(); const code = codeRaw.trim();
if (!mobile || !code) { if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است'); throw new BadRequestException('موبایل یا کد ناقص است');
@@ -148,10 +150,15 @@ export class PortalService {
data: { consumedAt: new Date() }, data: { consumedAt: new Date() },
}); });
let person = await this.prisma.person.findFirst({ let person = mobileKeys.length
where: { organizationId: org.id, mobile }, ? await this.prisma.person.findFirst({
orderBy: { createdAt: 'asc' }, where: {
}); organizationId: org.id,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
orderBy: { createdAt: 'asc' },
})
: null;
if (!person) { if (!person) {
person = await this.prisma.person.create({ person = await this.prisma.person.create({
data: { data: {
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { projectCardAccessWhere } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile); const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobile) { if (mobileKeys.length) {
const needle = mobile.replace(/^\+98|^98|^0/, '');
const byMobile = await this.prisma.person.findFirst({ const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: needle } }, where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true }, select: { id: true },
}); });
if (byMobile) return byMobile.id; if (byMobile) return byMobile.id;
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string; organizationId: string;
role: string; role: string;
apiKeyId?: string; apiKeyId?: string;
apiKeyName?: string;
}; };
}>(); }>();
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: auth.organizationId, organizationId: auth.organizationId,
role: auth.role, role: auth.role,
apiKeyId: auth.apiKeyId, apiKeyId: auth.apiKeyId,
apiKeyName: auth.name,
}; };
return true; return true;
} }
+3 -2
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate( async authenticate(
rawKey: string, rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> { ): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
const key = rawKey.trim(); const key = rawKey.trim();
if (!key.startsWith('mk_') || key.length < 20) { if (!key.startsWith('mk_') || key.length < 20) {
throw new UnauthorizedException('کلید API نامعتبر است'); throw new UnauthorizedException('کلید API نامعتبر است');
@@ -71,7 +71,7 @@ export class ApiKeysService {
const keyHash = this.hashKey(key); const keyHash = this.hashKey(key);
const row = await this.prisma.organizationApiKey.findFirst({ const row = await this.prisma.organizationApiKey.findFirst({
where: { keyHash, revokedAt: null }, where: { keyHash, revokedAt: null },
select: { id: true, organizationId: true }, select: { id: true, organizationId: true, name: true },
}); });
if (!row) throw new UnauthorizedException('کلید API نامعتبر است'); if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,6 +83,7 @@ export class ApiKeysService {
return { return {
organizationId: row.organizationId, organizationId: row.organizationId,
apiKeyId: row.id, apiKeyId: row.id,
name: row.name,
role: 'owner', role: 'owner',
}; };
} }
@@ -1,18 +1,55 @@
import { import {
Body,
Controller, Controller,
Get, Get,
HttpCode,
HttpException,
HttpStatus,
Param, Param,
Post,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { Type } from 'class-transformer';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { LeadsService } from '../leads/leads.service';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; 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 { class ListQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -35,7 +72,36 @@ class ListQueryDto {
@Controller('public/v1') @Controller('public/v1')
@UseGuards(ApiKeyAuthGuard) @UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller { 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') @Get('people')
async listPeople( async listPeople(
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { LeadsModule } from '../leads/leads.module';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; import { ApiKeyAuthGuard } from './api-key-auth.guard';
import { ApiKeysController } from './api-keys.controller'; import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service'; import { ApiKeysService } from './api-keys.service';
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service'; import { WebhooksService } from './webhooks.service';
@Module({ @Module({
imports: [AuthModule, BillingModule], imports: [AuthModule, BillingModule, LeadsModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller], controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService], providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService], exports: [ApiKeysService, WebhooksService],
+1
View File
@@ -185,6 +185,7 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink), entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore, reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive, isActive: row.isActive,
createdById: row.createdById,
recurrenceLabel: describeRecurrence(rule), recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null, executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total, executionTotal: execution.total,
+28 -13
View File
@@ -200,8 +200,7 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> { async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope); const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({ const row = await this.prisma.routine.create({
data: { data: {
...data, ...data,
@@ -216,10 +215,9 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> { async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
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.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput, data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -232,9 +230,8 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> { async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: { isActive }, data: { isActive },
@@ -251,9 +248,8 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto, dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> { ): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); 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); const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین'); await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
@@ -268,9 +264,8 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> { async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id); const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src); this.assertCanWriteRoutine(scope, src);
const { const {
id: _id, id: _id,
createdAt: _c, createdAt: _c,
@@ -295,9 +290,8 @@ export class RoutinesService {
async remove(actor: Actor, id: string) { async remove(actor: Actor, id: string) {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
await this.prisma.routine.update({ await this.prisma.routine.update({
where: { id }, where: { id },
data: { deletedAt: new Date(), isActive: false }, data: { deletedAt: new Date(), isActive: false },
@@ -345,6 +339,27 @@ export class RoutinesService {
return row; 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( private assertCanManageRoutine(
scope: RoutineScope, scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
+38 -1
View File
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown }; 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 data = await this.report(actor, from, to);
const book = new ExcelJS.Workbook(); const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('گزارش مالی'); const sheet = book.addWorksheet('گزارش مالی');
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
sheet.addRow([]); sheet.addRow([]);
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']); sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share])); 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(); const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer); 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) { private async period(organizationId: string, from: Date, to: Date) {
const docs = await this.prisma.financialDocument.findMany({ const docs = await this.prisma.financialDocument.findMany({
where: { where: {
+43 -2
View File
@@ -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 { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,6 +43,20 @@ export class TreasuryController {
return this.treasury.createCategory(user, body); 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') @Get('accounts')
accounts( accounts(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -92,6 +106,29 @@ export class TreasuryController {
return this.treasury.transfer(user, body); 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') @Get('documents')
documents( documents(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -232,7 +269,11 @@ export class TreasuryController {
@Query('from') from?: string, @Query('from') from?: string,
@Query('to') to?: 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, { return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="treasury-report.xlsx"', disposition: 'attachment; filename="treasury-report.xlsx"',
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller'; import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard'; import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service'; import { TreasuryService } from './treasury.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule], imports: [AuthModule, NotificationsModule, ContactsModule],
controllers: [TreasuryController], controllers: [TreasuryController],
providers: [ providers: [
TreasuryService, TreasuryService,
+311 -16
View File
@@ -13,8 +13,14 @@ import type {
FinancialCategoryDto, FinancialCategoryDto,
FinancialDocumentDto, FinancialDocumentDto,
FinancialMovementDto, FinancialMovementDto,
FinancialPartyKind,
TreasuryDashboardDto, TreasuryDashboardDto,
TreasuryPartyBalanceDto,
TreasuryPartySearchDto,
} from '@mento/shared'; } 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 { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
@@ -65,6 +71,8 @@ export class TreasuryService {
private readonly receivables: TreasuryReceivablesService, private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService, private readonly storage: StorageQuotaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {} ) {}
async ensureCategories(organizationId: string) { async ensureCategories(organizationId: string) {
@@ -112,19 +120,68 @@ export class TreasuryService {
): Promise<FinancialCategoryDto> { ): Promise<FinancialCategoryDto> {
const name = input.name.trim(); const name = input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
const row = await this.prisma.financialCategory.create({ try {
data: { const row = await this.prisma.financialCategory.create({
organizationId: actor.organizationId, data: {
name, organizationId: actor.organizationId,
kind: input.kind, 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 { if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
id: row.id, const name = input.name == null ? current.name : input.name.trim();
name: row.name, if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
kind: row.kind as FinancialCategoryDto['kind'], try {
isActive: row.isActive, 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( async listAccounts(
@@ -462,7 +519,10 @@ export class TreasuryService {
bookedAt?: string; bookedAt?: string;
dueAt?: string | null; dueAt?: string | null;
categoryId?: string | null; categoryId?: string | null;
partyKind?: FinancialPartyKind | null;
personId?: string | null; personId?: string | null;
companyId?: string | null;
userId?: string | null;
partyName?: string | null; partyName?: string | null;
method?: string | null; method?: string | null;
reference?: string | null; reference?: string | null;
@@ -476,9 +536,13 @@ export class TreasuryService {
const amount = dec(input.amount); const amount = dec(input.amount);
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.'); if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const party = await this.resolveParty(actor, input);
const settle = input.settleAmount == null || input.settleAmount === '' const settle = input.settleAmount == null || input.settleAmount === ''
? new Prisma.Decimal(0) ? new Prisma.Decimal(0)
: dec(input.settleAmount); : dec(input.settleAmount);
if (party.partyKind === 'other' && !settle.equals(amount)) {
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
}
if (settle.isNegative() || settle.greaterThan(amount)) { if (settle.isNegative() || settle.greaterThan(amount)) {
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.'); throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
} }
@@ -497,6 +561,13 @@ export class TreasuryService {
} }
const bookedAt = startOfDay(input.bookedAt) ?? new Date(); const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const doc = await this.prisma.$transaction(async (tx) => { 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({ const created = await tx.financialDocument.create({
data: { data: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -506,12 +577,15 @@ export class TreasuryService {
bookedAt, bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null, dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null, categoryId: input.categoryId || null,
personId: input.personId || null, personId: party.personId,
partyName: input.partyName?.trim() || null, companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
accountId: input.accountId || null, accountId: input.accountId || null,
method: input.method || null, method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference: input.reference?.trim() || null, reference,
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, createdById: actor.userId,
}, },
@@ -527,7 +601,7 @@ export class TreasuryService {
kind: input.kind === 'income' ? 'receipt' : 'payment', kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title, memo: title,
reference: input.reference?.trim() || null, reference,
createdById: actor.userId, createdById: actor.userId,
}, },
}); });
@@ -540,6 +614,221 @@ export class TreasuryService {
return this.toDocumentDto(doc); 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( async settleDocument(
actor: Actor, actor: Actor,
id: string, id: string,
@@ -948,7 +1237,10 @@ export class TreasuryService {
bookedAt: Date; bookedAt: Date;
dueAt: Date | null; dueAt: Date | null;
categoryId: string | null; categoryId: string | null;
partyKind?: string | null;
personId: string | null; personId: string | null;
companyId?: string | null;
userId?: string | null;
partyName: string | null; partyName: string | null;
accountId: string | null; accountId: string | null;
method: string | null; method: string | null;
@@ -975,7 +1267,10 @@ export class TreasuryService {
dueAt: row.dueAt ? row.dueAt.toISOString() : null, dueAt: row.dueAt ? row.dueAt.toISOString() : null,
categoryId: row.categoryId, categoryId: row.categoryId,
categoryName: row.category?.name ?? null, categoryName: row.category?.name ?? null,
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
personId: row.personId, personId: row.personId,
companyId: row.companyId ?? null,
userId: row.userId ?? null,
partyName: row.partyName, partyName: row.partyName,
accountId: row.accountId, accountId: row.accountId,
accountName: row.account?.name ?? null, accountName: row.account?.name ?? null,
+8 -5
View File
@@ -264,10 +264,12 @@ defineExpose({ refresh: load });
.layer { .layer {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 80; z-index: 120;
display: grid; display: flex;
place-items: center; align-items: center;
justify-content: center;
padding: 1rem; padding: 1rem;
overflow: auto;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -279,8 +281,9 @@ defineExpose({ refresh: load });
.modal { .modal {
position: relative; position: relative;
width: min(420px, 100%); width: min(420px, calc(100vw - 2rem));
max-height: min(90vh, 640px); max-height: calc(100vh - 2rem);
margin: auto;
overflow: auto; overflow: auto;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
@@ -76,11 +76,13 @@ const priorityFlag = computed(() =>
const cardStyle = computed(() => ({ const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab', cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.45 : 1, opacity: props.dragging ? 0.4 : 1,
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
margin: 0, margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer, background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary, borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: token.value.boxShadowTertiary, boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
})); }));
</script> </script>
@@ -13,7 +13,7 @@ import {
import AddDealButton from './AddDealButton.vue'; import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue'; import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board'; import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -51,6 +51,8 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const { token } = theme.useToken(); const { token } = theme.useToken();
const bodyEl = ref<HTMLElement | null>(null);
const insertAt = ref<number | null>(null);
const renaming = ref(false); const renaming = ref(false);
const draft = ref(''); const draft = ref('');
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null); const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
margin: 0, margin: 0,
})); }));
function onCardDragOver(stageId: string, event: DragEvent) { function trackDrag(event: DragEvent, index: number | null) {
event.preventDefault(); event.preventDefault();
dragScrollColumn(bodyEl.value, event.clientY);
if (index != null) insertAt.value = index;
}
function clearDragUi() {
insertAt.value = null;
stopDragScroll();
}
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
event.stopPropagation(); event.stopPropagation();
trackDrag(event, pointerDropIndex(index, event));
emit('dragOver', stageId, event); emit('dragOver', stageId, event);
} }
function onColumnDrop(event: DragEvent) {
const at = insertAt.value ?? undefined;
clearDragUi();
emit('drop', props.stage.id, event, at);
}
function onCardDrop(stageId: string, index: number, event: DragEvent) { function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
emit('drop', stageId, event, pointerDropIndex(index, event)); const at = insertAt.value ?? pointerDropIndex(index, event);
clearDragUi();
emit('drop', stageId, event, at);
}
function slotStyle(active: boolean, collapseGap = false) {
return {
height: active ? '36px' : '0px',
marginTop: !active && collapseGap ? '-12px' : '0',
borderRadius: `${token.value.borderRadius}px`,
background: active ? token.value.colorPrimaryBg : 'transparent',
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
outlineOffset: '-1px',
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
overflow: 'hidden',
flexShrink: 0,
};
} }
function startRename() { function startRename() {
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
<template> <template>
<section <section
:style="columnStyle" :style="columnStyle"
@dragover.prevent="emit('dragOver', stage.id, $event)" @dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)" @drop.prevent="onColumnDrop"
> >
<div :style="headStyle"> <div :style="headStyle">
<span <span
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
</a-dropdown> </a-dropdown>
</div> </div>
<div role="list" :aria-label="stage.title" :style="bodyStyle"> <div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
<div <div
v-for="(deal, index) in stage.deals" v-for="(deal, index) in stage.deals"
:key="deal.id" :key="deal.id"
@dragover="onCardDragOver(stage.id, $event)" @dragover="onCardDragOver(stage.id, index, $event)"
@drop="onCardDrop(stage.id, index, $event)" @drop="onCardDrop(stage.id, index, $event)"
> >
<div
:style="slotStyle(dropActive && insertAt === index)"
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
/>
<DealCard <DealCard
:deal="deal" :deal="deal"
:accent="stage.color" :accent="stage.color"
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
:select-mode="selectMode" :select-mode="selectMode"
:selected="selectedIds.has(deal.id)" :selected="selectedIds.has(deal.id)"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')" @drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)" @open="emit('openDeal', $event)"
@menu="emit('menuDeal', $event)" @menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
/> />
</div> </div>
<div
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
/>
<a-typography-text <a-typography-text
v-if="!stage.deals.length" v-if="!stage.deals.length"
type="secondary" type="secondary"
@@ -23,7 +23,7 @@ const links = computed(() => {
{ to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' }, { to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' }, { to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' }, { to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ to: '/finance/categories', label: 'دسته‌بندی‌ها', section: 'treasury_categories' }, { to: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
]; ];
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section)); return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
}); });
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Table as ATable } from 'ant-design-vue';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { toman } from '@/views/treasury/treasury-format';
const props = defineProps<{
rows: TreasuryPartyBalanceDto[];
settledLabel: string;
}>();
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
user: 'کاربر',
};
const columns = computed(() => [
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName' },
{
title: 'نوع',
key: 'partyKind',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) =>
(record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی',
},
{
title: 'مبلغ کل',
key: 'amount',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.amount),
},
{
title: props.settledLabel,
key: 'settledAmount',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.settledAmount),
},
{
title: 'مانده',
key: 'remaining',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
},
]);
</script>
<template>
<a-table
:columns="columns"
:data-source="rows"
:pagination="false"
row-key="key"
:locale="{ emptyText: 'مانده‌ای نیست.' }"
/>
</template>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Button as AButton,
Flex as AFlex,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared';
import { UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import CompanyFormView from '@/views/CompanyFormView.vue';
import PersonFormView from '@/views/PersonFormView.vue';
const VNodes = defineComponent({
props: { vnodes: { type: Object, required: true } },
render() {
return this.vnodes;
},
});
const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography;
const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId');
type Choice = { id: string; name: string };
const people = ref<Choice[]>([]);
const companies = ref<Choice[]>([]);
const users = ref<Choice[]>([]);
const labels = ref<Record<string, string>>({});
const loading = ref(false);
const selectOpen = ref(false);
const personOpen = ref(false);
const companyOpen = ref(false);
const selectValue = computed(() => {
if (kind.value === 'other') return 'other';
if (kind.value && partyId.value) return `${kind.value}:${partyId.value}`;
return undefined;
});
function withCurrent(list: Choice[], group: 'person' | 'company' | 'user') {
if (kind.value !== group || !partyId.value) return list;
if (list.some((row) => row.id === partyId.value)) return list;
const name = labels.value[`${group}:${partyId.value}`] || 'انتخاب‌شده';
return [{ id: partyId.value, name }, ...list];
}
const peopleOptions = computed(() => withCurrent(people.value, 'person'));
const companyOptions = computed(() => withCurrent(companies.value, 'company'));
const userOptions = computed(() => withCurrent(users.value, 'user'));
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ثبت ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق بود.';
}
let timer = 0;
function onSearch(value: string) {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
void load(value);
}, 250);
}
async function load(q = '') {
loading.value = true;
try {
const { data } = await api.get<TreasuryPartySearchDto>('/treasury/parties', {
params: { q: q.trim() || undefined },
});
people.value = data.people;
companies.value = data.companies;
users.value = data.users;
for (const row of data.people) labels.value[`person:${row.id}`] = row.name;
for (const row of data.companies) labels.value[`company:${row.id}`] = row.name;
for (const row of data.users) labels.value[`user:${row.id}`] = row.name;
} catch (e) {
if (!(axios.isAxiosError(e) && e.response?.status === 404)) message.error(apiMessage(e));
} finally {
loading.value = false;
}
}
function onChange(value: unknown) {
if (typeof value !== 'string' || !value) {
kind.value = undefined;
partyId.value = undefined;
return;
}
if (value === 'other') {
kind.value = 'other';
partyId.value = undefined;
return;
}
const splitAt = value.indexOf(':');
const nextKind = value.slice(0, splitAt) as FinancialPartyKind;
const id = value.slice(splitAt + 1);
if (nextKind !== 'person' && nextKind !== 'company' && nextKind !== 'user') return;
kind.value = nextKind;
partyId.value = id;
}
function onDropdown(open: boolean) {
selectOpen.value = open;
}
function openPersonForm() {
selectOpen.value = false;
personOpen.value = true;
}
function openCompanyForm() {
selectOpen.value = false;
companyOpen.value = true;
}
function onPersonCreated(person?: PersonDto) {
personOpen.value = false;
if (!person?.id) return;
const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim();
people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)];
labels.value[`person:${person.id}`] = name;
kind.value = 'person';
partyId.value = person.id;
}
function onCompanyCreated(company?: CompanyDto) {
companyOpen.value = false;
if (!company?.id) return;
companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)];
labels.value[`company:${company.id}`] = company.name;
kind.value = 'company';
partyId.value = company.id;
}
onMounted(() => {
void load();
});
</script>
<template>
<a-flex vertical :gap="4">
<a-select
:open="selectOpen"
show-search
@dropdownVisibleChange="onDropdown"
allow-clear
:filter-option="false"
:loading="loading"
:value="selectValue"
placeholder="مشتری، شرکت، کاربر یا سایر"
style="width: 100%"
@search="onSearch"
@change="onChange"
>
<a-select-option value="other" label="سایر">سایر</a-select-option>
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
<a-select-option
v-for="row in peopleOptions"
:key="`person:${row.id}`"
:value="`person:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<a-select-opt-group v-if="companyOptions.length" label="شرکت‌ها">
<a-select-option
v-for="row in companyOptions"
:key="`company:${row.id}`"
:value="`company:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<a-select-opt-group v-if="userOptions.length" label="کاربران">
<a-select-option
v-for="row in userOptions"
:key="`user:${row.id}`"
:value="`user:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<template #dropdownRender="{ menuNode }">
<v-nodes :vnodes="menuNode" />
<a-flex @mousedown.prevent>
<a-button type="link" @click="openPersonForm">شخص جدید</a-button>
<a-button type="link" @click="openCompanyForm">شرکت جدید</a-button>
</a-flex>
</template>
</a-select>
<a-typography-text v-if="kind === 'other'" type="secondary">
برای سایر، مبلغ دریافت یا پرداخت با مبلغ کل یکی است و مانده نمی‌ماند.
</a-typography-text>
</a-flex>
<UiModal :open="personOpen" title="شخص جدید" @close="personOpen = false">
<PersonFormView
v-if="personOpen"
embedded
@saved="onPersonCreated"
@cancel="personOpen = false"
/>
</UiModal>
<UiModal :open="companyOpen" title="شرکت جدید" @close="companyOpen = false">
<CompanyFormView
v-if="companyOpen"
embedded
@saved="onCompanyCreated"
@cancel="companyOpen = false"
/>
</UiModal>
</template>
@@ -0,0 +1,52 @@
import { ref, watch, type Ref } from 'vue';
/** مبلغ دریافت/پرداخت را با مبلغ کل یکی می‌کند تا کاربر خودش آن را عوض کند. */
export function useSettleDefault(
form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>,
) {
const dirty = ref(false);
let mirroring = false;
function locked() {
return form.value.partyKind === 'other';
}
function copy() {
mirroring = true;
form.value.settleAmount = form.value.amount;
mirroring = false;
}
watch(
() => form.value.amount,
() => {
if (locked() || !dirty.value) copy();
},
{ flush: 'sync' },
);
watch(
() => form.value.settleAmount,
() => {
if (!mirroring && !locked()) dirty.value = true;
},
{ flush: 'sync' },
);
watch(
() => form.value.partyKind,
(kind) => {
if (kind === 'other') {
dirty.value = false;
copy();
}
},
{ flush: 'sync' },
);
return {
reset() {
dirty.value = false;
},
};
}
+37
View File
@@ -431,6 +431,43 @@ export function pointerDropIndex(index: number, event: DragEvent): number {
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0); return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
} }
const DRAG_SCROLL_EDGE = 72;
const DRAG_SCROLL_MAX = 16;
let dragScrollEl: HTMLElement | null = null;
let dragScrollY = 0;
let dragScrollRaf = 0;
function dragScrollTick() {
dragScrollRaf = 0;
const el = dragScrollEl;
if (!el) return;
const rect = el.getBoundingClientRect();
let delta = 0;
if (dragScrollY < rect.top + DRAG_SCROLL_EDGE) {
const closeness = (rect.top + DRAG_SCROLL_EDGE - dragScrollY) / DRAG_SCROLL_EDGE;
delta = -Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
} else if (dragScrollY > rect.bottom - DRAG_SCROLL_EDGE) {
const closeness = (dragScrollY - (rect.bottom - DRAG_SCROLL_EDGE)) / DRAG_SCROLL_EDGE;
delta = Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
}
if (!delta || el.scrollHeight <= el.clientHeight) return;
el.scrollTop += delta;
dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
/** نزدیک لبهٔ بالا یا پایین ستون، فهرست خودش حرکت می‌کند تا مقصد دیده شود. */
export function dragScrollColumn(el: HTMLElement | null, clientY: number) {
dragScrollEl = el;
dragScrollY = clientY;
if (!dragScrollRaf) dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
export function stopDragScroll() {
dragScrollEl = null;
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
dragScrollRaf = 0;
}
export function moveStageToIndex( export function moveStageToIndex(
stages: PipelineStage[], stages: PipelineStage[],
stageId: string, stageId: string,
+2 -2
View File
@@ -369,8 +369,8 @@ export const router = createRouter({
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دسته‌بندی‌های مالی', treasuryTitle: 'دسته‌بندی',
treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.', treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
}, },
}, },
], ],
+2 -2
View File
@@ -40,7 +40,7 @@ const props = withDefaults(
}>(), }>(),
{ embedded: false }, { embedded: false },
); );
const emit = defineEmits<{ saved: []; cancel: [] }>(); const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -165,7 +165,7 @@ async function submit() {
if (isNew.value) { if (isNew.value) {
const { data } = await api.post<CompanyDto>('/companies', payload()); const { data } = await api.post<CompanyDto>('/companies', payload());
if (props.embedded) { if (props.embedded) {
emit('saved'); emit('saved', data);
return; return;
} }
await router.replace({ name: 'company-detail', params: { id: data.id } }); await router.replace({ name: 'company-detail', params: { id: data.id } });
+59 -13
View File
@@ -32,7 +32,7 @@ import {
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { canManageSettings } from '@/lib/roles'; import { canManageSettings } from '@/lib/roles';
import { pointerDropIndex } from '@/lib/pipeline-board'; import { dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared'; import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
import { CrmSkeletonCard } from '@/components/skeleton'; import { CrmSkeletonCard } from '@/components/skeleton';
@@ -63,6 +63,8 @@ const addUnqualified = ref(false);
const deleteTarget = ref<LeadStageDto | null>(null); const deleteTarget = ref<LeadStageDto | null>(null);
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null); const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
const draggingId = ref<string | null>(null); const draggingId = ref<string | null>(null);
const insertAt = ref<number | null>(null);
const bodyEls = new Map<string, HTMLElement>();
const dropStageId = ref<string | null>(null); const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */ /** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
let suppressCardClick = false; let suppressCardClick = false;
@@ -469,17 +471,44 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
} }
function setBodyEl(stageId: string, el: unknown) {
if (el instanceof HTMLElement) bodyEls.set(stageId, el);
else bodyEls.delete(stageId);
}
function clearDragUi() {
insertAt.value = null;
stopDragScroll();
}
function slotStyle(active: boolean, collapseGap = false) {
return {
height: active ? '36px' : '0px',
marginTop: !active && collapseGap ? '-12px' : '0',
borderRadius: `${token.value.borderRadius}px`,
background: active ? token.value.colorPrimaryBg : 'transparent',
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
outlineOffset: '-1px',
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
overflow: 'hidden',
flexShrink: 0,
};
}
function onDragEnd() { function onDragEnd() {
draggingId.value = null; draggingId.value = null;
dropStageId.value = null; dropStageId.value = null;
clearDragUi();
window.setTimeout(() => { window.setTimeout(() => {
suppressCardClick = false; suppressCardClick = false;
}, 0); }, 0);
} }
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
dropStageId.value = stageId; dropStageId.value = stageId;
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
if (index != null) insertAt.value = index;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
} }
@@ -488,8 +517,10 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.stopPropagation(); event.stopPropagation();
const leadId = const leadId =
event.dataTransfer?.getData('text/plain') || draggingId.value || ''; event.dataTransfer?.getData('text/plain') || draggingId.value || '';
const at = index ?? insertAt.value ?? undefined;
draggingId.value = null; draggingId.value = null;
dropStageId.value = null; dropStageId.value = null;
clearDragUi();
if (!leadId || moving.value) return; if (!leadId || moving.value) return;
const fromCol = columns.value.find((c) => const fromCol = columns.value.find((c) =>
@@ -502,14 +533,14 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
if (!lead) return; if (!lead) return;
const same = fromCol.stage.id === stageId; const same = fromCol.stage.id === stageId;
if (same && index == null) return; if (same && at == null) return;
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId); const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
let insertAt = index == null || index < 0 ? 0 : index; let insertIndex = at == null || at < 0 ? 0 : at;
if (same && index != null && index > fromIndex) insertAt -= 1; if (same && at != null && at > fromIndex) insertIndex -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length)); insertIndex = Math.max(0, Math.min(insertIndex, without.length));
if (same && insertAt === fromIndex) return; if (same && insertIndex === fromIndex) return;
const nextLeads = [...without]; const nextLeads = [...without];
nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name }); nextLeads.splice(insertIndex, 0, { ...lead, stageId, stageName: target.stage.name });
const prev = columns.value.map((c) => ({ const prev = columns.value.map((c) => ({
...c, ...c,
@@ -534,7 +565,7 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
moving.value = true; moving.value = true;
error.value = ''; error.value = '';
try { try {
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt }); await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
} catch { } catch {
columns.value = prev; columns.value = prev;
error.value = 'جابه‌جایی لید ناموفق بود.'; error.value = 'جابه‌جایی لید ناموفق بود.';
@@ -668,24 +699,34 @@ onMounted(load);
</a-flex> </a-flex>
</div> </div>
<div :style="columnBodyStyle"> <div
:ref="(el) => setBodyEl(col.stage.id, el)"
:style="columnBodyStyle"
>
<div <div
v-for="(lead, index) in col.leads" v-for="(lead, index) in col.leads"
:key="lead.id" :key="lead.id"
@dragover.prevent="onDragOver(col.stage.id, $event)" @dragover.prevent="onDragOver(col.stage.id, $event, pointerDropIndex(index, $event))"
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))" @drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
> >
<div
:style="slotStyle(dropStageId === col.stage.id && insertAt === index)"
@dragover.stop.prevent="onDragOver(col.stage.id, $event, index)"
@drop.stop.prevent="onDrop(col.stage.id, $event, index)"
/>
<a-card <a-card
hoverable hoverable
:bordered="true" :bordered="true"
:body-style="cardBodyStyle" :body-style="cardBodyStyle"
:style="{ :style="{
cursor: 'pointer', cursor: 'pointer',
opacity: draggingId === lead.id ? 0.45 : 1, opacity: draggingId === lead.id ? 0.4 : 1,
transform: draggingId === lead.id ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
flexShrink: 0, flexShrink: 0,
margin: 0, margin: 0,
background: token.colorBgContainer, background: token.colorBgContainer,
boxShadow: token.boxShadow, boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
}" }"
@click="openLead(lead)" @click="openLead(lead)"
> >
@@ -816,6 +857,11 @@ onMounted(load);
</a-flex> </a-flex>
</a-card> </a-card>
</div> </div>
<div
:style="slotStyle(dropStageId === col.stage.id && insertAt === col.leads.length, true)"
@dragover.stop.prevent="onDragOver(col.stage.id, $event, col.leads.length)"
@drop.stop.prevent="onDrop(col.stage.id, $event, col.leads.length)"
/>
<a-typography-text <a-typography-text
v-if="!col.leads.length" v-if="!col.leads.length"
+4 -2
View File
@@ -249,17 +249,19 @@ onMounted(() => {
<code>X-Api-Key: mk_…</code> <code>X-Api-Key: mk_…</code>
یا یا
<code>Authorization: Bearer mk_…</code> <code>Authorization: Bearer mk_…</code>
— مسیرها: — خواندن:
<code>/api/public/v1/people</code> <code>/api/public/v1/people</code>
و و
<code>/api/public/v1/deals</code> <code>/api/public/v1/deals</code>
— ثبت لید از سایت بیرونی:
<code>POST /api/public/v1/leads</code>
</a-typography-paragraph> </a-typography-paragraph>
<a-form layout="vertical" @finish="createKey"> <a-form layout="vertical" @finish="createKey">
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item label="نام کلید" required> <a-form-item label="نام کلید" required>
<a-input v-model:value="newKeyName" placeholder="مثلاً اتصال سایت" /> <a-input v-model:value="newKeyName" placeholder="مثلاً نام سایت" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
+135 -1
View File
@@ -10,7 +10,7 @@ import type {
UpdateTahesabConnectionRequest, UpdateTahesabConnectionRequest,
} from '@mento/shared'; } from '@mento/shared';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali'; import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import { import {
Alert as AAlert, Alert as AAlert,
@@ -103,6 +103,91 @@ const syncTablePageSize = ref(50);
const syncTablePage = ref(1); const syncTablePage = ref(1);
const syncOnlyNew = ref(false); const syncOnlyNew = ref(false);
const autoCount = ref('0');
const autoHours = ref<string[]>([]);
const autoSyncSaving = ref(false);
const AUTO_HOUR_PRESETS: Record<number, number[]> = {
1: [8],
2: [8, 14],
3: [8, 12, 16],
4: [8, 12, 16, 20],
5: [8, 11, 14, 17, 20],
6: [8, 10, 12, 14, 16, 20],
};
const selectedConnection = computed(() =>
items.value.find((row) => row.id === sync.connectionId),
);
function hourLabel(hour: number) {
return `${toFaDigits(String(hour).padStart(2, '0'))}:۰۰`;
}
const autoCountOptions = [
{ value: '0', label: 'خاموش' },
...Array.from({ length: 6 }, (_, index) => ({
value: String(index + 1),
label: `${toFaDigits(index + 1)} بار`,
})),
];
const hourOptions = Array.from({ length: 24 }, (_, hour) => ({
value: String(hour),
label: hourLabel(hour),
}));
function fillAutoSync() {
const hours = [...(selectedConnection.value?.customerSyncHours ?? [])]
.sort((a, b) => a - b)
.slice(0, 6);
autoCount.value = String(hours.length);
autoHours.value = hours.map(String);
}
function onAutoCount(value: string) {
const count = Math.min(6, Math.max(0, Number(value) || 0));
const preset = AUTO_HOUR_PRESETS[count] ?? [];
const next = autoHours.value.slice(0, count);
while (next.length < count) {
next.push(String(preset[next.length] ?? 8));
}
autoCount.value = String(count);
autoHours.value = next;
}
function setAutoHour(index: number, value: string) {
autoHours.value[index] = value;
}
async function saveAutoSync() {
if (!sync.connectionId) return;
const hours = autoHours.value.map((hour) => Number(hour));
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
error.value = 'ساعت باید بین ۰ و ۲۳ باشد.';
return;
}
if (new Set(hours).size !== hours.length) {
error.value = 'ساعت‌ها نباید تکراری باشند.';
return;
}
autoSyncSaving.value = true;
error.value = '';
ok.value = '';
try {
await api.patch(`/integrations/tahesab/connections/${sync.connectionId}`, {
customerSyncHours: hours,
});
ok.value = hours.length
? 'زمان‌بندی بروزرسانی ذخیره شد.'
: 'بروزرسانی خودکار خاموش شد.';
await load();
} catch (e) {
error.value = apiErr(e, 'ذخیره زمان‌بندی ناموفق بود');
} finally {
autoSyncSaving.value = false;
}
}
const syncVisibleResults = computed(() => const syncVisibleResults = computed(() =>
syncOnlyNew.value syncOnlyNew.value
? syncResults.value.filter((r) => !r.alreadyLinked) ? syncResults.value.filter((r) => !r.alreadyLinked)
@@ -115,6 +200,7 @@ const syncPagedResults = computed(() => {
const start = (syncTablePage.value - 1) * syncTablePageSize.value; const start = (syncTablePage.value - 1) * syncTablePageSize.value;
return syncVisibleResults.value.slice(start, start + syncTablePageSize.value); return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
}); });
watch(() => sync.connectionId, () => fillAutoSync());
watch([syncOnlyNew, syncTablePageSize], () => { watch([syncOnlyNew, syncTablePageSize], () => {
syncTablePage.value = 1; syncTablePage.value = 1;
}); });
@@ -207,6 +293,7 @@ async function load() {
if (!editingId.value) form.isDefault = data.length === 0; if (!editingId.value) form.isDefault = data.length === 0;
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id; if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id; if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
fillAutoSync();
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String( ? String(
@@ -964,6 +1051,53 @@ onMounted(load);
</a-row> </a-row>
</a-form> </a-form>
<a-card type="inner" title="بروزرسانی خودکار">
<a-flex vertical :gap="12">
<a-typography-text type="secondary">
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعت‌ها، به وقت تهران،
مشتریان تازه را وارد می‌کند و نام مشتری‌های قبلی را هم با ته‌حساب تازه می‌کند. اگر شماره یکی باشد
همان مشتری می‌ماند، نام قبلی حفظ می‌شود و شماره به شکل ۰۹۱۲ ذخیره می‌شود. خاموش یعنی فقط ورود دستی.
</a-typography-text>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="تعداد در روز">
<a-select
:value="autoCount"
:options="autoCountOptions"
:disabled="!sync.connectionId || autoSyncSaving"
@update:value="(value) => onAutoCount(String(value ?? '0'))"
/>
</a-form-item>
</a-col>
<a-col v-for="(_, index) in autoHours" :key="index" :xs="24" :md="8">
<a-form-item :label="`ساعت ${toFaDigits(index + 1)}`">
<a-select
:value="autoHours[index]"
:options="hourOptions"
:disabled="autoSyncSaving"
@update:value="(value) => setAutoHour(index, String(value ?? '8'))"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-button :loading="autoSyncSaving" :disabled="!sync.connectionId" @click="saveAutoSync">
ذخیره زمان‌بندی
</a-button>
<a-typography-text v-if="selectedConnection?.customerSyncLastRunAt" type="secondary">
آخرین اجرا:
{{ formatJalaliDateTime(new Date(selectedConnection.customerSyncLastRunAt)) }}
<template v-if="selectedConnection.customerSyncLastSummary">
— {{ toFaDigits(selectedConnection.customerSyncLastSummary) }}
</template>
</a-typography-text>
<a-typography-text v-if="selectedConnection?.customerSyncLastError" type="danger">
آخرین خطا: {{ selectedConnection.customerSyncLastError }}
</a-typography-text>
</a-flex>
</a-card>
<a-card type="inner" title="۱) وارد کردن دسته‌ای با بازه کد مشتری"> <a-card type="inner" title="۱) وارد کردن دسته‌ای با بازه کد مشتری">
<a-flex vertical :gap="12"> <a-flex vertical :gap="12">
<a-typography-text type="secondary"> <a-typography-text type="secondary">
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue'; import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
import { import {
Alert as AAlert, Alert as AAlert,
@@ -30,13 +30,18 @@ import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/Re
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines'; import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography; Typography;
const shell = inject(ROUTINES_CTX)!; const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const isManager = computed(() => !!shell.context.value?.isManager);
const selfId = computed(() => auth.user?.id ?? '');
const backTo = computed(() => (isManager.value ? '/routines/manage' : '/routines/list'));
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null)); const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value); const isEdit = computed(() => !!id.value);
@@ -76,6 +81,8 @@ const recurrence = ref<RecurrenceValue>({
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const loaded = ref<RoutineDto | null>(null);
const foreign = ref(false);
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>; const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>; const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
@@ -140,10 +147,14 @@ function fill(r: RoutineDto) {
} }
onMounted(async () => { onMounted(async () => {
if (!id.value) return; if (!id.value) {
if (selfId.value) form.assigneeId = selfId.value;
return;
}
loading.value = true; loading.value = true;
try { try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`); const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
loaded.value = data;
fill(data); fill(data);
} catch (e) { } catch (e) {
error.value = apiError(e, 'روتین پیدا نشد.'); error.value = apiError(e, 'روتین پیدا نشد.');
@@ -152,6 +163,23 @@ onMounted(async () => {
} }
}); });
watch(
[() => shell.context.value, loaded, selfId],
() => {
const ctx = shell.context.value;
const data = loaded.value;
if (!ctx || !data || ctx.isManager) {
foreign.value = false;
return;
}
foreign.value = data.createdById !== selfId.value;
if (foreign.value) {
error.value = 'این روتین را مدیر تعریف کرده و فقط خودش می‌تواند تغییرش دهد.';
}
},
{ immediate: true },
);
const users = computed(() => shell.context.value?.users ?? []); const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []); const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []); const categories = computed(() => shell.context.value?.categories ?? []);
@@ -198,6 +226,11 @@ const summary = computed(() => {
}); });
function validate(): string | null { function validate(): string | null {
if (!isManager.value) {
form.target = 'user';
form.assigneeId = selfId.value;
form.teamId = '';
}
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.'; if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.'; if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.'; if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
@@ -249,7 +282,7 @@ async function save() {
if (isEdit.value) await api.patch(`/routines/${id.value}`, body); if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
else await api.post('/routines', body); else await api.post('/routines', body);
shell.notifyChanged(); shell.notifyChanged();
void router.push('/routines/manage'); void router.push(backTo.value);
} catch (e) { } catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.'); error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally { } finally {
@@ -263,7 +296,7 @@ async function save() {
<a-flex vertical :gap="4"> <a-flex vertical :gap="4">
<a-breadcrumb> <a-breadcrumb>
<a-breadcrumb-item> <a-breadcrumb-item>
<RouterLink to="/routines/manage">مدیریت روتین‌ها</RouterLink> <RouterLink :to="backTo">{{ isManager ? 'مدیریت روتین‌ها' : 'روتین‌های من' }}</RouterLink>
</a-breadcrumb-item> </a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item> <a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb> </a-breadcrumb>
@@ -275,7 +308,9 @@ async function save() {
</a-typography-text> </a-typography-text>
</a-flex> </a-flex>
<CrmSkeleton :loading="loading"> <a-alert v-if="foreign" type="error" show-icon :message="error" />
<CrmSkeleton v-if="!foreign" :loading="loading">
<CrmSkeletonCard :rows="8"> <CrmSkeletonCard :rows="8">
<a-form layout="vertical" @finish="save"> <a-form layout="vertical" @finish="save">
<a-row :gutter="[16, 16]"> <a-row :gutter="[16, 16]">
@@ -323,6 +358,10 @@ async function save() {
</a-card> </a-card>
<a-card :bordered="false" title="مسئول"> <a-card :bordered="false" title="مسئول">
<a-typography-text v-if="!isManager" type="secondary">
این روتین فقط برای خودتان ساخته می‌شود.
</a-typography-text>
<template v-else>
<a-form-item> <a-form-item>
<a-radio-group v-model:value="form.target" button-style="solid"> <a-radio-group v-model:value="form.target" button-style="solid">
<a-radio-button value="user">یک کاربر</a-radio-button> <a-radio-button value="user">یک کاربر</a-radio-button>
@@ -354,6 +393,7 @@ async function save() {
style="width: 100%" style="width: 100%"
/> />
</a-form-item> </a-form-item>
</template>
</a-card> </a-card>
<a-card :bordered="false" title="زمان‌بندی و تکرار"> <a-card :bordered="false" title="زمان‌بندی و تکرار">
@@ -484,7 +524,7 @@ async function save() {
<a-button type="primary" html-type="submit" block :loading="saving"> <a-button type="primary" html-type="submit" block :loading="saving">
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }} {{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}
</a-button> </a-button>
<a-button block :disabled="saving" @click="router.push('/routines/manage')"> <a-button block :disabled="saving" @click="router.push(backTo)">
انصراف انصراف
</a-button> </a-button>
</a-space> </a-space>
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue'; import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineDto } from '@mento/shared'; import type { RoutineDto } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { import {
@@ -35,6 +37,8 @@ const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyPa
const { token } = theme.useToken(); const { token } = theme.useToken();
const shell = inject(ROUTINES_CTX)!; const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const meId = computed(() => auth.user?.id ?? '');
const items = ref<RoutineDto[]>([]); const items = ref<RoutineDto[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -90,7 +94,7 @@ const TONE_COLORS: Record<string, string> = {
<a-row :gutter="[16, 12]" align="middle"> <a-row :gutter="[16, 12]" align="middle">
<a-col :xs="24" :md="16"> <a-col :xs="24" :md="16">
<a-typography-text type="secondary"> <a-typography-text type="secondary">
روتین‌هایی که به شما یا تیم شما سپرده شده‌اند. کارهای هر روز از روی این‌ها ساخته می‌شوند. روتین‌های شما. کارهای هر روز از روی این‌ها ساخته می‌شوند. با «روتین جدید» می‌توانید برای خودتان برنامه تکراری بسازید.
</a-typography-text> </a-typography-text>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -116,7 +120,7 @@ const TONE_COLORS: Record<string, string> = {
:description=" :description="
q q
? 'روتینی با این عنوان پیدا نشد' ? 'روتینی با این عنوان پیدا نشد'
: 'هنوز روتینی برای شما تعریف نشده؛ روتین‌ها توسط مدیر تیم تعریف می‌شوند و هر روز به‌صورت خودکار در «کارهای امروز» ظاهر می‌شوند.' : 'هنوز روتینی برای شما نیست. با دکمه «روتین جدید» یک برنامه تکراری برای خودتان بسازید. روتینی که مدیر برای شما تعریف کرده هم اینجا دیده می‌شود.'
" "
/> />
@@ -170,9 +174,14 @@ const TONE_COLORS: Record<string, string> = {
:show-info="false" :show-info="false"
:stroke-color="TONE_COLORS[percentTone(r.executionRate)]" :stroke-color="TONE_COLORS[percentTone(r.executionRate)]"
/> />
<a-typography-text type="secondary"> <a-flex justify="space-between" align="center" :gap="8">
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }} <a-typography-text type="secondary">
</a-typography-text> از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
</a-typography-text>
<RouterLink v-if="r.createdById === meId" :to="`/routines/manage/${r.id}/edit`">
ویرایش
</RouterLink>
</a-flex>
</a-flex> </a-flex>
</a-flex> </a-flex>
</a-card> </a-card>
@@ -98,7 +98,7 @@ const tabs: Tab[] = [
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value)); const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']); const managerOnlyRoutes = new Set(['routines-team', 'routines-manage']);
watch( watch(
[() => route.name, context], [() => route.name, context],
@@ -145,7 +145,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
> >
<template #icon><BellOutlined /></template> <template #icon><BellOutlined /></template>
</a-button> </a-button>
<a-button v-if="isManager" @click="router.push('/routines/manage/new')"> <a-button @click="router.push('/routines/manage/new')">
<template #icon><SyncOutlined /></template> <template #icon><SyncOutlined /></template>
روتین جدید روتین جدید
</a-button> </a-button>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
@@ -13,20 +13,38 @@ import {
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
List as AList, List as AList,
Modal as AModal,
Popconfirm as APopconfirm,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Switch as ASwitch,
Tag as ATag, Tag as ATag,
Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared'; import type { FinancialCategoryDto } from '@mento/shared';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
const rows = ref<FinancialCategoryDto[]>([]); const rows = ref<FinancialCategoryDto[]>([]);
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' }); const form = ref({ name: '', kind: 'income' as 'income' | 'expense' });
const error = ref(''); const error = ref('');
const saving = ref(false);
const editOpen = ref(false);
const editSaving = ref(false);
const edit = ref({ id: '', name: '', isActive: true });
const incomeRows = computed(() => rows.value.filter((row) => row.kind === 'income'));
const expenseRows = computed(() => rows.value.filter((row) => row.kind === 'expense'));
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() { async function load() {
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories'); const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
@@ -35,6 +53,7 @@ async function load() {
async function add() { async function add() {
error.value = ''; error.value = '';
saving.value = true;
try { try {
await api.post('/treasury/categories', { await api.post('/treasury/categories', {
name: form.value.name, name: form.value.name,
@@ -43,66 +62,160 @@ async function add() {
form.value.name = ''; form.value.name = '';
await load(); await load();
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = apiMessage(e);
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.') } finally {
: 'ثبت ناموفق بود.'; saving.value = false;
} }
} }
function openEdit(item: FinancialCategoryDto) {
edit.value = { id: item.id, name: item.name, isActive: item.isActive };
editOpen.value = true;
}
async function remove(item: FinancialCategoryDto) {
error.value = '';
try {
await api.delete(`/treasury/categories/${item.id}`);
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
function saveEdit() {
const name = edit.value.name.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return Promise.reject(new Error('invalid'));
}
editSaving.value = true;
error.value = '';
return api
.patch(`/treasury/categories/${edit.value.id}`, {
name,
isActive: edit.value.isActive,
})
.then(() => load())
.catch((e: unknown) => {
error.value = apiMessage(e);
return Promise.reject(e);
})
.finally(() => {
editSaving.value = false;
});
}
onMounted(() => { onMounted(() => {
void load(); void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
}); });
</script> </script>
<template> <template>
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" /> <a-alert v-if="error" type="error" show-icon :message="error" />
<a-typography-text type="secondary">
دسته‌ها فقط برای همین محیط هستند و در فرم درآمد و هزینه نمایش داده می‌شوند.
</a-typography-text>
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider> <a-divider orientation="right">دسته جدید</a-divider>
<a-form layout="vertical" :model="form" @finish="add"> <a-form layout="vertical" :model="form" @finish="add">
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item <a-form-item
label="نام" label="نام"
name="name" name="name"
:rules="[{ required: true, message: 'نام الزامی است' }]" :rules="[{ required: true, message: 'نام الزامی است' }]"
>
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block :loading="saving">افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">درآمد</a-divider>
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
<a-list v-else item-layout="horizontal" :data-source="incomeRows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
<a-popconfirm
title="این دسته حذف شود؟ سندهای قبلی بدون دسته می‌مانند."
ok-text="حذف"
cancel-text="انصراف"
:ok-button-props="{ danger: true }"
@confirm="remove(item)"
> >
<a-input v-model:value="form.name" /> <a-button type="link" danger>حذف</a-button>
</a-form-item> </a-popconfirm>
</a-col> </template>
<a-col :xs="24" :md="8"> <a-list-item-meta>
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]"> <template #title>{{ item.name }}</template>
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%"> </a-list-item-meta>
<a-select-option value="income" label="درآمد">درآمد</a-select-option> </a-list-item>
<a-select-option value="expense" label="هزینه">هزینه</a-select-option> </template>
</a-select> </a-list>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">فهرست</a-divider> <a-divider orientation="right">هزینه</a-divider>
<a-empty v-if="!rows.length" description="دسته‌ای ثبت نشده است." /> <a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows"> <a-list v-else item-layout="horizontal" :data-source="expenseRows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
<a-tag :color="item.kind === 'income' ? 'success' : 'default'"> <a-tag v-if="!item.isActive">غیرفعال</a-tag>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} <a-button type="link" @click="openEdit(item)">ویرایش</a-button>
</a-tag> <a-popconfirm
</template> title="این دسته حذف شود؟ سندهای قبلی بدون دسته می‌مانند."
<a-list-item-meta> ok-text="حذف"
<template #title>{{ item.name }}</template> cancel-text="انصراف"
</a-list-item-meta> :ok-button-props="{ danger: true }"
</a-list-item> @confirm="remove(item)"
</template> >
</a-list> <a-button type="link" danger>حذف</a-button>
</a-card> </a-popconfirm>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
<a-modal
v-model:open="editOpen"
title="ویرایش دسته"
ok-text="ذخیره"
cancel-text="انصراف"
:confirm-loading="editSaving"
@ok="saveEdit"
>
<a-form layout="vertical">
<a-form-item label="نام" required>
<a-input v-model:value="edit.name" />
</a-form-item>
<a-form-item label="فعال در فرم‌ها">
<a-switch v-model:checked="edit.isActive" />
</a-form-item>
</a-form>
</a-modal>
</a-flex> </a-flex>
</template> </template>
@@ -19,8 +19,17 @@ import {
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared'; import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
@@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option;
const kind = 'expense' as const; const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]); const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]); const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const error = ref(''); const error = ref('');
@@ -41,13 +51,14 @@ const form = ref({
title: '', title: '',
amount: '', amount: '',
categoryId: '', categoryId: '',
partyName: '', partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: '', accountId: '',
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
reference: '',
notes: '', notes: '',
}); });
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده'; const settleLabel = 'مبلغ پرداخت‌شده';
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
@@ -71,13 +82,29 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
} }
function docLine(item: FinancialDocumentDto) {
const parts = [
item.reference ? `پیگیری ${item.reference}` : '',
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
].filter(Boolean);
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() { async function load() {
const [docs, cats, acc] = await Promise.all([ const [docs, cats, acc, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }), api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'), api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]); ]);
rows.value = docs.data; rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive); categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
} }
@@ -96,31 +123,44 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.'; error.value = 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
await api.post('/treasury/documents', { if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return;
}
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind, kind,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null, partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null, accountId: form.value.accountId || null,
settleAmount, settleAmount,
method: form.value.method, method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null, notes: form.value.notes || null,
}); });
settleMirror.reset();
form.value = { form.value = {
title: '', title: '',
amount: '', amount: '',
categoryId: '', categoryId: '',
partyName: '', partyKind: undefined,
partyId: undefined,
accountId: '', accountId: '',
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
reference: '',
notes: '', notes: '',
}; };
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -180,7 +220,7 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" /> <MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -198,8 +238,8 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName"> <a-form-item label="طرف حساب" name="partyKind">
<a-input v-model:value="form.partyName" /> <TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -225,15 +265,13 @@ onMounted(() => {
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button> <a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداخت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider> <a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical"> <a-form layout="vertical">
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
@@ -242,7 +280,7 @@ onMounted(() => {
<a-input <a-input
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو…" placeholder="عنوان یا شماره پیگیری"
@press-enter="load" @press-enter="load"
@change="load" @change="load"
/> />
@@ -268,11 +306,7 @@ onMounted(() => {
</template> </template>
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description> <template #description>{{ docLine(item) }}</template>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta> </a-list-item-meta>
</a-list-item> </a-list-item>
</template> </template>
@@ -15,7 +15,6 @@ import {
Grid, Grid,
Input as AInput, Input as AInput,
List as AList, List as AList,
Modal as AModal,
Row as ARow, Row as ARow,
Segmented as ASegmented, Segmented as ASegmented,
Select as ASelect, Select as ASelect,
@@ -34,10 +33,7 @@ import {
CreditCardOutlined, CreditCardOutlined,
FileTextOutlined, FileTextOutlined,
InfoCircleOutlined, InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined, TagOutlined,
UserOutlined,
} from '@/icons/ant-bridge'; } from '@/icons/ant-bridge';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali'; import { formatLocalDateInput } from '@/lib/jalali';
@@ -45,10 +41,15 @@ import type {
FinancialAccountDto, FinancialAccountDto,
FinancialCategoryDto, FinancialCategoryDto,
FinancialDocumentDto, FinancialDocumentDto,
FinancialPartyKind,
TreasuryDashboardDto, TreasuryDashboardDto,
TreasuryPartyBalanceDto,
} from '@mento/shared'; } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
@@ -62,6 +63,7 @@ const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const; const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]); const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]); const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null); const dashboard = ref<TreasuryDashboardDto | null>(null);
@@ -70,9 +72,6 @@ const ok = ref('');
const saving = ref(false); const saving = ref(false);
const q = ref(''); const q = ref('');
const formTab = ref<'main' | 'extra'>('main'); const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>(); const mobileFormRef = ref<FormInstance>();
function emptyForm() { function emptyForm() {
@@ -80,17 +79,18 @@ function emptyForm() {
title: '', title: '',
amount: '', amount: '',
categoryId: undefined as string | undefined, categoryId: undefined as string | undefined,
partyName: '', partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: undefined as string | undefined, accountId: undefined as string | undefined,
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
reference: '',
notes: '', notes: '',
bookedAt: formatLocalDateInput(new Date()), bookedAt: formatLocalDateInput(new Date()),
}; };
} }
const form = ref(emptyForm()); const form = ref(emptyForm());
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده'; const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
@@ -137,20 +137,37 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
} }
function docLine(item: FinancialDocumentDto) {
const parts = [
item.reference ? `پیگیری ${item.reference}` : '',
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
].filter(Boolean);
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() { async function load() {
const [docs, cats, acc, dash] = await Promise.all([ const [docs, cats, acc, dash, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }), api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'), api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null), api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]); ]);
rows.value = docs.data; rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive); categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data; if (dash) dashboard.value = dash.data;
} }
function resetForm() { function resetForm() {
settleMirror.reset();
form.value = emptyForm(); form.value = emptyForm();
formTab.value = 'main'; formTab.value = 'main';
error.value = ''; error.value = '';
@@ -167,22 +184,34 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.'; error.value = 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
await api.post('/treasury/documents', { if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind, kind,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null, partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null, accountId: form.value.accountId || null,
settleAmount, settleAmount,
method: form.value.method, method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null, notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined, bookedAt: form.value.bookedAt || undefined,
}); });
resetForm(); resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -211,30 +240,6 @@ async function voidDoc(id: string) {
} }
} }
async function createCategoryQuick() {
const name = newCategoryName.value.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return;
}
categorySaving.value = true;
error.value = '';
try {
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
name,
kind,
});
await load();
form.value.categoryId = data.id;
newCategoryName.value = '';
categoryModalOpen.value = false;
} catch (e) {
error.value = apiMessage(e);
} finally {
categorySaving.value = false;
}
}
onMounted(() => { onMounted(() => {
void load().catch(() => { void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.'; error.value = 'بارگذاری ناموفق بود.';
@@ -358,10 +363,8 @@ onMounted(() => {
<a-row :gutter="[12, 0]"> <a-row :gutter="[12, 0]">
<a-col :span="12"> <a-col :span="12">
<a-form-item label="مشتری / طرف حساب" name="partyName"> <a-form-item label="طرف حساب" name="partyKind">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب"> <TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
@@ -393,21 +396,16 @@ onMounted(() => {
<span>دسته‌بندی درآمد</span> <span>دسته‌بندی درآمد</span>
</a-space> </a-space>
</template> </template>
<a-flex :gap="8"> <a-select show-search option-filter-prop="label"
<a-select show-search option-filter-prop="label" v-model:value="form.categoryId"
v-model:value="form.categoryId" allow-clear
allow-clear placeholder="انتخاب دسته"
placeholder="انتخاب دسته" style="width: 100%"
style="flex: 1; min-width: 0" >
> <a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name"> {{ cat.name }}
{{ cat.name }} </a-select-option>
</a-select-option> </a-select>
</a-select>
<a-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
</a-form-item> </a-form-item>
<a-row :gutter="[12, 0]"> <a-row :gutter="[12, 0]">
@@ -426,7 +424,12 @@ onMounted(() => {
name="settleAmount" name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]" :rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
> >
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" /> <MoneyInput
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -493,12 +496,6 @@ onMounted(() => {
</div> </div>
<div v-show="formTab === 'extra'"> <div v-show="formTab === 'extra'">
<a-form-item label="شماره پیگیری" name="reference">
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="توضیحات" name="notes"> <a-form-item label="توضیحات" name="notes">
<a-textarea <a-textarea
v-model:value="form.notes" v-model:value="form.notes"
@@ -511,13 +508,15 @@ onMounted(() => {
</a-card> </a-card>
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider> <a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="جستجو"> <a-form-item label="جستجو">
<a-input <a-input
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو…" placeholder="عنوان یا شماره پیگیری"
@press-enter="load" @press-enter="load"
@change="load" @change="load"
/> />
@@ -540,11 +539,7 @@ onMounted(() => {
</template> </template>
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description> <template #description>{{ docLine(item) }}</template>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta> </a-list-item-meta>
</a-list-item> </a-list-item>
</template> </template>
@@ -614,7 +609,7 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" /> <MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -632,8 +627,8 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName"> <a-form-item label="طرف حساب" name="partyKind">
<a-input v-model:value="form.partyName" /> <TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -674,11 +669,6 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" /> <JalaliDateInput v-model="form.bookedAt" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16"> <a-col :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes"> <a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" /> <a-input v-model:value="form.notes" />
@@ -688,6 +678,8 @@ onMounted(() => {
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button> <a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider> <a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical"> <a-form layout="vertical">
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
@@ -696,7 +688,7 @@ onMounted(() => {
<a-input <a-input
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو…" placeholder="عنوان یا شماره پیگیری"
@press-enter="load" @press-enter="load"
@change="load" @change="load"
/> />
@@ -722,11 +714,7 @@ onMounted(() => {
</template> </template>
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description> <template #description>{{ docLine(item) }}</template>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta> </a-list-item-meta>
</a-list-item> </a-list-item>
</template> </template>
@@ -735,23 +723,5 @@ onMounted(() => {
</a-flex> </a-flex>
</template> </template>
<a-modal
v-model:open="categoryModalOpen"
title="دسته جدید"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="categorySaving"
@ok="createCategoryQuick"
>
<a-form layout="vertical">
<a-form-item label="نام دسته" required>
<a-input
v-model:value="newCategoryName"
placeholder="مثلاً: فروش خدمات"
@press-enter="createCategoryQuick"
/>
</a-form-item>
</a-form>
</a-modal>
</div> </div>
</template> </template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
@@ -9,16 +10,21 @@ import {
Flex as AFlex, Flex as AFlex,
List as AList, List as AList,
Table as ATable, Table as ATable,
Tabs as ATabs,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@/icons/ant-bridge'; import { DownloadOutlined } from '@/icons/ant-bridge';
import * as XLSX from 'xlsx';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ATabPane = ATabs.TabPane;
type Period = { type Period = {
bookedIncome: string; bookedIncome: string;
@@ -41,7 +47,17 @@ const current = ref<Period | null>(null);
const previous = ref<Period | null>(null); const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]); const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]); const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
const debtors = ref<TreasuryPartyBalanceDto[]>([]);
const creditors = ref<TreasuryPartyBalanceDto[]>([]);
const reportTab = ref('periods');
const error = ref(''); const error = ref('');
const balanceError = ref('');
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
user: 'کاربر',
};
const lines = [ const lines = [
['درآمد ثبت‌شده', 'bookedIncome'], ['درآمد ثبت‌شده', 'bookedIncome'],
@@ -81,6 +97,50 @@ async function load() {
receivablesByParty.value = data.receivablesByParty; receivablesByParty.value = data.receivablesByParty;
} }
function sumRemaining(rows: TreasuryPartyBalanceDto[]) {
return rows.reduce((sum, row) => sum + Number(row.remaining || 0), 0).toFixed(2);
}
function exportParties(
rows: TreasuryPartyBalanceDto[],
title: string,
settledLabel: string,
filename: string,
) {
const body = rows.map((row) => [
row.partyName,
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
Number(row.amount),
Number(row.settledAmount),
Number(row.remaining),
]);
const totals = rows.reduce(
(sum, row) => ({
amount: sum.amount + Number(row.amount || 0),
settled: sum.settled + Number(row.settledAmount || 0),
remaining: sum.remaining + Number(row.remaining || 0),
}),
{ amount: 0, settled: 0, remaining: 0 },
);
body.push(['جمع', '', totals.amount, totals.settled, totals.remaining]);
const sheet = XLSX.utils.aoa_to_sheet([
['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده'],
...body,
]);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, title);
XLSX.writeFile(book, filename);
}
async function loadBalances() {
const [income, expense] = await Promise.all([
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }),
]);
debtors.value = income.data;
creditors.value = expense.data;
}
async function download() { async function download() {
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' }); const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
const url = URL.createObjectURL(data); const url = URL.createObjectURL(data);
@@ -95,6 +155,14 @@ onMounted(() => {
void load().catch(() => { void load().catch(() => {
error.value = 'گزارش بارگذاری نشد.'; error.value = 'گزارش بارگذاری نشد.';
}); });
void loadBalances().catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 404 || e.response?.status === 403)) {
debtors.value = [];
creditors.value = [];
return;
}
balanceError.value = 'مانده بدهکاران و بستانکاران بارگذاری نشد.';
});
}); });
</script> </script>
@@ -108,9 +176,12 @@ onMounted(() => {
</a-flex> </a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" /> <a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="balanceError" type="error" show-icon :message="balanceError" />
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">مقایسه دوره‌ها</a-divider> <a-tabs v-model:activeKey="reportTab">
<a-tab-pane key="periods" tab="مقایسه دوره‌ها">
<a-flex vertical :gap="16">
<a-table <a-table
v-if="metricRows.length" v-if="metricRows.length"
:columns="metricColumns" :columns="metricColumns"
@@ -168,6 +239,39 @@ onMounted(() => {
</template> </template>
</a-list> </a-list>
</template> </template>
</a-flex>
</a-tab-pane>
<a-tab-pane key="debtors" tab="بدهکاران">
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
</a-typography-text>
<a-button @click="exportParties(debtors, 'بدهکاران', 'دریافت‌شده', 'debtors.xlsx')">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryPartyBalanceTable :rows="debtors" settled-label="دریافت‌شده" />
</a-flex>
</a-tab-pane>
<a-tab-pane key="creditors" tab="بستانکاران">
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
</a-typography-text>
<a-button @click="exportParties(creditors, 'بستانکاران', 'پرداخت‌شده', 'creditors.xlsx')">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryPartyBalanceTable :rows="creditors" settled-label="پرداخت‌شده" />
</a-flex>
</a-tab-pane>
</a-tabs>
</a-card> </a-card>
</a-flex> </a-flex>
</template> </template>
@@ -30,6 +30,9 @@ function doc(
notes: null, notes: null,
createdAt: daysFromNow(-2), createdAt: daysFromNow(-2),
...partial, ...partial,
partyKind: partial.partyKind ?? null,
companyId: partial.companyId ?? null,
userId: partial.userId ?? null,
}; };
} }
+37 -2
View File
@@ -843,7 +843,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
{ key: "treasury_recurring", label: "مالی — تکرارشونده" }, { key: "treasury_recurring", label: "مالی — تکرارشونده" },
{ key: "treasury_reports", label: "مالی — گزارش" }, { key: "treasury_reports", label: "مالی — گزارش" },
{ key: "treasury_audit", label: "مالی — سوابق" }, { key: "treasury_audit", label: "مالی — سوابق" },
{ key: "treasury_categories", label: "مالی — دسته‌بندی‌ها" }, { key: "treasury_categories", label: "مالی — دسته‌بندی" },
{ key: "behavior", label: "شخصیت‌شناسی و دستیار فروش" }, { key: "behavior", label: "شخصیت‌شناسی و دستیار فروش" },
]; ];
@@ -4329,6 +4329,11 @@ export interface TahesabConnectionDto {
lastHealthAt: string | null; lastHealthAt: string | null;
lastHealthOk: boolean | null; lastHealthOk: boolean | null;
lastError: string | null; lastError: string | null;
/** ساعت‌های ۰ تا ۲۳ به وقت تهران. خالی یعنی بروزرسانی خودکار خاموش است. */
customerSyncHours: number[];
customerSyncLastRunAt: string | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -4343,7 +4348,11 @@ export interface CreateTahesabConnectionRequest {
allowInsecureTls?: boolean; allowInsecureTls?: boolean;
} }
export type UpdateTahesabConnectionRequest = Partial<CreateTahesabConnectionRequest>; export interface UpdateTahesabConnectionRequest
extends Partial<CreateTahesabConnectionRequest> {
/** آرایهٔ خالی بروزرسانی خودکار را خاموش می‌کند. */
customerSyncHours?: number[];
}
export interface PersonAccountingLinkDto { export interface PersonAccountingLinkDto {
id: string; id: string;
@@ -6303,6 +6312,8 @@ export interface RoutineDto {
entityLink: RoutineEntityLink; entityLink: RoutineEntityLink;
reminderMinutesBefore: number | null; reminderMinutesBefore: number | null;
isActive: boolean; isActive: boolean;
/** سازندهٔ روتین؛ عضو عادی فقط روتین خودش را ویرایش می‌کند */
createdById: string | null;
recurrenceLabel: string; recurrenceLabel: string;
/** درصد اجرا در ۳۰ روز اخیر */ /** درصد اجرا در ۳۰ روز اخیر */
executionRate: number | null; executionRate: number | null;
@@ -6503,6 +6514,7 @@ export interface RoutineUserSettingsDto {
export type FinancialAccountType = "bank" | "cash" | "petty"; export type FinancialAccountType = "bank" | "cash" | "petty";
export type FinancialDocKind = "income" | "expense"; export type FinancialDocKind = "income" | "expense";
export type FinancialDocStatus = "open" | "partial" | "settled" | "void"; export type FinancialDocStatus = "open" | "partial" | "settled" | "void";
export type FinancialPartyKind = "person" | "company" | "user" | "other";
export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other"; export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other";
export interface FinancialAccountDto { export interface FinancialAccountDto {
@@ -6541,7 +6553,10 @@ export interface FinancialDocumentDto {
dueAt: string | null; dueAt: string | null;
categoryId: string | null; categoryId: string | null;
categoryName: string | null; categoryName: string | null;
partyKind: FinancialPartyKind | null;
personId: string | null; personId: string | null;
companyId: string | null;
userId: string | null;
partyName: string | null; partyName: string | null;
accountId: string | null; accountId: string | null;
accountName: string | null; accountName: string | null;
@@ -6594,6 +6609,26 @@ export interface FinancialReceivableDto {
createdAt: string; createdAt: string;
} }
export interface TreasuryPartyChoiceDto {
id: string;
name: string;
}
export interface TreasuryPartySearchDto {
people: TreasuryPartyChoiceDto[];
companies: TreasuryPartyChoiceDto[];
users: TreasuryPartyChoiceDto[];
}
export interface TreasuryPartyBalanceDto {
key: string;
partyKind: FinancialPartyKind | null;
partyName: string;
amount: string;
settledAmount: string;
remaining: string;
}
export interface TreasuryDashboardDto { export interface TreasuryDashboardDto {
bookedIncome: string; bookedIncome: string;
actualReceipts: string; actualReceipts: string;