@@ -0,0 +1,502 @@
< script setup lang = "ts" >
import { computed , onMounted , onUnmounted , ref , watch } from 'vue' ;
import { useRouter } from 'vue-router' ;
import type { TableColumnsType , TablePaginationConfig } from 'ant-design-vue' ;
import { ReloadOutlined , 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 { 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 } ,
{
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 ,
} ;
} ) ;
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 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-flex vertical :gap = "16" >
< a-row : gutter = "[16, 16]" align = "middle" justify = "space-between" >
< a-col :xs = "24" :md = "14" >
< a-typography-title :level = "3" style = "margin: 0" >
گزارش فروش ته حساب
< / a-typography-title >
< a-typography-text type = "secondary" >
اسنپ شات اسناد مشتریان لینک شده — طبقه بندی فروش / مرجوعی تقریبی است و بعد از بازخورد کارفرما اصلاح می شود .
< / a-typography-text >
< / a-col >
< a-col :xs = "24" :md = "10" >
< a-flex justify = "flex-end" wrap = "wrap" :gap = "8" >
< a-button @click ="router.push({ name: 'reports' })" > بازگشت به گزارشها < / a -button >
< a-button :loading = "loading" @click ="loadReport" >
< template # icon > < ReloadOutlined / > < / template >
نمایش
< / a-button >
< a-button type = "primary" :loading = "syncing" @click ="startSync" >
< template # icon > < SyncOutlined / > < / template >
به روزرسانی از ته حساب
< / a-button >
< / a-flex >
< / a-col >
< / a-row >
< a-alert v-if = "error" type="error" show-icon :message="error" closable @close="error = ''" / >
< a-alert v-if = "syncHint" type="info" show-icon :message="syncHint" / >
< a-card :bordered = "false" title = "فیلترها" >
< a-form layout = "vertical" >
< 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"
/ >
< / a-form-item >
< / a-col >
< / a-row >
< / a-form >
< / a-card >
< a-row v-if = "summary" :gutter="[16, 16]" >
< a -col :xs = "24" :sm = "8" >
< a-card :bordered = "false" >
< a-statistic title = "تعداد ردیف" :value = "summary.totalLines" / >
< / a-card >
< / a-col >
< a-col :xs = "24" :sm = "8" >
< a-card :bordered = "false" >
< a-statistic title = "جمع مبلغ" :value = "formatMali(summary.maliSum)" / >
< / a-card >
< / a-col >
< a-col :xs = "24" :sm = "8" >
< a-card :bordered = "false" >
< a-statistic title = "جمع وزن" :value = "formatNum(summary.vaznSum)" / >
< / a-card >
< / a-col >
< / a-row >
< a-row v-if = "summary?.byKind?.length" :gutter="[16, 16]" >
< a -col :xs = "24" :lg = "12" >
< a-card :bordered = "false" title = "به تفکیک نوع" >
< a-list
:data-source = "summary.byKind"
item -layout = " horizontal "
size = "small"
>
< 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 :xs = "24" :lg = "12" >
< a-card :bordered = "false" title = "به تفکیک گروه" >
< a-list
: data -source = " summary.byGroup.slice ( 0 , 8 ) "
item -layout = " horizontal "
size = "small"
>
< 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-row >
< a-card v-if = "summary?.distinctNos?.length" :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-card :bordered = "false" title = "ردیفها" >
< a-table
:columns = "columns"
: data -source = " report ? .items ? ? [ ] "
:loading = "loading"
: pagination = "{
current: page,
pageSize,
total: report?.total ?? 0,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
}"
row -key = " id "
: scroll = "{ x: 1100 }"
@change ="onTableChange"
/ >
< / a-card >
< / a-flex >
< / template >