Single-customer and row sync preview balances with documents before overwrite/keep; bulk sync keeps confirm. Also ship Zarin wallet sync actions, field help, and get_user cache improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
512 lines
16 KiB
Vue
512 lines
16 KiB
Vue
<template>
|
|
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
|
|
<BasicShowContext
|
|
:title="customerId ? 'اسناد زرین این مشتری' : 'اسناد زرین'"
|
|
:help="accountingZarinDocsIntro"
|
|
>
|
|
<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">
|
|
<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>
|
|
|
|
<ZarinMobileCardList v-else-if="docsView !== 'failed'" ref="mobileListRef" />
|
|
</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>
|
|
<div
|
|
v-if="selected?.res"
|
|
class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap"
|
|
>
|
|
{{ selected.res }}
|
|
</div>
|
|
<div
|
|
v-if="selected?.sync_log"
|
|
class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap"
|
|
>
|
|
{{ selected.sync_log }}
|
|
</div>
|
|
<div v-if="canSyncSelected" class="pt-2">
|
|
<a-button type="primary" block :loading="!!syncingRowKey" @click="runSyncOne(selected!)">
|
|
ثبت در زرین
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
</BasicModal>
|
|
|
|
<ZarinRetryModal
|
|
v-model="sendVisible"
|
|
mode="retry-sanad"
|
|
:zarin-id="selected?.id ?? undefined"
|
|
@success="afterRowSync"
|
|
/>
|
|
|
|
<ZarinRetryModal
|
|
v-model="registerVisible"
|
|
mode="register-customer"
|
|
:customer-id="selected?.customer_id"
|
|
:customer-name="selected?.customer?.name"
|
|
@success="afterRowSync"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
import { message, Modal } from 'ant-design-vue'
|
|
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
|
|
import type { TableInput } from '@core/type/baseTable'
|
|
import {
|
|
getZarinPendingDocs,
|
|
getZarinPendingSummary,
|
|
getZarinTable,
|
|
syncAllZarinPending,
|
|
syncOneZarin,
|
|
type ZarinPendingSummary,
|
|
type ZarinRecord
|
|
} from '@/api/zarin'
|
|
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'
|
|
import {
|
|
zarinIssueKindLabel,
|
|
zarinStatusLabel
|
|
} from '@/pages/menuPages/accounting/utils/zarinStatus'
|
|
import {
|
|
metalAmountLabel,
|
|
rialAmountLabel
|
|
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
|
|
import { toJalali } from '@core/utils/useDataTime'
|
|
import { accountingZarinDocsIntro } from '@/utils/adminHelpCopy'
|
|
|
|
const props = defineProps<{
|
|
customerId?: number
|
|
}>()
|
|
|
|
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)
|
|
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)
|
|
|
|
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' })
|
|
}
|
|
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
|
|
})
|
|
|
|
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 })
|
|
}
|
|
}))
|
|
|
|
const infoItems = computed(() => {
|
|
const row = selected.value
|
|
if (!row) return []
|
|
return [
|
|
{ label: 'شناسه سند', value: row.id ?? '—' },
|
|
{ label: 'نوع مشکل', value: row.issue_kind ? zarinIssueKindLabel(row.issue_kind) : '—' },
|
|
{ label: 'مشتری', value: row.customer?.name },
|
|
{ label: 'موبایل', value: row.customer?.mobile },
|
|
{ label: 'نوع', value: row.type },
|
|
{ label: 'مقدار', value: metalAmountLabel(row) },
|
|
{ label: 'مبلغ ریالی', value: rialAmountLabel(row) },
|
|
{ label: 'شرح', value: row.desc },
|
|
{ label: 'سفارش', value: row.order_id || '-' },
|
|
{ label: 'برداشت', value: row.withdraw_id || '-' },
|
|
{ label: 'زمان ثبت', value: row.created_at ? toJalali(row.created_at) : '-' },
|
|
{ label: 'وضعیت', value: zarinStatusLabel(row.sync_status, row.zarin_id) },
|
|
{ label: 'کد زرین', value: row.zarin_id || 'ثبت نشده' }
|
|
]
|
|
})
|
|
|
|
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()}`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
async function fetch() {
|
|
if (docsView.value === 'failed') {
|
|
await loadFailedDocs()
|
|
return
|
|
}
|
|
if (isMobile.value) {
|
|
await nextTick()
|
|
await mobileListRef.value?.fetch?.()
|
|
return
|
|
}
|
|
await nextTick()
|
|
await reload()
|
|
}
|
|
|
|
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()
|
|
})
|
|
|
|
defineExpose({ fetch })
|
|
</script>
|