Sync Tahesab wallet balance on import and let admins resolve conflicts.

Add customer search on the Tahesab-only list, bulk/per-customer mande sync UI, and a decision modal when app balance differs from Tahesab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:16:36 +03:30
co-authored by Cursor
parent 084b449177
commit 04cc03fe25
7 changed files with 761 additions and 8 deletions
@@ -1,6 +1,8 @@
<template>
<div class="cs-wrap">
<TahesabWalletBulkSync />
<ColleagueAccountingAccounts provider-only="tahesab" />
<!-- ─── بنر آمار ─── -->
@@ -195,6 +197,14 @@
<span class="cs-card__head-title">مشتریان ته‌حساب بدون حساب پنل</span>
</div>
<div class="cs-card__head-actions">
<a-input
v-model:value="tahesabSearch"
placeholder="جستجو نام یا شماره..."
allow-clear
size="small"
class="cs-search"
@change="onTahesabSearch"
/>
<a-button
type="primary"
size="small"
@@ -208,7 +218,7 @@
<a-button
size="small"
:loading="importStore.active"
:disabled="!tahesabOnlyList.length"
:disabled="!filteredTahesabList.length"
@click="importAll"
>
{{ importStore.active ? 'در حال وارد کردن…' : `وارد کردن همه (${status?.tahesab_only_count ?? 0})` }}
@@ -235,7 +245,7 @@
</div>
<div v-if="loadingTahesab" class="cs-loading"><a-spin /></div>
<template v-else-if="tahesabOnlyList.length">
<template v-else-if="filteredTahesabList.length">
<!-- موبایل -->
<div class="cs-mobile-list">
<div
@@ -294,7 +304,7 @@
<div v-if="tahesabLastPage > 1" class="cs-pagination">
<a-pagination
v-model:current="tahesabPage"
:total="tahesabOnlyList.length"
:total="filteredTahesabList.length"
:page-size="20" size="small" show-less-items
@change="p => { tahesabPage = p; selectedTahesab.clear() }"
/>
@@ -302,7 +312,9 @@
</template>
<div v-else class="cs-empty">
<div class="cs-empty__icon"><Icon icon="vuesax-linear:tick-circle" :size="28" /></div>
<div class="cs-empty__title">همه مشتریان ته‌حساب در پنل ثبت شده‌اند</div>
<div class="cs-empty__title">
{{ tahesabSearch.trim() ? 'نتیجه‌ای برای این جستجو پیدا نشد' : 'همه مشتریان ته‌حساب در پنل ثبت شده‌اند' }}
</div>
</div>
</div>
@@ -326,6 +338,7 @@ import {
} from '@/api/tahesab'
import { useCustomerImportStore } from '@/store/customerImportStore'
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
import TahesabWalletBulkSync from '@/pages/menuPages/accounting/components/TahesabWalletBulkSync.vue'
const importStore = useCustomerImportStore()
@@ -350,6 +363,7 @@ const showPanelErrors = ref(false)
// تب ۲
const tahesabOnlyList = ref<TahesabOnlyCustomer[]>([])
const tahesabSearch = ref('')
const tahesabPage = ref(1)
const tahesabDone = reactive(new Set<number>())
const selectedTahesab = reactive(new Set<number>())
@@ -362,11 +376,35 @@ const registeredPercent = computed(() => {
return Math.round((status.value.panel_registered / status.value.panel_total) * 100)
})
const tahesabLastPage = computed(() => Math.max(1, Math.ceil(tahesabOnlyList.value.length / 20)))
const filteredTahesabList = computed(() => {
const raw = tahesabSearch.value.trim()
if (!raw) return tahesabOnlyList.value
const q = normalizeSearchText(raw)
const qDigits = q.replace(/\D/g, '')
return tahesabOnlyList.value.filter((c) => {
const name = normalizeSearchText(c.name || '')
const tel = normalizeSearchText(c.tel || '')
const code = String(c.code ?? '')
if (name.includes(q) || tel.includes(q) || code.includes(q)) return true
if (qDigits && tel.replace(/\D/g, '').includes(qDigits)) return true
if (qDigits && code.includes(qDigits)) return true
return false
})
})
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
}
const tahesabLastPage = computed(() => Math.max(1, Math.ceil(filteredTahesabList.value.length / 20)))
const pagedTahesab = computed(() => {
const s = (tahesabPage.value - 1) * 20
return tahesabOnlyList.value.slice(s, s + 20)
return filteredTahesabList.value.slice(s, s + 20)
})
const allPageSelected = computed(() =>
@@ -452,6 +490,11 @@ function onPanelSearch() {
panelSearchTimer = setTimeout(() => loadPanel(1), 400)
}
function onTahesabSearch() {
tahesabPage.value = 1
selectedTahesab.clear()
}
// ─── تب ۱: عملیات ───
async function registerOnePanel(customer: PanelOnlyCustomer) {
panelRegisteringId.value = customer.id
@@ -519,11 +562,11 @@ async function doImport(customers: TahesabOnlyCustomer[]) {
}
async function importOne(customer: TahesabOnlyCustomer) { await doImport([customer]) }
async function importSelected() { await doImport(tahesabOnlyList.value.filter(c => selectedTahesab.has(c.code))) }
async function importSelected() { await doImport(filteredTahesabList.value.filter(c => selectedTahesab.has(c.code))) }
// «وارد کردن همه» با پروگرس‌بار سراسری و قابل مینیمایز (پس‌زمینه‌ای)
async function importAll() {
const pending = tahesabOnlyList.value.filter(c => !tahesabDone.has(c.code))
const pending = filteredTahesabList.value.filter(c => !tahesabDone.has(c.code))
if (!pending.length) return
if (importStore.active) {
importStore.expand()
@@ -0,0 +1,313 @@
<template>
<div class="tw-bulk">
<div class="tw-bulk__head">
<div class="tw-bulk__icon" aria-hidden="true">
<Icon icon="vuesax-linear:refresh-circle" :size="20" color="var(--admin-primary, #10913A)" />
</div>
<div class="tw-bulk__copy min-w-0 flex-1">
<p class="tw-bulk__title">همگام‌سازی ماندهٔ مشتریان لینک‌شده</p>
<p class="tw-bulk__hint">
موجودی کیف همهٔ مشتریان دارای کد ته‌حساب را با ماندهٔ ته‌حساب یکی می‌کند.
اگر مشتری در اپ مانده داشته باشد و با ته‌حساب فرق کند، در جزئیات نشان داده می‌شود تا جداگانه تصمیم بگیرید.
</p>
</div>
</div>
<div class="tw-bulk__actions">
<a-button
type="primary"
block
class="tw-bulk__btn"
:loading="running && !overwriteMode"
:disabled="running"
@click="confirmAndStart(false)"
>
همگام‌سازی مانده (بدون بازنویسی تداخل‌ها)
</a-button>
<a-button
block
danger
class="tw-bulk__btn"
:loading="running && overwriteMode"
:disabled="running"
@click="confirmAndStart(true)"
>
اعمال اجباری مانده ته‌حساب برای همه
</a-button>
</div>
<div v-if="running || resultVisible" class="tw-bulk__progress">
<template v-if="running">
<div class="tw-bulk__progress-row">
<a-spin size="small" />
<span>
در حال همگام‌سازی…
<template v-if="total > 0">{{ processed }} / {{ total }}</template>
</span>
</div>
<div class="tw-bulk__track">
<div class="tw-bulk__track-fill" :style="{ width: percent + '%' }" />
</div>
</template>
<template v-else-if="resultVisible">
<div class="tw-bulk__progress-row">
<span class="tw-bulk__ok">✓ {{ success }} موفق</span>
<span v-if="changed" class="tw-bulk__changed">{{ changed }} تغییر موجودی</span>
<span v-if="needsDecision" class="tw-bulk__decision">{{ needsDecision }} نیازمند تصمیم</span>
<span v-if="failed" class="tw-bulk__fail">✗ {{ failed }} ناموفق</span>
<a-button
v-if="errors.length || decisions.length"
type="link"
size="small"
@click="showDetails = !showDetails"
>
{{ showDetails ? 'پنهان' : 'جزئیات' }}
</a-button>
<a-button type="link" size="small" @click="dismissResult">بستن</a-button>
</div>
<div v-if="showDetails && decisions.length" class="tw-bulk__decisions">
<div class="tw-bulk__decisions-title">تداخل موجودی — از صفحه مشتری تصمیم بگیرید:</div>
<div v-for="d in decisions" :key="d.id">
<b>{{ d.name }}</b>
<template v-if="d.mobile"> ({{ d.mobile }})</template>
— اپ: {{ formatGold(d.app_gold) }} طلا / {{ formatCash(d.app_cash) }} ریال
· ته‌حساب: {{ formatGold(d.tahesab_gold) }} طلا / {{ formatCash(d.tahesab_cash) }} ریال
</div>
</div>
<div v-if="showDetails && errors.length" class="tw-bulk__errors">
<div v-for="e in errors" :key="e.id">
<b>{{ e.name }}</b>
<template v-if="e.mobile"> ({{ e.mobile }})</template>
— {{ e.error }}
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, createVNode, ref } from 'vue'
import { Modal, message } from 'ant-design-vue'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import {
syncAllWalletsFromTahesabChunk,
type TahesabBulkWalletSyncChunk,
} from '@/api/tahesab'
const CHUNK_LIMIT = 20
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const running = ref(false)
const overwriteMode = ref(false)
const resultVisible = ref(false)
const showDetails = ref(false)
const total = ref(0)
const processed = ref(0)
const success = ref(0)
const changed = ref(0)
const needsDecision = ref(0)
const failed = ref(0)
const errors = ref<TahesabBulkWalletSyncChunk['errors']>([])
const decisions = ref<TahesabBulkWalletSyncChunk['decisions']>([])
const percent = computed(() =>
total.value > 0 ? Math.min(100, Math.round((processed.value / total.value) * 100)) : 0,
)
function formatGold(v?: number) {
if (v == null || Number.isNaN(v)) return '—'
return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 })
}
function formatCash(v?: number) {
if (v == null || Number.isNaN(v)) return '—'
return Math.round(Number(v)).toLocaleString('fa-IR')
}
function dismissResult() {
resultVisible.value = false
showDetails.value = false
}
function confirmAndStart(overwrite: boolean) {
if (running.value) return
Modal.confirm({
title: overwrite
? 'اعمال اجباری مانده ته‌حساب برای همه؟'
: 'همگام‌سازی ماندهٔ همهٔ مشتریان؟',
icon: createVNode(ExclamationCircleOutlined),
content: overwrite
? 'موجودی کیف همهٔ مشتریان دارای کد ته‌حساب با ماندهٔ ته‌حساب جایگزین می‌شود — حتی اگر در اپ مانده داشته باشند.'
: 'برای مشتریانی که کیف خالی دارند مانده ته‌حساب می‌نشیند. اگر اپ مانده داشته باشد و فرق کند، در لیست «نیازمند تصمیم» می‌آید.',
okText: overwrite ? 'بله، برای همه اعمال کن' : 'شروع همگام‌سازی',
okType: overwrite ? 'danger' : 'primary',
cancelText: 'انصراف',
centered: true,
onOk: () => start(overwrite),
})
}
async function start(overwrite: boolean) {
running.value = true
overwriteMode.value = overwrite
resultVisible.value = false
showDetails.value = false
total.value = 0
processed.value = 0
success.value = 0
changed.value = 0
needsDecision.value = 0
failed.value = 0
errors.value = []
decisions.value = []
let offset = 0
let done = false
try {
while (!done) {
const res = await syncAllWalletsFromTahesabChunk({
offset,
limit: CHUNK_LIMIT,
overwrite,
})
total.value = res.total
success.value += res.success
changed.value += res.changed
needsDecision.value += res.needs_decision ?? 0
failed.value += res.failed
if (res.errors?.length) errors.value = [...errors.value, ...res.errors]
if (res.decisions?.length) decisions.value = [...decisions.value, ...res.decisions]
processed.value = Math.min(res.next_offset, res.total)
offset = res.next_offset
done = res.done || res.processed === 0 || res.total === 0
if (!done) await sleep(150)
}
resultVisible.value = true
if (failed.value === 0 && needsDecision.value === 0) {
message.success(
total.value === 0
? 'مشتری دارای کد ته‌حساب یافت نشد'
: `همگام‌سازی ${success.value} مشتری انجام شد`,
)
} else if (success.value > 0 || needsDecision.value > 0) {
message.warning(
`${success.value} موفق` +
(needsDecision.value ? ` — ${needsDecision.value} نیازمند تصمیم` : '') +
(failed.value ? ` — ${failed.value} ناموفق` : ''),
)
} else {
message.error('همگام‌سازی ناموفق بود')
}
} catch (e) {
console.error(e)
message.error('خطا در ارتباط با سرور')
resultVisible.value = processed.value > 0 || success.value > 0 || failed.value > 0
} finally {
running.value = false
overwriteMode.value = false
}
}
</script>
<style scoped>
.tw-bulk {
@apply rounded-xl border border-base p-3;
background: var(--admin-surface-muted, var(--admin-bg-muted, #f8fafc));
}
.tw-bulk__head {
@apply mb-3 flex items-start gap-2.5;
}
.tw-bulk__icon {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-50;
}
.tw-bulk__title {
@apply m-0 text-sm font-700;
color: var(--admin-text, inherit);
}
.tw-bulk__hint {
@apply m-0 mt-0.5 text-xs leading-5;
color: var(--admin-text-secondary, #6b7280);
}
.tw-bulk__actions {
@apply flex flex-col gap-2;
}
.tw-bulk__btn {
@apply font-600;
}
.tw-bulk__progress {
@apply mt-3 rounded-lg border border-base p-2.5 text-xs;
background: var(--admin-bg-surface, #fff);
color: var(--admin-text, inherit);
}
.tw-bulk__progress-row {
@apply flex flex-wrap items-center gap-2;
}
.tw-bulk__track {
@apply mt-2 h-1.5 overflow-hidden rounded-full;
background: var(--admin-border, #e5e7eb);
}
.tw-bulk__track-fill {
@apply h-full rounded-full transition-all duration-300;
background: var(--admin-primary, #10913A);
}
.tw-bulk__ok {
@apply rounded-full px-2.5 py-0.5 font-600;
background: rgba(52, 211, 153, 0.15);
color: #059669;
}
.tw-bulk__changed {
@apply rounded-full px-2.5 py-0.5 font-600;
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
}
.tw-bulk__decision {
@apply rounded-full px-2.5 py-0.5 font-600;
background: rgba(245, 158, 11, 0.15);
color: #d97706;
}
.tw-bulk__fail {
@apply rounded-full px-2.5 py-0.5 font-600;
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.tw-bulk__decisions {
@apply mt-2 max-h-36 overflow-y-auto rounded-lg p-2 text-xs leading-6;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
color: #b45309;
}
.tw-bulk__decisions-title {
@apply mb-1 font-700;
}
.tw-bulk__errors {
@apply mt-2 max-h-28 overflow-y-auto rounded-lg p-2 text-xs leading-6;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #dc2626;
}
</style>