feat(tahesab): sync filtered customers and pick Jalali periods

Document refresh can follow the selected group or person instead of every linked customer, and the sales report picks a named month or season.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 10:09:40 +03:30
co-authored by Cursor
parent fdabdc54a3
commit 79733e6c96
5 changed files with 366 additions and 32 deletions
@@ -38,6 +38,12 @@ import {
const ASNAD_COUNT_LAST = 5000;
const SYNC_CONCURRENCY = 3;
function latinDigits(value: string): string {
return value
.replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch)))
.replace(/[٠-٩]/g, (ch) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(ch)));
}
const ASNAD_KINDS = new Set<string>(['sale', 'return', 'purchase', 'other']);
function normalizeShamsiYmd(input: string): string {
@@ -184,6 +190,7 @@ export class TahesabAsnadReportService {
connectionId: string | undefined,
from: string,
to: string,
scope?: { personId?: string; groupIds?: string[] },
): Promise<TahesabAsnadSyncJobDto> {
const fromNorm = normalizeShamsiYmd(from);
const toNorm = normalizeShamsiYmd(to);
@@ -194,6 +201,22 @@ export class TahesabAsnadReportService {
organizationId,
connectionId,
);
const scopedPersonIds = await this.resolveSyncPersonIds(organizationId, scope);
if (scopedPersonIds && scopedPersonIds.length === 0) {
throw new BadRequestException(
'برای گروه یا شخص انتخاب‌شده مشتری‌ای پیدا نشد.',
);
}
if (scopedPersonIds) {
const linked = await this.prisma.personAccountingLink.count({
where: { connectionId: resolvedId, personId: { in: scopedPersonIds } },
});
if (linked === 0) {
throw new BadRequestException(
'مشتریان این فیلتر به ته‌حساب وصل نیستند. به‌روزرسانی همه را بزنید یا اول اتصال مشتری را انجام دهید.',
);
}
}
const job = await this.prisma.accountingSyncJob.create({
data: {
@@ -201,7 +224,13 @@ export class TahesabAsnadReportService {
connectionId: resolvedId,
kind: 'asnad-sync',
status: 'pending',
request: { from: fromNorm, to: toNorm, connectionId: resolvedId },
request: {
from: fromNorm,
to: toNorm,
connectionId: resolvedId,
personId: scope?.personId ?? null,
groupIds: scope?.groupIds ?? [],
},
result: {
connectionId: resolvedId,
from: fromNorm,
@@ -214,9 +243,14 @@ export class TahesabAsnadReportService {
},
});
void this.runSync(job.id, organizationId, resolvedId, fromNorm, toNorm).catch(
() => undefined,
);
void this.runSync(
job.id,
organizationId,
resolvedId,
fromNorm,
toNorm,
scopedPersonIds,
).catch(() => undefined);
return this.mapJob(job);
}
@@ -238,6 +272,7 @@ export class TahesabAsnadReportService {
connectionId: string,
from: string,
to: string,
personIds: string[] | null,
) {
const progress: SyncProgress = {
connectionId,
@@ -257,7 +292,10 @@ export class TahesabAsnadReportService {
const { api } = await this.connections.getApi(organizationId, connectionId);
const links = await this.prisma.personAccountingLink.findMany({
where: { connectionId },
where: {
connectionId,
...(personIds ? { personId: { in: personIds } } : {}),
},
select: {
id: true,
personId: true,
@@ -276,6 +314,7 @@ export class TahesabAsnadReportService {
organizationId,
connectionId,
tarikh: { gte: from, lte: to },
...(personIds ? { personId: { in: personIds } } : {}),
},
});
@@ -638,6 +677,82 @@ export class TahesabAsnadReportService {
};
}
/** null یعنی همهٔ مشتریان متصل. آرایه یعنی فقط همین اشخاص. */
private async resolveSyncPersonIds(
organizationId: string,
scope?: { personId?: string; groupIds?: string[] },
): Promise<string[] | null> {
const groupIds = (scope?.groupIds ?? []).map((id) => id.trim()).filter(Boolean);
const personId = scope?.personId?.trim() || undefined;
if (!personId && groupIds.length === 0) return null;
let ids: string[] | null = null;
if (groupIds.length) {
const expanded = await this.expandGroupIds(organizationId, groupIds);
const rows = await this.prisma.personGroupLink.findMany({
where: {
groupId: { in: expanded },
person: { organizationId },
},
select: { personId: true },
distinct: ['personId'],
});
ids = rows.map((row) => row.personId);
}
if (personId) ids = ids ? ids.filter((id) => id === personId) : [personId];
return ids ?? [];
}
async searchCustomers(
organizationId: string,
connectionId: string | undefined,
q: string,
) {
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const query = q.trim();
const tokens = query.split(/\s+/).filter(Boolean);
const code = Number(latinDigits(query));
const or: Prisma.PersonAccountingLinkWhereInput[] = [];
if (tokens.length) {
or.push({
person: {
organizationId,
AND: tokens.map((token) => ({
OR: [
{ firstName: { contains: token } },
{ lastName: { contains: token } },
],
})),
},
});
}
if (Number.isInteger(code) && code > 0 && latinDigits(query) === String(code)) {
or.push({ moshtariCode: code, person: { organizationId } });
}
const rows = await this.prisma.personAccountingLink.findMany({
where: {
connectionId: resolvedId,
...(or.length ? { OR: or } : { person: { organizationId } }),
},
take: 30,
orderBy: [{ person: { lastName: 'asc' } }, { person: { firstName: 'asc' } }],
select: {
personId: true,
moshtariCode: true,
person: { select: { firstName: true, lastName: true } },
},
});
return {
items: rows.map((row) => ({
personId: row.personId,
moshtariCode: row.moshtariCode,
name: `${row.person.firstName} ${row.person.lastName}`.trim(),
})),
};
}
/** گروه اصلی، زیرگروه‌ها را هم داخل فیلتر می‌آورد. */
private async expandGroupIds(organizationId: string, ids: string[]): Promise<string[]> {
const rows = await this.prisma.customerGroup.findMany({
+12
View File
@@ -447,6 +447,18 @@ export class SyncAsnadReportDto {
@MinLength(8)
@MaxLength(12)
to!: string;
/** اگر باشد، فقط سندهای همین شخص گرفته می‌شود */
@IsOptional()
@IsString()
personId?: string;
/** اگر باشد، فقط مشتریان این گروه‌ها (و زیرگروه‌ها) گرفته می‌شوند */
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(200)
groupIds?: string[];
}
export class UpdateTahesabInvoiceSettingsDto {
@@ -309,6 +309,20 @@ export class TahesabController {
dto.connectionId,
dto.from,
dto.to,
{ personId: dto.personId, groupIds: dto.groupIds },
);
}
@Get('reports/asnad/customers')
searchAsnadCustomers(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('q') q?: string,
) {
return this.asnadReport.searchCustomers(
user.organizationId,
connectionId,
q ?? '',
);
}