Files
boursecu_admin/src/pages/menuPages/accounting/components/zarinTable.vue
T

512 lines
16 KiB
Vue
Raw Normal View History

2026-07-23 22:24:26 +03:30
<template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext
:title="customerId ? 'اسناد زرین این مشتری' : 'اسناد زرین'"
:help="accountingZarinDocsIntro"
>
2026-08-27 16:15:11 +03:30
<template #extra>
<a-button
v-if="(pending?.total ?? 0) > 0"
type="primary"
size="small"
:loading="syncing"
@click="runSyncAll"
>
ثبت همه ({{ pending?.total }})
</a-button>
</template>
<div
v-if="(pending?.total ?? 0) > 0"
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<div class="font-700">
{{ pending?.total }} سند جاافتاده / ناموفق برای زرین
<span v-if="customerId">این مشتری</span>
</div>
<div class="mt-1 opacity-90">
خطا خورده در جدول: {{ pending?.failed_rows }} —
شارژ جاافتاده: {{ pending?.missing_withdraws }} —
سفارش جاافتاده: {{ pending?.missing_orders }}
</div>
</div>
<a-tabs v-model:activeKey="docsView" class="mb-2" @change="onViewChange">
<a-tab-pane key="failed">
<template #tab>
خطا خورده / جاافتاده
<span v-if="(pending?.total ?? 0) > 0">({{ pending?.total }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="ok" tab="سندهای سالم" />
<a-tab-pane key="all" tab="کل اسناد" />
</a-tabs>
<div v-if="docsView === 'failed'" class="min-w-0 w-full overflow-x-auto">
<a-spin :spinning="failedLoading">
<a-table
:columns="failedColumns"
:data-source="failedRows"
:pagination="{ pageSize: 20, showSizeChanger: true }"
:row-key="failedRowKey"
size="small"
:locale="{ emptyText: 'مورد خطا خورده یا جاافتاده‌ای نیست' }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'row'">{{ index + 1 }}</template>
<template v-else-if="column.key === 'issue'">
<a-tag :color="record.issue_kind === 'missing' ? 'orange' : 'red'">
{{ zarinIssueKindLabel(record.issue_kind) }}
</a-tag>
</template>
<template v-else-if="column.key === 'customer'">
{{ record.customer?.name || '-' }}
</template>
<template v-else-if="column.key === 'amount'">
{{ metalAmountLabel(record) }}
</template>
<template v-else-if="column.key === 'rial'">
{{ rialAmountLabel(record) }}
</template>
<template v-else-if="column.key === 'status'">
{{ zarinStatusLabel(record.sync_status, record.zarin_id) }}
</template>
<template v-else-if="column.key === 'time'">
{{ record.created_at ? toJalali(record.created_at) : '-' }}
</template>
<template v-else-if="column.key === 'action'">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>جزئیات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.zarin_id && !record.customer?.moshtari_code && record.customer_id"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
>
<template #title><span>ثبت مشتری</span></template>
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-button
type="link"
size="small"
:loading="syncingRowKey === failedRowKey(record)"
@click="runSyncOne(record)"
>
ثبت
</a-button>
</div>
</template>
</template>
</a-table>
</a-spin>
</div>
<div v-else-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable :key="docsView" v-bind="tblProps" @register="submit" ref="tbl">
2026-07-23 22:24:26 +03:30
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>اطلاعات سند</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.zarin_id && !record.customer?.moshtari_code"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
>
<template #title><span>ثبت مشتری در زرین</span></template>
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-tooltip
v-if="!record.zarin_id"
placement="bottom"
class="cursor-pointer"
@click="openSend(record)"
>
<template #title><span>ارسال به زرین</span></template>
<Icon icon="vuesax-linear:send-2" color="#CA8A04" />
</a-tooltip>
</div>
</template>
</BaseTable>
</div>
2026-08-27 16:15:11 +03:30
<ZarinMobileCardList v-else-if="docsView !== 'failed'" ref="mobileListRef" />
2026-07-23 22:24:26 +03:30
</BasicShowContext>
<BasicModal v-model:visible="infoVisible" title="اطلاعات سند" width="36rem" :footer="null">
<div class="space-y-2 text-sm">
<div
v-for="item in infoItems"
:key="item.label"
class="flex justify-between gap-3 border-b border-gray-100 py-2"
>
<span class="text-gray-500">{{ item.label }}</span>
<span class="break-all text-left">{{ item.value ?? '-' }}</span>
</div>
2026-08-27 16:15:11 +03:30
<div
v-if="selected?.res"
class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap"
>
2026-07-23 22:24:26 +03:30
{{ selected.res }}
</div>
2026-08-27 16:15:11 +03:30
<div
v-if="selected?.sync_log"
class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap"
>
2026-07-23 22:24:26 +03:30
{{ selected.sync_log }}
</div>
2026-08-27 16:15:11 +03:30
<div v-if="canSyncSelected" class="pt-2">
<a-button type="primary" block :loading="!!syncingRowKey" @click="runSyncOne(selected!)">
ثبت در زرین
</a-button>
</div>
2026-07-23 22:24:26 +03:30
</div>
</BasicModal>
<ZarinRetryModal
v-model="sendVisible"
mode="retry-sanad"
2026-08-27 16:15:11 +03:30
:zarin-id="selected?.id ?? undefined"
@success="afterRowSync"
2026-07-23 22:24:26 +03:30
/>
<ZarinRetryModal
v-model="registerVisible"
mode="register-customer"
:customer-id="selected?.customer_id"
:customer-name="selected?.customer?.name"
2026-08-27 16:15:11 +03:30
@success="afterRowSync"
2026-07-23 22:24:26 +03:30
/>
</div>
</template>
<script setup lang="ts">
2026-08-27 16:15:11 +03:30
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
2026-07-23 22:24:26 +03:30
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
2026-08-27 16:15:11 +03:30
import {
getZarinPendingDocs,
getZarinPendingSummary,
getZarinTable,
syncAllZarinPending,
syncOneZarin,
type ZarinPendingSummary,
type ZarinRecord
} from '@/api/zarin'
2026-07-23 22:24:26 +03:30
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { isMobile } from '@core/utils/is'
import BasicModal from '@/components/BasicModal.vue'
import ZarinRetryModal from '@/components/ZarinRetryModal.vue'
import ZarinMobileCardList from '@/pages/menuPages/accounting/components/ZarinMobileCardList.vue'
2026-08-27 16:15:11 +03:30
import {
zarinIssueKindLabel,
zarinStatusLabel
} from '@/pages/menuPages/accounting/utils/zarinStatus'
2026-07-29 16:02:41 +03:30
import {
metalAmountLabel,
2026-08-27 16:15:11 +03:30
rialAmountLabel
2026-07-29 16:02:41 +03:30
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
2026-07-23 22:24:26 +03:30
import { toJalali } from '@core/utils/useDataTime'
import { accountingZarinDocsIntro } from '@/utils/adminHelpCopy'
2026-07-23 22:24:26 +03:30
2026-08-27 16:15:11 +03:30
const props = defineProps<{
customerId?: number
}>()
2026-07-23 22:24:26 +03:30
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
const infoVisible = ref(false)
const sendVisible = ref(false)
const registerVisible = ref(false)
const selected = ref<ZarinRecord | null>(null)
2026-08-27 16:15:11 +03:30
const pending = ref<ZarinPendingSummary | null>(null)
const syncing = ref(false)
const docsView = ref<'failed' | 'ok' | 'all'>('failed')
const failedRows = ref<ZarinRecord[]>([])
const failedLoading = ref(false)
const syncingRowKey = ref<string | null>(null)
2026-07-23 22:24:26 +03:30
2026-08-27 16:15:11 +03:30
const columns = computed(() => {
const cols: any[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (r: any) => r.index + 1
}
]
if (!props.customerId) {
cols.push({ title: 'مشتری', dataIndex: 'customer.name', key: 'customer.name', type: 'Text' })
2026-07-23 22:24:26 +03:30
}
2026-08-27 16:15:11 +03:30
cols.push(
{ title: 'نوع', dataIndex: 'type', key: 'type', type: 'Text' },
{
title: 'مقدار',
dataIndex: 'amount',
key: 'amount',
type: 'Number',
filterable: false,
customRender: (r: any) => metalAmountLabel(r.record)
},
{
title: 'مبلغ ریالی',
dataIndex: 'rial_amount',
key: 'rial_amount',
type: 'Text',
filterable: false,
sortable: false,
customRender: (r: any) => rialAmountLabel(r.record)
},
{ title: 'شرح', dataIndex: 'desc', key: 'desc', type: 'Text' },
{
title: 'وضعیت',
dataIndex: 'sync_status',
key: 'sync_status',
type: 'Text',
filterable: false,
customRender: (r: any) => zarinStatusLabel(r.record.sync_status, r.record.zarin_id)
},
{
title: 'زمان',
dataIndex: 'created_at',
key: 'created_at',
type: 'Text',
customRender: (r: any) => toJalali(r.record.created_at)
},
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false
}
)
return cols
2026-07-23 22:24:26 +03:30
})
2026-08-27 16:15:11 +03:30
const failedColumns = computed(() => {
const cols: any[] = [
{ title: 'ردیف', key: 'row', width: 60 },
{ title: 'نوع مشکل', key: 'issue', width: 110 }
]
if (!props.customerId) {
cols.push({
title: 'مشتری',
key: 'customer'
})
}
cols.push(
{ title: 'نوع', dataIndex: 'type', key: 'type' },
{ title: 'مقدار', key: 'amount' },
{ title: 'مبلغ ریالی', key: 'rial' },
{ title: 'شرح', dataIndex: 'desc', key: 'desc', ellipsis: true },
{ title: 'وضعیت', key: 'status' },
{ title: 'زمان', key: 'time', width: 140 },
{ title: 'عملیات', key: 'action', width: 140 }
)
return cols
})
const tblProps = computed(() => ({
columns: columns.value,
api: (params: TableInput) => {
if (props.customerId) {
params.filters = params.filters || []
params.filters.push({
field: 'customer_id',
type: 'text',
value: props.customerId,
cr: 'and',
op: 'eq',
hidden: false
})
}
return getZarinTable({ ...params, view: docsView.value })
}
}))
2026-07-23 22:24:26 +03:30
const infoItems = computed(() => {
const row = selected.value
if (!row) return []
return [
2026-08-27 16:15:11 +03:30
{ label: 'شناسه سند', value: row.id ?? '—' },
{ label: 'نوع مشکل', value: row.issue_kind ? zarinIssueKindLabel(row.issue_kind) : '—' },
2026-07-23 22:24:26 +03:30
{ label: 'مشتری', value: row.customer?.name },
{ label: 'موبایل', value: row.customer?.mobile },
{ label: 'نوع', value: row.type },
2026-07-29 16:02:41 +03:30
{ label: 'مقدار', value: metalAmountLabel(row) },
{ label: 'مبلغ ریالی', value: rialAmountLabel(row) },
2026-07-23 22:24:26 +03:30
{ label: 'شرح', value: row.desc },
2026-08-27 16:15:11 +03:30
{ label: 'سفارش', value: row.order_id || '-' },
{ label: 'برداشت', value: row.withdraw_id || '-' },
2026-07-23 22:24:26 +03:30
{ label: 'زمان ثبت', value: row.created_at ? toJalali(row.created_at) : '-' },
{ label: 'وضعیت', value: zarinStatusLabel(row.sync_status, row.zarin_id) },
{ label: 'کد زرین', value: row.zarin_id || 'ثبت نشده' }
]
})
2026-08-27 16:15:11 +03:30
const canSyncSelected = computed(() => {
const row = selected.value
if (!row?.sync_target) return false
return !row.zarin_id
})
function failedRowKey(record: ZarinRecord) {
if (record.sync_target) return `${record.sync_target.type}-${record.sync_target.id}`
if (record.id) return `row-${record.id}`
return `x-${record.withdraw_id || record.order_id || Math.random()}`
}
2026-07-23 22:24:26 +03:30
function openInfo(record: ZarinRecord) {
selected.value = record
infoVisible.value = true
}
function openSend(record: ZarinRecord) {
selected.value = record
sendVisible.value = true
}
function openRegister(record: ZarinRecord) {
selected.value = record
registerVisible.value = true
}
2026-08-27 16:15:11 +03:30
async function loadPending() {
try {
pending.value = await getZarinPendingSummary(props.customerId)
} catch {
pending.value = null
}
}
async function loadFailedDocs() {
failedLoading.value = true
try {
const res = await getZarinPendingDocs(props.customerId, 200)
failedRows.value = res.rows || []
pending.value = res.summary
} catch {
failedRows.value = []
} finally {
failedLoading.value = false
}
}
async function runSyncOne(record: ZarinRecord) {
const target = record.sync_target
if (!target) {
if (record.id) {
openSend(record)
return
}
message.warning('هدف ثبت مشخص نیست')
return
}
const key = failedRowKey(record)
syncingRowKey.value = key
try {
const result = await syncOneZarin(target.type, target.id)
if (result.ok) {
message.success(result.message || 'ثبت شد')
infoVisible.value = false
await afterRowSync()
} else {
message.error(result.message || 'ثبت ناموفق بود')
}
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || 'خطا در ثبت')
} finally {
syncingRowKey.value = null
}
}
async function runSyncAll() {
Modal.confirm({
title: 'ثبت همه در زرین',
content: props.customerId
? 'همهٔ سندهای ناموفق/جاافتادهٔ این مشتری ارسال می‌شود (حداکثر ۸۰). ادامه؟'
: 'حداکثر ۸۰ سند ناموفق/جاافتاده به زرین ارسال می‌شود. ادامه؟',
okText: 'ثبت همه',
cancelText: 'انصراف',
async onOk() {
syncing.value = true
try {
const result = await syncAllZarinPending(80, props.customerId)
const parts = [
result.retried ? `${result.retried} ارسال مجدد` : '',
result.backfilled_withdraws ? `${result.backfilled_withdraws} شارژ` : '',
result.backfilled_orders ? `${result.backfilled_orders} سفارش` : '',
result.failed ? `${result.failed} ناموفق` : ''
].filter(Boolean)
if (result.ok) {
message.success(parts.join(' — ') || 'همگام‌سازی انجام شد')
} else {
message.warning(parts.join(' — ') || 'بخشی از موارد ناموفق بود')
}
pending.value = result.summary
await afterRowSync()
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || 'خطا در همگام‌سازی')
} finally {
syncing.value = false
}
}
})
}
async function afterRowSync() {
await loadPending()
await fetch()
}
async function onViewChange() {
await fetch()
}
2026-07-23 22:24:26 +03:30
async function fetch() {
2026-08-27 16:15:11 +03:30
if (docsView.value === 'failed') {
await loadFailedDocs()
return
}
2026-07-23 22:24:26 +03:30
if (isMobile.value) {
2026-08-27 16:15:11 +03:30
await nextTick()
2026-07-23 22:24:26 +03:30
await mobileListRef.value?.fetch?.()
return
}
2026-08-27 16:15:11 +03:30
await nextTick()
2026-07-23 22:24:26 +03:30
await reload()
}
2026-08-27 16:15:11 +03:30
watch(
() => props.customerId,
async () => {
docsView.value = 'failed'
await loadPending()
await fetch()
}
)
onMounted(async () => {
await loadPending()
if ((pending.value?.total ?? 0) > 0) {
docsView.value = 'failed'
} else {
docsView.value = 'all'
}
await fetch()
})
2026-07-23 22:24:26 +03:30
defineExpose({ fetch })
</script>