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:
@@ -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({
|
||||
|
||||
@@ -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 ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user