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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user