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 ?? '',
);
}
+216 -27
View File
@@ -13,6 +13,7 @@ import {
Form as AForm,
FormItem as AFormItem,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
@@ -41,7 +42,6 @@ import {
TAHESAB_ASNAD_KINDS,
TAHESAB_JENS_FELEZ_LABELS,
type CustomerGroupDto,
type PersonDto,
type TahesabAsnadKind,
type TahesabAsnadLineDto,
type TahesabAsnadReportPageDto,
@@ -54,6 +54,8 @@ import { api } from '@/lib/api';
import {
dateToJalali,
formatLocalDateInput,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
parseLocalInput,
toFaDigits,
@@ -78,6 +80,20 @@ const personId = ref<string | undefined>();
const personOptions = ref<Array<{ value: string; label: string }>>([]);
const kinds = ref<TahesabAsnadKind[]>([]);
const period = ref<'custom' | 'week' | 'month' | 'season'>('month');
const periodModalOpen = ref(false);
const periodModalKind = ref<'month' | 'season'>('month');
const pickerYear = ref(dateToJalali(new Date()).jy);
const syncModalOpen = ref(false);
const SEASONS = [
{ key: 'spring', label: 'بهار', start: 1, end: 3 },
{ key: 'summer', label: 'تابستان', start: 4, end: 6 },
{ key: 'autumn', label: 'پاییز', start: 7, end: 9 },
{ key: 'winter', label: 'زمستان', start: 10, end: 12 },
] as const;
const pickerYears = computed(() => {
const current = dateToJalali(new Date()).jy;
return Array.from({ length: 8 }, (_, index) => current - index);
});
const overview = ref<TahesabSalesOverviewDto | null>(null);
const auth = useAuthStore();
const canGroups = computed(() => canManageSettings(auth.user?.role));
@@ -203,6 +219,66 @@ function markCustomPeriod() {
period.value = 'custom';
}
function openPeriodModal(kind: 'month' | 'season') {
const parsed = parseLocalInput(fromDate.value);
pickerYear.value = parsed ? dateToJalali(parsed).jy : dateToJalali(new Date()).jy;
periodModalKind.value = kind;
periodModalOpen.value = true;
}
function onPeriodPick(value: string | number) {
const key = String(value) as 'week' | 'month' | 'season' | 'custom';
if (key === 'month' || key === 'season') {
openPeriodModal(key);
return;
}
applyNamedPeriod(key, true);
}
function onPeriodReclick(event: MouseEvent) {
const item = (event.target as HTMLElement | null)?.closest('.ant-segmented-item');
const text = item?.querySelector('.ant-segmented-item-label')?.textContent?.replace(/\s/g, '') ?? '';
if (text === 'ماه') openPeriodModal('month');
if (text === 'فصل') openPeriodModal('season');
}
function applyJalaliSpan(
year: number,
startMonth: number,
endMonth: number,
key: 'month' | 'season',
) {
const now = new Date();
const today = dateToJalali(now);
const start = jalaliToDate(year, startMonth, 1);
let end = jalaliToDate(year, endMonth, jalaliMonthLength(year, endMonth));
if (
today.jy === year &&
today.jm >= startMonth &&
today.jm <= endMonth &&
end.getTime() > now.getTime()
) {
end = now;
}
applyingPeriod = true;
period.value = key;
fromDate.value = formatLocalDateInput(start);
toDate.value = formatLocalDateInput(end);
periodModalOpen.value = false;
void nextTick(() => {
applyingPeriod = false;
});
applyFilters();
}
function pickMonth(month: number) {
applyJalaliSpan(pickerYear.value, month, month, 'month');
}
function pickSeason(start: number, end: number) {
applyJalaliSpan(pickerYear.value, start, end, 'season');
}
function groupOptionLabel(g: CustomerGroupDto) {
return g.parentName ? `${g.parentName} / ${g.name}` : g.name;
}
@@ -375,25 +451,51 @@ async function loadConnections() {
if (!connectionId.value && def) connectionId.value = def.id;
}
async function searchPeople(q: string) {
if (!q || q.trim().length < 2) {
let personSearchTimer: ReturnType<typeof setTimeout> | null = null;
let customersEndpointMissing = false;
async function loadTahesabCustomers(q: string) {
if (!connectionId.value) {
personOptions.value = [];
return;
}
try {
const { data } = await api.get<{ items: PersonDto[] }>('/people', {
params: { q: q.trim(), limit: 20 },
const { data } = await api.get<{
items: Array<{ personId: string; name: string; moshtariCode: number }>;
}>('/integrations/tahesab/reports/asnad/customers', {
params: { connectionId: connectionId.value, q: q.trim() },
});
const items = Array.isArray(data?.items) ? data.items : [];
personOptions.value = items.map((p) => ({
value: p.id,
label: [p.firstName, p.lastName].filter(Boolean).join(' ') || p.id,
const next = items.map((item) => ({
value: item.personId,
label: `${item.name || 'بدون نام'} (${toFaDigits(String(item.moshtariCode))})`,
}));
} catch {
personOptions.value = [];
const selected = personOptions.value.find((item) => item.value === personId.value);
if (selected && !next.some((item) => item.value === selected.value)) next.unshift(selected);
personOptions.value = next;
} catch (e: unknown) {
const status = axios.isAxiosError(e) ? e.response?.status : undefined;
if (status === 404 && !customersEndpointMissing) {
customersEndpointMissing = true;
message.warning('جستجوی مشتری ته‌حساب بعد از به‌روزرسانی سرور فعال می‌شود.');
}
personOptions.value = personId.value
? personOptions.value.filter((item) => item.value === personId.value)
: [];
}
}
function searchTahesabCustomers(q: string) {
if (personSearchTimer) clearTimeout(personSearchTimer);
personSearchTimer = setTimeout(() => {
void loadTahesabCustomers(q);
}, 250);
}
function onPersonDropdown(open: boolean) {
if (open) void loadTahesabCustomers('');
}
function apiErrMessage(e: unknown, fallback: string): string {
if (!axios.isAxiosError(e)) return fallback;
const raw = e.response?.data as { message?: string | string[] } | undefined;
@@ -493,7 +595,20 @@ async function pollJob(jobId: string) {
}, 1500);
}
async function startSync() {
const hasSyncScope = computed(
() => Boolean(personId.value) || groupIds.value.length > 0,
);
function openSyncChoice() {
if (!filterParams.value) {
error.value = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.';
message.warning(error.value);
return;
}
syncModalOpen.value = true;
}
async function startSync(scoped: boolean) {
const params = filterParams.value;
if (!params) {
error.value = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.';
@@ -502,7 +617,10 @@ async function startSync() {
}
syncing.value = true;
error.value = '';
syncHint.value = 'شروع همگام‌سازی…';
syncHint.value = scoped
? 'شروع به‌روزرسانی مشتریان همین فیلتر…'
: 'شروع به‌روزرسانی همه مشتریان متصل…';
syncModalOpen.value = false;
try {
const { data } = await api.post<TahesabAsnadSyncJobDto>(
'/integrations/tahesab/reports/asnad/sync',
@@ -510,6 +628,8 @@ async function startSync() {
connectionId: params.connectionId,
from: params.from,
to: params.to,
...(scoped && personId.value ? { personId: personId.value } : {}),
...(scoped && groupIds.value.length ? { groupIds: [...groupIds.value] } : {}),
},
{ timeout: 60_000 },
);
@@ -549,7 +669,10 @@ watch([connectionId, groupIds, kinds, jensFelez, personId], () => {
page.value = 1;
});
onUnmounted(() => stopPoll());
onUnmounted(() => {
stopPoll();
if (personSearchTimer) clearTimeout(personSearchTimer);
});
</script>
<template>
@@ -574,7 +697,7 @@ onUnmounted(() => stopPoll());
<template #icon><ArrowRightOutlined /></template>
بازگشت
</a-button>
<a-button type="primary" :loading="syncing" @click="startSync">
<a-button type="primary" :loading="syncing" @click="openSyncChoice">
<template #icon><SyncOutlined /></template>
به‌روزرسانی از ته‌حساب
</a-button>
@@ -613,17 +736,19 @@ onUnmounted(() => stopPoll());
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="بازه">
<a-segmented
v-model:value="period"
block
:options="[
{ label: 'هفته', value: 'week' },
{ label: 'ماه', value: 'month' },
{ label: 'فصل', value: 'season' },
{ label: 'تاریخ دلخواه', value: 'custom' },
]"
@change="(value: string | number) => applyNamedPeriod(value as 'week' | 'month' | 'season' | 'custom', true)"
/>
<div @click="onPeriodReclick">
<a-segmented
:value="period"
block
:options="[
{ label: 'هفته', value: 'week' },
{ label: 'ماه', value: 'month' },
{ label: 'فصل', value: 'season' },
{ label: 'تاریخ دلخواه', value: 'custom' },
]"
@change="onPeriodPick"
/>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
@@ -642,6 +767,8 @@ onUnmounted(() => stopPoll());
v-model:value="groupIds"
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه گروه‌ها"
:options="
(customerGroups as CustomerGroupDto[]).map((g) => ({
@@ -658,6 +785,8 @@ onUnmounted(() => stopPoll());
<a-select
v-model:value="kinds"
mode="multiple"
show-search
option-filter-prop="label"
:options="kindOptions"
placeholder="همه انواع"
:max-tag-count="2"
@@ -669,6 +798,8 @@ onUnmounted(() => stopPoll());
<a-select
v-model:value="jensFelez"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه"
:options="metalOptions"
/>
@@ -680,10 +811,11 @@ onUnmounted(() => stopPoll());
v-model:value="personId"
show-search
allow-clear
placeholder="جستجوی نام…"
placeholder="جستجوی مشتری ته‌حساب"
:filter-option="false"
:options="personOptions"
@search="searchPeople"
@search="searchTahesabCustomers"
@dropdown-visible-change="onPersonDropdown"
>
<template #suffixIcon><SearchOutlined /></template>
</a-select>
@@ -883,4 +1015,61 @@ onUnmounted(() => stopPoll());
</a-card>
</a-col>
</a-row>
<a-modal
v-model:open="periodModalOpen"
:title="periodModalKind === 'month' ? 'انتخاب ماه' : 'انتخاب فصل'"
:footer="null"
>
<a-form layout="vertical">
<a-form-item label="سال">
<a-select
v-model:value="pickerYear"
:options="pickerYears.map((year) => ({ value: year, label: toFaDigits(year) }))"
/>
</a-form-item>
</a-form>
<a-row v-if="periodModalKind === 'month'" :gutter="[8, 8]">
<a-col v-for="month in 12" :key="month" :span="8">
<a-button block @click="pickMonth(month)">{{ jalaliMonthName(month) }}</a-button>
</a-col>
</a-row>
<a-row v-else :gutter="[8, 8]">
<a-col v-for="season in SEASONS" :key="season.key" :span="12">
<a-button block @click="pickSeason(season.start, season.end)">{{ season.label }}</a-button>
</a-col>
</a-row>
</a-modal>
<a-modal
v-model:open="syncModalOpen"
title="به‌روزرسانی از ته‌حساب"
:footer="null"
>
<a-typography-paragraph>
ته‌حساب سند را مشتری‌به‌مشتری می‌دهد. اگر گروه یا شخص را انتخاب کنید، فقط همان‌ها گرفته می‌شود و سریع‌تر تمام می‌شود. نوع سند و فلز بعد از دریافت، روی همین داده‌ها فیلتر می‌شوند.
</a-typography-paragraph>
<a-typography-paragraph v-if="!hasSyncScope" type="secondary">
الان گروه یا شخصی انتخاب نشده، پس به‌روزرسانی همهٔ مشتریان متصل این بازه را از ته‌حساب می‌گیرد.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button
v-if="hasSyncScope"
type="primary"
block
:loading="syncing"
@click="startSync(true)"
>
فقط مشتریان همین فیلتر
</a-button>
<a-button
block
:type="hasSyncScope ? 'default' : 'primary'"
:loading="syncing"
@click="startSync(false)"
>
همه مشتریان این اتصال
</a-button>
</a-flex>
</a-modal>
</template>
+4
View File
@@ -4199,6 +4199,10 @@ export interface TahesabAsnadSyncRequest {
from: string;
/** شمسی YYYY-MM-DD */
to: string;
/** محدود کردن همگام‌سازی به یک شخص */
personId?: string;
/** محدود کردن همگام‌سازی به گروه‌ها و زیرگروه‌ها */
groupIds?: string[];
}
export interface TahesabAsnadSyncJobDto {