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>