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

573 lines
18 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { theme } from 'ant-design-vue';
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
import {
ArrowRightOutlined,
FilterOutlined,
SearchOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
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 { formatMoney } from '@/lib/money';
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) {
if (n == null) return '—';
return formatMoney(n, 'IRR');
}
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 = [];
}
}
async function loadReport() {
const params = filterParams.value;
if (!params) {
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;
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? 'بارگذاری گزارش ناموفق بود.';
error.value = String(msg);
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 = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.';
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 },
);
await pollJob(data.id);
} catch (e: unknown) {
syncing.value = false;
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? 'شروع همگام‌سازی ناموفق بود.';
error.value = String(msg);
}
}
function applyFilters() {
page.value = 1;
void loadReport();
}
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;
});
onMounted(async () => {
defaultRange();
try {
await Promise.all([loadConnections(), loadCustomerGroups()]);
await loadReport();
} catch {
error.value = 'بارگذاری اولیه ناموفق بود.';
}
});
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" @finish="applyFilters">
<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="از تاریخ" required>
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="تا تاریخ" required>
<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" html-type="submit" :loading="loading">
اعمال فیلتر
</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="summary.totalLines"
:formatter="(val) => toFaDigits(String(val))"
/>
</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>