feat(tahesab): net sales by factor, group, and commission

Report weight after returns for the selected period, with parent customer groups and separate silver and investment commission.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-26 11:05:34 +03:30
co-authored by Cursor
parent 15f89aec04
commit 2ff35e55fc
12 changed files with 1408 additions and 18 deletions
+213 -13
View File
@@ -1,17 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
List as AList,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Statistic as AStatistic,
@@ -45,17 +48,22 @@ import {
type TahesabAsnadSummaryDto,
type TahesabAsnadSyncJobDto,
type TahesabConnectionDto,
type TahesabSalesOverviewDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import {
dateToJalali,
formatLocalDateInput,
jalaliToDate,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
import { useCustomerGroups } from '@/lib/customer-groups';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import SalesReportBoard from '@/components/tahesab/SalesReportBoard.vue';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
const router = useRouter();
const { token } = theme.useToken();
@@ -68,7 +76,15 @@ const toDate = ref('');
const groupIds = ref<string[]>([]);
const personId = ref<string | undefined>();
const personOptions = ref<Array<{ value: string; label: string }>>([]);
const kinds = ref<TahesabAsnadKind[]>(['sale', 'return']);
const kinds = ref<TahesabAsnadKind[]>([]);
const period = ref<'custom' | 'week' | 'month' | 'season'>('month');
const overview = ref<TahesabSalesOverviewDto | null>(null);
const auth = useAuthStore();
const canGroups = computed(() => canManageSettings(auth.user?.role));
const childGroupId = ref<string | undefined>();
const parentGroupId = ref<string | undefined>();
const overviewError = ref('');
let applyingPeriod = false;
const jensFelez = ref<number | undefined>();
const loading = ref(false);
@@ -100,11 +116,96 @@ function toShamsiYmd(localIso: string): string | null {
}
function defaultRange() {
const now = new Date();
const from = new Date(now);
from.setDate(from.getDate() - 30);
fromDate.value = formatLocalDateInput(from);
toDate.value = formatLocalDateInput(now);
applyNamedPeriod('month', false);
}
function applyNamedPeriod(key: 'week' | 'month' | 'season' | 'custom', reload: boolean) {
applyingPeriod = true;
period.value = key;
if (key !== 'custom') {
const now = new Date();
const j = dateToJalali(now);
let start = now;
if (key === 'week') {
const delta = (now.getDay() + 1) % 7;
start = new Date(now);
start.setDate(now.getDate() - delta);
} else if (key === 'month') {
start = jalaliToDate(j.jy, j.jm, 1);
} else {
const sm = j.jm <= 3 ? 1 : j.jm <= 6 ? 4 : j.jm <= 9 ? 7 : 10;
start = jalaliToDate(j.jy, sm, 1);
}
fromDate.value = formatLocalDateInput(start);
toDate.value = formatLocalDateInput(now);
}
void nextTick(() => {
applyingPeriod = false;
});
if (reload) applyFilters();
}
function markCustomPeriod() {
if (applyingPeriod) return;
period.value = 'custom';
}
function groupOptionLabel(g: CustomerGroupDto) {
return g.parentName ? `${g.parentName} / ${g.name}` : g.name;
}
function parentChoices(groupId: string) {
return (customerGroups.value as CustomerGroupDto[])
.filter((g) => g.id !== groupId && !g.parentId)
.map((g) => ({ value: g.id, label: g.name }));
}
function groupHasChildren(groupId: string) {
return (customerGroups.value as CustomerGroupDto[]).some((g) => g.parentId === groupId);
}
const childGroupOptions = computed(() =>
(customerGroups.value as CustomerGroupDto[])
.filter((g) => !groupHasChildren(g.id))
.map((g) => ({ value: g.id, label: groupOptionLabel(g) })),
);
const linkedGroups = computed(() =>
(customerGroups.value as CustomerGroupDto[]).filter((g) => g.parentId && g.parentName),
);
function onChildGroup(id: string | undefined) {
childGroupId.value = id;
const current = (customerGroups.value as CustomerGroupDto[]).find((g) => g.id === id);
parentGroupId.value = current?.parentId ?? undefined;
}
async function saveGroupParent() {
const groupId = childGroupId.value;
if (!groupId) return;
const current = (customerGroups.value as CustomerGroupDto[]).find((g) => g.id === groupId);
const next = parentGroupId.value || null;
if ((current?.parentId ?? null) === next) return;
try {
await api.patch(`/customer-groups/${groupId}`, { parentId: next });
await loadCustomerGroups(true);
message.success('گروه اصلی ذخیره شد.');
await loadReport();
} catch (e: unknown) {
message.error(apiErrMessage(e, 'ذخیره گروه اصلی ناموفق بود.'));
}
}
async function clearGroupParent(groupId: string) {
try {
await api.patch(`/customer-groups/${groupId}`, { parentId: null });
if (childGroupId.value === groupId) parentGroupId.value = undefined;
await loadCustomerGroups(true);
message.success('گروه از زیرمجموعه خارج شد.');
await loadReport();
} catch (e: unknown) {
message.error(apiErrMessage(e, 'برداشتن گروه اصلی ناموفق بود.'));
}
}
function kindLabel(k: string) {
@@ -269,6 +370,21 @@ async function loadReport(opts?: { notifyEmpty?: boolean }) {
]);
report.value = pageRes.data;
summary.value = sumRes.data;
try {
const overRes = await api.get<TahesabSalesOverviewDto>(
'/integrations/tahesab/reports/asnad/overview',
{ params },
);
overview.value = overRes.data;
overviewError.value = '';
} catch (e: unknown) {
overview.value = null;
const status = axios.isAxiosError(e) ? e.response?.status : undefined;
overviewError.value =
status === 404
? 'خلاصه فروش، گروه و پورسانت بعد از به‌روزرسانی سرور اینجا می‌آید. جدول سندها با دادهٔ فعلی کار می‌کند.'
: apiErrMessage(e, 'خلاصه فروش، گروه و پورسانت بارگذاری نشد.');
}
if (opts?.notifyEmpty && (pageRes.data.total ?? 0) === 0) {
message.info(
'ردیفی نیست. اول «به‌روزرسانی از ته‌حساب» را برای همین بازه بزنید.',
@@ -279,6 +395,8 @@ async function loadReport(opts?: { notifyEmpty?: boolean }) {
message.error(error.value);
report.value = null;
summary.value = null;
overview.value = null;
overviewError.value = '';
} finally {
loading.value = false;
}
@@ -306,7 +424,7 @@ async function pollJob(jobId: string) {
error.value = data.errorMessage || 'همگام‌سازی ناموفق بود.';
} else {
if (data.truncatedPersonIds?.length) {
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری ممکن است اسناد ناقص باشد (سقف ۲۰۰).`;
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری سندهای بازه کامل نیامده است.`;
} else {
syncHint.value = `همگام‌سازی تمام شد · ${toFaDigits(String(data.linesUpserted))} ردیف`;
}
@@ -394,7 +512,7 @@ onUnmounted(() => stopPoll());
گزارش فروش ته‌حساب
</a-typography-title>
<a-typography-text type="secondary">
فروش و مرجوعی مشتریان لینک‌شده بر اساس بازه تاریخ
وزن خالص فروش و خرید بعد از مرجوعی، به تفکیک گروه، کارشناس و فروش اینترنتی
</a-typography-text>
</div>
<a-space :size="8" wrap>
@@ -439,14 +557,29 @@ onUnmounted(() => stopPoll());
/>
</a-form-item>
</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)"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="از تاریخ">
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" />
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" @update:model-value="markCustomPeriod" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="تا تاریخ">
<JalaliDateInput v-model="toDate" placeholder="تا تاریخ" />
<JalaliDateInput v-model="toDate" placeholder="تا تاریخ" @update:model-value="markCustomPeriod" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
@@ -459,7 +592,7 @@ onUnmounted(() => stopPoll());
:options="
(customerGroups as CustomerGroupDto[]).map((g) => ({
value: g.id,
label: g.name,
label: groupOptionLabel(g),
}))
"
:max-tag-count="2"
@@ -472,7 +605,7 @@ onUnmounted(() => stopPoll());
v-model:value="kinds"
mode="multiple"
:options="kindOptions"
placeholder="فروش / مرجوعی / …"
placeholder="همه انواع"
:max-tag-count="2"
/>
</a-form-item>
@@ -518,6 +651,73 @@ onUnmounted(() => stopPoll());
</a-card>
</a-col>
<a-col v-if="canGroups" :span="24">
<a-collapse :bordered="false">
<a-collapse-panel key="parents" header="چیدمان گروه اصلی و فرعی">
<a-typography-paragraph type="secondary">
گروه فرعی را به یک گروه اصلی وصل کنید تا فیلتر فروش، زیرگروه‌ها را هم بیاورد.
</a-typography-paragraph>
<a-row :gutter="[12, 12]" align="bottom">
<a-col :xs="24" :md="9">
<a-form-item label="گروه فرعی">
<a-select
:value="childGroupId"
show-search
allow-clear
placeholder="جستجوی گروه"
option-filter-prop="label"
:options="childGroupOptions"
@change="(value: string | undefined) => onChildGroup(value)"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="9">
<a-form-item label="گروه اصلی">
<a-select
v-model:value="parentGroupId"
show-search
allow-clear
placeholder="بدون گروه اصلی"
option-filter-prop="label"
:disabled="!childGroupId"
:options="childGroupId ? parentChoices(childGroupId) : []"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label=" ">
<a-button type="primary" block :disabled="!childGroupId" @click="saveGroupParent">
ذخیره ارتباط
</a-button>
</a-form-item>
</a-col>
</a-row>
<a-list
v-if="linkedGroups.length"
:data-source="linkedGroups"
:locale="{ emptyText: 'هنوز گروهی زیرمجموعه نشده.' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-typography-text>{{ item.parentName }} / {{ item.name }}</a-typography-text>
<template #actions>
<a-button type="link" @click="clearGroupParent(item.id)">جدا کردن</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
</a-collapse>
</a-col>
<a-col v-if="overviewError" :span="24">
<a-alert type="warning" show-icon :message="overviewError" />
</a-col>
<a-col :span="24">
<SalesReportBoard :overview="overview" />
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic