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