Files
CRM_Montzar/apps/web/src/views/TahesabSalesReportView.vue
T

610 lines
19 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
List as AList,
Row as ARow,
Select as ASelect,
Space as ASpace,
Statistic as AStatistic,
Table as ATable,
Tag as ATag,
Typography,
message,
theme,
} from 'ant-design-vue';
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
import {
ArrowRightOutlined,
FilterOutlined,
SearchOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import axios from 'axios';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import {
TAHESAB_ASNAD_KIND_LABELS,
TAHESAB_ASNAD_KINDS,
TAHESAB_JENS_FELEZ_LABELS,
type CustomerGroupDto,
type PersonDto,
type TahesabAsnadKind,
type TahesabAsnadLineDto,
type TahesabAsnadReportPageDto,
type TahesabAsnadSummaryDto,
type TahesabAsnadSyncJobDto,
type TahesabConnectionDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import {
dateToJalali,
formatLocalDateInput,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
import { useCustomerGroups } from '@/lib/customer-groups';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
const router = useRouter();
const { token } = theme.useToken();
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const connections = ref<TahesabConnectionDto[]>([]);
const connectionId = ref<string | undefined>();
const fromDate = ref('');
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 jensFelez = ref<number | undefined>();
const loading = ref(false);
const syncing = ref(false);
const error = ref('');
const syncHint = ref('');
const page = ref(1);
const pageSize = ref(50);
const report = ref<TahesabAsnadReportPageDto | null>(null);
const summary = ref<TahesabAsnadSummaryDto | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const kindOptions = TAHESAB_ASNAD_KINDS.map((k) => ({
value: k,
label: TAHESAB_ASNAD_KIND_LABELS[k],
}));
const metalOptions = [
{ value: 0, label: TAHESAB_JENS_FELEZ_LABELS[0] },
{ value: 1, label: TAHESAB_JENS_FELEZ_LABELS[1] },
{ value: 2, label: TAHESAB_JENS_FELEZ_LABELS[2] },
];
function toShamsiYmd(localIso: string): string | null {
const d = parseLocalInput(localIso);
if (!d) return null;
const { jy, jm, jd } = dateToJalali(d);
return `${jy}-${String(jm).padStart(2, '0')}-${String(jd).padStart(2, '0')}`;
}
function defaultRange() {
const now = new Date();
const from = new Date(now);
from.setDate(from.getDate() - 30);
fromDate.value = formatLocalDateInput(from);
toDate.value = formatLocalDateInput(now);
}
function kindLabel(k: string) {
return TAHESAB_ASNAD_KIND_LABELS[k as TahesabAsnadKind] ?? k;
}
function metalLabel(v: number | null) {
if (v == null) return '—';
return TAHESAB_JENS_FELEZ_LABELS[v] ?? String(v);
}
function formatNum(n: number | null | undefined) {
if (n == null) return '—';
return toFaDigits(
new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n),
);
}
function formatMali(n: number | null | undefined) {
return formatTahesabBedBes(n);
}
const columns: TableColumnsType<TahesabAsnadLineDto> = [
{
title: 'تاریخ',
dataIndex: 'tarikh',
key: 'tarikh',
width: 110,
customRender: ({ text }) => toFaDigits(String(text ?? '')),
},
{
title: 'شخص',
key: 'person',
ellipsis: true,
customRender: ({ record }) =>
record.personName
? `${record.personName} (${toFaDigits(String(record.moshtariCode))})`
: toFaDigits(String(record.moshtariCode)),
},
{
title: 'گروه',
key: 'groups',
ellipsis: true,
customRender: ({ record }) =>
record.groups?.map((g) => g.name).join('، ') || '—',
},
{ title: 'نوع سند (NO)', dataIndex: 'noRaw', key: 'noRaw', ellipsis: true },
{
title: 'طبقه‌بندی',
key: 'kind',
width: 100,
customRender: ({ record }) => kindLabel(record.kind),
},
{
title: 'فلز',
key: 'jensFelez',
width: 90,
customRender: ({ record }) => metalLabel(record.jensFelez),
},
{
title: 'وزن',
key: 'vazn',
width: 90,
customRender: ({ record }) => formatNum(record.vazn),
},
{
title: 'مبلغ',
key: 'mali',
width: 140,
customRender: ({ record }) => formatMali(record.mali),
},
{
title: 'ش. فاکتور',
key: 'shFactor',
width: 100,
customRender: ({ record }) =>
record.shFactor != null ? toFaDigits(String(record.shFactor)) : '—',
},
];
const filterParams = computed(() => {
const from = toShamsiYmd(fromDate.value);
const to = toShamsiYmd(toDate.value);
if (!from || !to) return null;
return {
connectionId: connectionId.value,
from,
to,
groupIds: groupIds.value.length ? groupIds.value.join(',') : undefined,
personId: personId.value,
kinds: kinds.value.length ? kinds.value.join(',') : undefined,
jensFelez: jensFelez.value,
};
});
const lastSyncedLabel = computed(() => {
const raw = summary.value?.lastSyncedAt;
if (!raw) return null;
try {
return toFaDigits(new Date(raw).toLocaleString('fa-IR'));
} catch {
return null;
}
});
async function loadConnections() {
const { data } = await api.get<TahesabConnectionDto[]>(
'/integrations/tahesab/connections/public',
);
connections.value = data.filter((c) => c.enabled);
const def =
connections.value.find((c) => c.isDefault) ?? connections.value[0];
if (!connectionId.value && def) connectionId.value = def.id;
}
async function searchPeople(q: string) {
if (!q || q.trim().length < 2) {
personOptions.value = [];
return;
}
try {
const { data } = await api.get<{ items: PersonDto[] }>('/people', {
params: { q: q.trim(), limit: 20 },
});
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,
}));
} catch {
personOptions.value = [];
}
}
function apiErrMessage(e: unknown, fallback: string): string {
if (!axios.isAxiosError(e)) return fallback;
const raw = e.response?.data as { message?: string | string[] } | undefined;
const msg = raw?.message;
if (Array.isArray(msg)) return msg.filter(Boolean).join(' · ') || fallback;
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
async function loadReport(opts?: { notifyEmpty?: boolean }) {
const params = filterParams.value;
if (!params) {
error.value = 'تاریخ از و تا را انتخاب کنید.';
message.warning(error.value);
return;
}
loading.value = true;
error.value = '';
try {
const [pageRes, sumRes] = await Promise.all([
api.get<TahesabAsnadReportPageDto>('/integrations/tahesab/reports/asnad', {
params: { ...params, page: page.value, pageSize: pageSize.value },
}),
api.get<TahesabAsnadSummaryDto>(
'/integrations/tahesab/reports/asnad/summary',
{ params },
),
]);
report.value = pageRes.data;
summary.value = sumRes.data;
if (opts?.notifyEmpty && (pageRes.data.total ?? 0) === 0) {
message.info(
'ردیفی نیست. اول «به‌روزرسانی از ته‌حساب» را برای همین بازه بزنید.',
);
}
} catch (e: unknown) {
error.value = apiErrMessage(e, 'بارگذاری گزارش ناموفق بود.');
message.error(error.value);
report.value = null;
summary.value = null;
} finally {
loading.value = false;
}
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function pollJob(jobId: string) {
stopPoll();
pollTimer = setInterval(async () => {
try {
const { data } = await api.get<TahesabAsnadSyncJobDto>(
`/integrations/tahesab/reports/asnad/sync/${jobId}`,
);
syncHint.value = `همگام‌سازی: ${toFaDigits(String(data.processed))} از ${toFaDigits(String(data.total))} · ${toFaDigits(String(data.linesUpserted))} ردیف`;
if (data.status === 'done' || data.status === 'failed') {
stopPoll();
syncing.value = false;
if (data.status === 'failed') {
error.value = data.errorMessage || 'همگام‌سازی ناموفق بود.';
} else {
if (data.truncatedPersonIds?.length) {
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری ممکن است اسناد ناقص باشد (سقف ۲۰۰).`;
} else {
syncHint.value = `همگام‌سازی تمام شد · ${toFaDigits(String(data.linesUpserted))} ردیف`;
}
await loadReport();
}
}
} catch {
stopPoll();
syncing.value = false;
error.value = 'وضعیت همگام‌سازی دریافت نشد.';
}
}, 1500);
}
async function startSync() {
const params = filterParams.value;
if (!params) {
error.value = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.';
message.warning(error.value);
return;
}
syncing.value = true;
error.value = '';
syncHint.value = 'شروع همگام‌سازی…';
try {
const { data } = await api.post<TahesabAsnadSyncJobDto>(
'/integrations/tahesab/reports/asnad/sync',
{
connectionId: params.connectionId,
from: params.from,
to: params.to,
},
{ timeout: 60_000 },
);
message.success('همگام‌سازی شروع شد.');
await pollJob(data.id);
} catch (e: unknown) {
syncing.value = false;
syncHint.value = '';
error.value = apiErrMessage(e, 'شروع همگام‌سازی ناموفق بود.');
message.error(error.value);
}
}
function applyFilters() {
page.value = 1;
void loadReport({ notifyEmpty: true });
}
onMounted(async () => {
defaultRange();
try {
await Promise.all([loadConnections(), loadCustomerGroups()]);
await loadReport();
} catch (e: unknown) {
error.value = apiErrMessage(e, 'بارگذاری اولیه ناموفق بود.');
message.error(error.value);
}
});
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1;
pageSize.value = pag.pageSize ?? 50;
void loadReport();
}
watch([connectionId, groupIds, kinds, jensFelez, personId], () => {
page.value = 1;
});
onUnmounted(() => stopPoll());
</script>
<template>
<a-row :gutter="[24, 24]">
<a-col :span="24">
<a-flex
justify="space-between"
align="center"
wrap="wrap"
:gap="token.marginSM"
>
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">
گزارش فروش ته‌حساب
</a-typography-title>
<a-typography-text type="secondary">
فروش و مرجوعی مشتریان لینک‌شده بر اساس بازه تاریخ
</a-typography-text>
</div>
<a-space :size="8" wrap>
<a-button @click="router.push({ name: 'reports' })">
<template #icon><ArrowRightOutlined /></template>
بازگشت
</a-button>
<a-button type="primary" :loading="syncing" @click="startSync">
<template #icon><SyncOutlined /></template>
به‌روزرسانی از ته‌حساب
</a-button>
</a-space>
</a-flex>
</a-col>
<a-col v-if="error" :span="24">
<a-alert type="error" show-icon :message="error" closable @close="error = ''" />
</a-col>
<a-col v-if="syncHint" :span="24">
<a-alert type="info" show-icon :message="syncHint" />
</a-col>
<a-col :span="24">
<a-card :bordered="false">
<template #title>
<a-space :size="8">
<FilterOutlined />
<span>فیلترها</span>
</a-space>
</template>
<a-form layout="vertical" @submit.prevent>
<a-row :gutter="[16, 8]">
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="اتصال">
<a-select
v-model:value="connectionId"
:options="
connections.map((c) => ({ value: c.id, label: c.label }))
"
placeholder="اتصال ته‌حساب"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="از تاریخ">
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="تا تاریخ">
<JalaliDateInput v-model="toDate" placeholder="تا تاریخ" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="گروه مشتری">
<a-select
v-model:value="groupIds"
mode="multiple"
allow-clear
placeholder="همه گروه‌ها"
:options="
(customerGroups as CustomerGroupDto[]).map((g) => ({
value: g.id,
label: g.name,
}))
"
:max-tag-count="2"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="نوع سند">
<a-select
v-model:value="kinds"
mode="multiple"
:options="kindOptions"
placeholder="فروش / مرجوعی / …"
:max-tag-count="2"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="فلز">
<a-select
v-model:value="jensFelez"
allow-clear
placeholder="همه"
:options="metalOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="شخص">
<a-select
v-model:value="personId"
show-search
allow-clear
placeholder="جستجوی نام…"
:filter-option="false"
:options="personOptions"
@search="searchPeople"
>
<template #suffixIcon><SearchOutlined /></template>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text v-if="lastSyncedLabel" type="secondary">
آخرین همگام‌سازی: {{ lastSyncedLabel }}
</a-typography-text>
<a-typography-text v-else type="secondary">
بعد از انتخاب بازه، فیلتر را اعمال کنید یا از ته‌حساب به‌روز کنید.
</a-typography-text>
<a-button type="primary" :loading="loading" @click="applyFilters">
اعمال فیلتر
</a-button>
</a-flex>
</a-form>
</a-card>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic
title="تعداد ردیف"
:value="toFaDigits(String(summary.totalLines))"
/>
</a-card>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع مبلغ" :value="formatMali(summary.maliSum)" />
</a-card>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع وزن" :value="formatNum(summary.vaznSum)" />
</a-card>
</a-col>
<a-col v-if="summary?.byKind?.length" :xs="24" :lg="12">
<a-card :bordered="false" title="به تفکیک نوع">
<a-list :data-source="summary.byKind" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
{{ formatMali(item.maliSum) }} · وزن {{ formatNum(item.vaznSum) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col v-if="summary?.byGroup?.length" :xs="24" :lg="12">
<a-card :bordered="false" title="به تفکیک گروه">
<a-list
:data-source="summary.byGroup.slice(0, 8)"
item-layout="horizontal"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
{{ formatMali(item.maliSum) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col v-if="summary?.distinctNos?.length" :span="24">
<a-card :bordered="false" title="انواع سند خام (NO)">
<a-space wrap>
<a-tag v-for="no in summary.distinctNos" :key="no">{{ no }}</a-tag>
</a-space>
</a-card>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="ردیف‌های گزارش">
<a-table
:columns="columns"
:data-source="report?.items ?? []"
:loading="loading"
:pagination="{
current: page,
pageSize,
total: report?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `${toFaDigits(String(total))} ردیف`,
pageSizeOptions: ['20', '50', '100'],
}"
row-key="id"
:scroll="{ x: 1100 }"
@change="onTableChange"
>
<template #emptyText>
<a-empty description="برای این بازه ردیفی نیست. بازه را اعمال کنید یا از ته‌حساب به‌روز کنید." />
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</template>