feat(tahesab): poll customer sync from any page and reuse the progress modal

Start outbound/inbound on the server; hydrate after refresh so the transfer modal returns without opening accounting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 10:09:54 +03:30
co-authored by Cursor
parent 6ab14313dd
commit 62a6a91455
6 changed files with 197 additions and 64 deletions
+9
View File
@@ -16,6 +16,7 @@ import CustomerImportProgress from '@/components/CustomerImportProgress.vue'
import TahesabDocSyncProgress from '@/components/TahesabDocSyncProgress.vue'
import GroupSmsSendProgress from '@/components/GroupSmsSendProgress.vue'
import { useUserStore } from '@/store/userStore'
import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
import { bootstrapAdminChat, useAdminChatService } from '@/utils/adminChat'
import { supportChatMenuEnabledSetting } from '@/utils/config'
@@ -66,7 +67,15 @@ watch(latestIncomingNotification, (payload) => {
onMounted(async () => {
await config.getConfigData()
void useTahesabDocSyncStore().hydrate()
})
watch(
() => userStore.userInfo?.id,
(id) => {
if (id) void useTahesabDocSyncStore().hydrate()
},
)
</script>
<style lang="less"></style>
+14
View File
@@ -38,6 +38,7 @@ export interface TahesabDocSyncState {
running: boolean
finished: boolean
stopped: boolean
stage?: 'docs' | 'customers_out' | 'customers_in'
percent: number
total: number
processed: number
@@ -45,6 +46,9 @@ export interface TahesabDocSyncState {
failed: number
current: string
errors: { label?: string; error?: string }[]
doc_total?: number
doc_success?: number
doc_failed?: number
started_at?: string | null
finished_at?: string | null
}
@@ -97,6 +101,16 @@ export const stopTahesabDocSync = async () =>
'/admin/tahesab/doc-sync/stop'
)
export const startTahesabCustomerOutbound = async () =>
await useAxios.post<{ ok: boolean; message: string; doc_sync: TahesabDocSyncState }>(
'/admin/tahesab/customer-sync/outbound/start'
)
export const startTahesabCustomerInbound = async () =>
await useAxios.post<{ ok: boolean; message: string; doc_sync: TahesabDocSyncState }>(
'/admin/tahesab/customer-sync/inbound/start'
)
export const registerCustomerInTahesab = async (customerId: number) =>
await useAxios.post<{ ok: boolean; message: string; moshtari_code?: string | number }>(
`/admin/customer/${customerId}/tahesab/register`
+67 -19
View File
@@ -33,7 +33,7 @@
<span class="cip-counter__sep">/</span>
<span class="cip-counter__total">{{ store.state.total }}</span>
</div>
<div class="cip-counter__label">در حال ارسال سندها به ته‌حساب…</div>
<div class="cip-counter__label">{{ runningLabel }}</div>
<div class="cip-track">
<div class="cip-track__fill" :style="{ width: store.percent + '%' }" />
</div>
@@ -59,14 +59,12 @@
<Icon :icon="outcomeInfo.icon" :size="34" />
</div>
<div class="cip-report__title">{{ outcomeInfo.text }}</div>
<div class="cip-report__subtitle">
از {{ store.state.total }} سند، {{ store.state.processed }} مورد پردازش شد
</div>
<div class="cip-report__subtitle">{{ reportSubtitle }}</div>
<div class="cip-report__cards">
<div class="cip-rcard cip-rcard--ok">
<Icon icon="vuesax-linear:tick-circle" :size="20" />
<div class="cip-rcard__num">{{ store.state.success }}</div>
<div class="cip-rcard__label">ارسال شد</div>
<div class="cip-rcard__label">{{ doneCardLabel }}</div>
</div>
<div class="cip-rcard" :class="store.state.failed ? 'cip-rcard--fail' : 'cip-rcard--muted'">
<Icon icon="vuesax-linear:close-circle" :size="20" />
@@ -121,7 +119,7 @@
<span class="cip-mini__text">
<span class="cip-mini__title">
<template v-if="store.finished">{{ outcomeInfo.text }}</template>
<template v-else>ارسال سند به ته‌حساب</template>
<template v-else>{{ runningMiniTitle }}</template>
</span>
<span class="cip-mini__count">
<template v-if="store.finished">
@@ -145,21 +143,71 @@ import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
const store = useTahesabDocSyncStore()
const OUTCOME_MAP = {
success: { icon: 'vuesax-linear:tick-circle', text: 'ارسال سندها تمام شد', tone: 'ok' as const },
error: { icon: 'vuesax-linear:close-circle', text: 'ارسال سندها ناموفق بود', tone: 'fail' as const },
partial: { icon: 'vuesax-linear:danger', text: 'بعضی سندها به خطا خورد', tone: 'warn' as const },
canceled: { icon: 'vuesax-linear:info-circle', text: 'ارسال متوقف شد', tone: 'warn' as const },
running: { icon: 'vuesax-linear:document-upload', text: 'ارسال سند به ته‌حساب', tone: 'ok' as const },
const stage = computed(() => store.stage)
function outcomeMap() {
if (stage.value === 'customers_in') {
return {
success: { icon: 'vuesax-linear:tick-circle', text: 'ورود مشتریان تمام شد', tone: 'ok' as const },
error: { icon: 'vuesax-linear:close-circle', text: 'ورود مشتریان ناموفق بود', tone: 'fail' as const },
partial: { icon: 'vuesax-linear:danger', text: 'بعضی مشتریان وارد نشدند', tone: 'warn' as const },
canceled: { icon: 'vuesax-linear:info-circle', text: 'ورود متوقف شد', tone: 'warn' as const },
running: { icon: 'vuesax-linear:import', text: 'ورود از ته‌حساب', tone: 'ok' as const },
}
}
if (stage.value === 'customers_out') {
return {
success: { icon: 'vuesax-linear:tick-circle', text: 'ثبت مشتریان تمام شد', tone: 'ok' as const },
error: { icon: 'vuesax-linear:close-circle', text: 'ثبت مشتریان ناموفق بود', tone: 'fail' as const },
partial: { icon: 'vuesax-linear:danger', text: 'بعضی مشتریان ثبت نشدند', tone: 'warn' as const },
canceled: { icon: 'vuesax-linear:info-circle', text: 'ثبت متوقف شد', tone: 'warn' as const },
running: { icon: 'vuesax-linear:profile-2user', text: 'ثبت در ته‌حساب', tone: 'ok' as const },
}
}
return {
success: { icon: 'vuesax-linear:tick-circle', text: 'ارسال سندها تمام شد', tone: 'ok' as const },
error: { icon: 'vuesax-linear:close-circle', text: 'ارسال سندها ناموفق بود', tone: 'fail' as const },
partial: { icon: 'vuesax-linear:danger', text: 'بعضی سندها به خطا خورد', tone: 'warn' as const },
canceled: { icon: 'vuesax-linear:info-circle', text: 'ارسال متوقف شد', tone: 'warn' as const },
running: { icon: 'vuesax-linear:document-upload', text: 'ارسال سند به ته‌حساب', tone: 'ok' as const },
}
}
const outcomeInfo = computed(() => OUTCOME_MAP[store.outcome])
const headIcon = computed(() =>
store.finished ? outcomeInfo.value.icon : 'vuesax-linear:document-upload',
)
const headTitle = computed(() =>
store.finished ? 'گزارش ارسال سندها' : 'ارسال سندها به ته‌حساب',
)
const outcomeInfo = computed(() => outcomeMap()[store.outcome])
const runningIcon = computed(() => {
if (stage.value === 'customers_in') return 'vuesax-linear:import'
if (stage.value === 'customers_out') return 'vuesax-linear:profile-2user'
return 'vuesax-linear:document-upload'
})
const headIcon = computed(() => (store.finished ? outcomeInfo.value.icon : runningIcon.value))
const headTitle = computed(() => {
if (store.finished) {
if (stage.value === 'customers_in') return 'گزارش ورود مشتریان'
if (stage.value === 'customers_out') return 'گزارش ثبت مشتریان'
return 'گزارش ارسال سندها'
}
if (stage.value === 'customers_in') return 'ورود مشتریان از ته‌حساب'
if (stage.value === 'customers_out') return 'ثبت مشتریان در ته‌حساب'
return 'ارسال سندها به ته‌حساب'
})
const runningLabel = computed(() => {
if (stage.value === 'customers_in') return 'در حال وارد کردن مشتریان از ته‌حساب…'
if (stage.value === 'customers_out') return 'در حال ثبت مشتریان پنل در ته‌حساب…'
return 'در حال ارسال سندها به ته‌حساب…'
})
const runningMiniTitle = computed(() => {
if (stage.value === 'customers_in') return 'ورود از ته‌حساب'
if (stage.value === 'customers_out') return 'ثبت مشتری در ته‌حساب'
return 'ارسال سند به ته‌حساب'
})
const unit = computed(() => (stage.value === 'docs' ? 'سند' : 'مشتری'))
const doneCardLabel = computed(() => (stage.value === 'docs' ? 'ارسال شد' : 'ثبت شد'))
const reportSubtitle = computed(() => {
const docs = store.state.doc_total
? `سندها: ${store.state.doc_success} موفق` + (store.state.doc_failed ? `، ${store.state.doc_failed} خطا. ` : '. ')
: ''
return `${docs}از ${store.state.total} ${unit.value}، ${store.state.processed} مورد پردازش شد`
})
const RING_CIRC = 2 * Math.PI * 15.5
const ringStyle = computed(() => ({
strokeDasharray: `${RING_CIRC}`,
@@ -105,8 +105,8 @@
<a-button
type="primary"
size="small"
:loading="syncingPanel"
:disabled="!status?.panel_only"
:loading="docSyncStore.starting || (docSyncStore.active && docSyncStore.stage === 'customers_out')"
:disabled="!status?.panel_only && !docSyncStore.active"
@click="syncAllPanel"
>
<template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template>
@@ -243,11 +243,15 @@
</a-button>
<a-button
size="small"
:loading="importStore.active"
:disabled="!importableCount"
:loading="docSyncStore.starting || (docSyncStore.active && docSyncStore.stage === 'customers_in')"
:disabled="!importableCount && !docSyncStore.active"
@click="importAll"
>
{{ importStore.active ? 'در حال…' : `وارد کردن همه (${importableCount})` }}
{{
docSyncStore.active && docSyncStore.stage === 'customers_in'
? 'در حال…'
: `وارد کردن همه (${importableCount})`
}}
</a-button>
</div>
</div>
@@ -364,7 +368,6 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { toJalali } from '@core/utils/useDataTime'
import {
getCustomerSyncStatus,
syncPanelToTahesab,
importFromTahesab,
registerCustomerInTahesab,
linkTahesabMatchingPhones,
@@ -374,14 +377,12 @@ import {
type TahesabOnlyCustomer,
type SyncPanelResult,
} from '@/api/tahesab'
import { useCustomerImportStore } from '@/store/customerImportStore'
import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
import TahesabDocSyncBox from '@/pages/menuPages/accounting/components/TahesabDocSyncBox.vue'
import { accountingTahesabCustomerSyncIntro } from '@/utils/adminHelpCopy'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
const importStore = useCustomerImportStore()
const docSyncStore = useTahesabDocSyncStore()
// ─── state ───
@@ -600,18 +601,15 @@ async function registerOnePanel(customer: PanelOnlyCustomer) {
}
async function syncAllPanel() {
syncingPanel.value = true
panelSyncResult.value = null
showPanelErrors.value = false
if (docSyncStore.active) {
docSyncStore.expand()
return
}
try {
const res = await syncPanelToTahesab()
panelSyncResult.value = res
if (res.success) message.success(`${res.success} مشتری ثبت شد`)
if (res.failed) message.warning(`${res.failed} مشتری ثبت نشد`)
await load(1)
panelDone.clear()
} catch { message.error('خطا در ارتباط با سرور') }
finally { syncingPanel.value = false }
await docSyncStore.startOutbound()
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || 'شروع ثبت ناموفق بود')
}
}
// ─── تب ۲: عملیات ───
@@ -703,11 +701,15 @@ async function importSelected() {
async function importAll() {
const pending = importableList.value
if (!pending.length) return
if (importStore.active) {
importStore.expand()
if (docSyncStore.active) {
docSyncStore.expand()
return
}
confirmImport(pending, () => importStore.start(pending, 'tahesab'))
confirmImport(pending, () => {
void docSyncStore.startInbound().catch((e: any) => {
message.error(e?.response?.data?.message || e?.message || 'شروع ورود ناموفق بود')
})
})
}
async function confirmLinkPhones() {
@@ -771,26 +773,21 @@ async function enrichPageMande() {
watch([tahesabPage, tahesabSearch], () => { void enrichPageMande() })
// با پیشرفت عملیات سراسری، ردیف‌های وارد شده را همین‌جا هم تیک بزن
watch(() => importStore.doneCodes.length, () => {
importStore.doneCodes.forEach(code => tahesabDone.add(code))
})
// پس از پایان کامل، آمار و لیست را تازه کن
watch(() => importStore.status, async (s, prev) => {
if (prev === 'running' && (s === 'done' || s === 'canceled')) {
selectedTahesab.clear()
if (status.value?.tahesab_only_count != null) {
status.value.tahesab_only_count = Math.max(0, status.value.tahesab_only_count - importStore.success)
watch(
() => docSyncStore.finished,
async (done, wasDone) => {
if (!done || wasDone) return
await load(1)
panelDone.clear()
if (docSyncStore.stage === 'customers_in') {
tahesabFetched.value = false
if (activeTab.value === 'tahesab') await loadTahesab()
}
await loadPanel(panelPagination.value.page)
}
})
},
)
onMounted(() => {
load(1)
// اگر عملیات سراسری در حال اجراست، ردیف‌های انجام‌شده را همگام کن
importStore.doneCodes.forEach(code => tahesabDone.add(code))
})
</script>
@@ -6,7 +6,7 @@
<p class="m-0 text-sm font-800 text-gray-800">ارسال سندها به ته‌حساب</p>
<p class="mb-0 mt-1 text-xs leading-relaxed text-gray-500">
با یک کلیک همهٔ سفارش‌ها و شارژهای جاافتاده یکی‌یکی و با فاصله به ته‌حساب می‌روند.
سندهایی که قبلاً رفته‌اند دوباره ارسال نمی‌شوند؛ می‌توانید پیشرفت را کوچک کنید.
بعد از سندها، مشتریان پنلِ بدون کد هم همان‌جا ثبت می‌شوند. پیشرفت را می‌توانید کوچک کنید.
</p>
</div>
<a-button
+71 -6
View File
@@ -3,19 +3,23 @@ import { defineStore } from 'pinia'
import {
getTahesabPendingSummary,
startTahesabDocSync,
startTahesabCustomerInbound,
startTahesabCustomerOutbound,
stopTahesabDocSync,
type TahesabDocSyncState,
} from '@/api/tahesab'
export type DocSyncOutcome = 'running' | 'success' | 'error' | 'partial' | 'canceled'
const POLL_MS = 10_000
const POLL_DOCS_MS = 10_000
const POLL_CUSTOMERS_MS = 3_000
function emptyState(): TahesabDocSyncState {
return {
running: false,
finished: false,
stopped: false,
stage: 'docs',
percent: 0,
total: 0,
processed: 0,
@@ -23,6 +27,9 @@ function emptyState(): TahesabDocSyncState {
failed: 0,
current: '',
errors: [],
doc_total: 0,
doc_success: 0,
doc_failed: 0,
}
}
@@ -37,6 +44,7 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
const active = computed(() => state.value.running)
const finished = computed(() => state.value.finished && !state.value.running)
const percent = computed(() => state.value.percent)
const stage = computed(() => state.value.stage || 'docs')
const outcome = computed<DocSyncOutcome>(() => {
if (state.value.running) return 'running'
if (state.value.stopped) return 'canceled'
@@ -45,13 +53,18 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
return 'partial'
})
function pollMs() {
const s = state.value.stage
return s === 'customers_out' || s === 'customers_in' ? POLL_CUSTOMERS_MS : POLL_DOCS_MS
}
function apply(snapshot?: TahesabDocSyncState | null) {
if (!snapshot) return
const wasVisible = visible.value
state.value = { ...emptyState(), ...snapshot }
if (snapshot.running) {
visible.value = true
startPoll()
restartPoll()
return
}
stopPoll()
@@ -64,7 +77,12 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
if (timer) return
timer = setInterval(() => {
void hydrate()
}, POLL_MS)
}, pollMs())
}
function restartPoll() {
stopPoll()
startPoll()
}
function stopPoll() {
@@ -74,12 +92,16 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
}
}
async function hydrate() {
async function hydrate(retries = 2) {
try {
const summary = await getTahesabPendingSummary()
apply(summary.doc_sync)
} catch {
/* ignore */
if (retries > 0) {
window.setTimeout(() => {
void hydrate(retries - 1)
}, 1500)
}
}
}
@@ -88,7 +110,7 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
if (state.value.running) {
visible.value = true
minimized.value = false
startPoll()
restartPoll()
return
}
starting.value = true
@@ -103,6 +125,46 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
}
}
async function startOutbound() {
if (starting.value) return
if (state.value.running) {
visible.value = true
minimized.value = false
restartPoll()
return
}
starting.value = true
try {
const res = await startTahesabCustomerOutbound()
message.value = res.message || ''
apply(res.doc_sync)
visible.value = true
minimized.value = false
} finally {
starting.value = false
}
}
async function startInbound() {
if (starting.value) return
if (state.value.running) {
visible.value = true
minimized.value = false
restartPoll()
return
}
starting.value = true
try {
const res = await startTahesabCustomerInbound()
message.value = res.message || ''
apply(res.doc_sync)
visible.value = true
minimized.value = false
} finally {
starting.value = false
}
}
async function stop() {
const res = await stopTahesabDocSync()
message.value = res.message || ''
@@ -135,10 +197,13 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
active,
finished,
percent,
stage,
outcome,
apply,
hydrate,
start,
startOutbound,
startInbound,
stop,
minimize,
expand,