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
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed } from 'vue';
import { theme } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import type {
SalesMovementStatsDto,
SalesNamedMovementDto,
TahesabSalesOverviewDto,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
const props = defineProps<{
overview: TahesabSalesOverviewDto | null;
}>();
const { token } = theme.useToken();
function formatNum(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return '—';
return toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n));
}
function formatMali(n: number | null | undefined) {
if (n == null) return '—';
return formatTahesabBedBes(n);
}
function percentText(stats: SalesMovementStatsDto, empty = 'درصدی ثبت نشده') {
const p = stats.percent;
if (!p || p.avg == null) return empty;
return `اول ${formatNum(p.first)} · آخر ${formatNum(p.last)} · میانگین ${formatNum(p.avg)} · کم ${formatNum(p.min)} · بیش ${formatNum(p.max)}`;
}
const factors = computed(() =>
(props.overview?.factors ?? []).filter((f) => f.key !== 'other' || f.sale.grossWeight > 0 || f.purchase.grossWeight > 0),
);
const karColumns: TableColumnsType<SalesNamedMovementDto> = [
{ title: 'کارساخته', dataIndex: 'name', key: 'name', ellipsis: true },
{
title: 'خالص فروش',
key: 'saleNet',
customRender: ({ record }) => formatNum(record.sale.netWeight),
},
{
title: 'مرجوعی فروش',
key: 'saleRet',
customRender: ({ record }) => formatNum(record.sale.returnWeight),
},
{
title: 'درصد خروج',
key: 'salePct',
customRender: ({ record }) => percentText(record.sale),
},
{
title: 'خالص خرید',
key: 'buyNet',
customRender: ({ record }) => formatNum(record.purchase.netWeight),
},
{
title: 'مرجوعی خرید',
key: 'buyRet',
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
},
{
title: 'درصد ورود',
key: 'buyPct',
customRender: ({ record }) => percentText(record.purchase),
},
];
</script>
<template>
<a-row v-if="overview" :gutter="[16, 16]">
<a-col v-if="!overview.percentReady" :span="24">
<a-alert
type="info"
show-icon
message="درصد ورود و خروج بعد از یک به‌روزرسانی تازه از ته‌حساب پر می‌شود. وزن و مرجوعی با سندهای فعلی حساب شده‌اند."
/>
</a-col>
<a-col v-for="factor in factors" :key="factor.key" :xs="24" :sm="12" :xl="6">
<a-card :bordered="false" :title="factor.label">
<a-row :gutter="[12, 8]">
<a-col :span="12">
<a-statistic title="خالص فروش" :value="formatNum(factor.sale.netWeight)" />
<a-typography-text type="secondary">
مرجوعی {{ formatNum(factor.sale.returnWeight) }}
<template v-if="factor.key === 'bullion'">
· تعداد {{ formatNum(factor.sale.netCount) }}
</template>
</a-typography-text>
</a-col>
<a-col :span="12">
<a-statistic title="خالص خرید" :value="formatNum(factor.purchase.netWeight)" />
<a-typography-text type="secondary">
مرجوعی {{ formatNum(factor.purchase.returnWeight) }}
</a-typography-text>
</a-col>
<a-col :span="24">
<a-typography-text type="secondary">مبلغ فروش {{ formatMali(factor.sale.netMali) }}</a-typography-text>
</a-col>
<a-col v-if="percentText(factor.sale, '') || percentText(factor.purchase, '')" :span="24">
<div v-if="percentText(factor.sale, '')">خروج: {{ percentText(factor.sale, '') }}</div>
<div v-if="percentText(factor.purchase, '')">ورود: {{ percentText(factor.purchase, '') }}</div>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="کارساخته، خالص بعد از مرجوعی">
<a-table
:columns="karColumns"
:data-source="overview.karsakhte"
row-key="name"
:pagination="{ pageSize: 8, showSizeChanger: false }"
:scroll="{ x: 980 }"
>
<template #emptyText>
<a-empty description="در این بازه سند کارساخته نیست." />
</template>
</a-table>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="گروه اصلی و فرعی">
<a-empty v-if="!overview.groups.length" description="گروهی در این بازه فروش ندارد." />
<div v-for="group in overview.groups" :key="group.id">
<a-flex justify="space-between" :style="{ marginTop: token.marginSM }">
<a-typography-text strong>{{ group.label }}</a-typography-text>
<a-typography-text>
خالص {{ formatNum(group.netSaleWeight) }} · مرجوعی {{ formatNum(group.saleReturnWeight) }}
</a-typography-text>
</a-flex>
<a-list v-if="group.children.length" :data-source="group.children">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
خالص {{ formatNum(item.netSaleWeight) }} · مبلغ {{ formatMali(item.netSaleMali) }} · مرجوعی
{{ formatNum(item.saleReturnWeight) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="پورسانت کارشناس">
<a-typography-paragraph type="secondary">
کارساختهٔ نقره و ساچمه (اتیکت جواهر و فروش اینترنتی) جدا حساب می‌شوند.
</a-typography-paragraph>
<a-list
:data-source="overview.experts"
item-layout="vertical"
:locale="{ emptyText: 'کارشناسی برای این بازه نیست.' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.name" :description="`درصد ${formatNum(item.percent)}`" />
<div>
کارساخته نقره: وزن {{ formatNum(item.silverWeight) }} · پورسانت
{{ formatMali(item.silverCommission) }}
</div>
<div>
سرمایه‌گذاری: وزن {{ formatNum(item.investWeight) }} · پورسانت
{{ formatMali(item.investCommission) }}
</div>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="فروش اینترنتی (مونتیفای)">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="8">
<a-statistic title="سفارش" :value="formatNum(overview.internet.orderCount)" />
</a-col>
<a-col :xs="24" :sm="8">
<a-statistic title="خالص نقره" :value="formatNum(overview.internet.netSilver)" />
</a-col>
<a-col :xs="24" :sm="8">
<a-statistic title="خالص طلا" :value="formatNum(overview.internet.netGold)" />
</a-col>
<a-col :span="24">
<a-typography-text type="secondary">
مبلغ {{ formatMali(overview.internet.netMali) }} · لغوشده
{{ formatNum(overview.internet.returnCount) }} سفارش، نقره
{{ formatNum(overview.internet.returnSilver) }} ·
{{ formatNum(overview.internet.linkedSkipped) }} سفارش چون در ته‌حساب سند دارد اینجا دوباره نیامده
</a-typography-text>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col v-if="overview.otherNos.length" :span="24">
<a-card :bordered="false" title="سندهایی که در چهار بخش جا نگرفتند">
<a-space wrap>
<a-tag v-for="name in overview.otherNos" :key="name">{{ name }}</a-tag>
</a-space>
</a-card>
</a-col>
</a-row>
</template>
+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