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
+69
View File
@@ -131,3 +131,72 @@ export const importFromTahesab = async (customers: TahesabOnlyCustomer[]) =>
await useAxios.post<SyncPanelResult>('/admin/tahesab/customer-sync/import-from-tahesab', {
customers,
})
export interface TahesabWalletSyncResult {
ok: boolean
needs_decision?: boolean
message: string
result?: {
applied: boolean
needs_decision?: boolean
skipped?: boolean
gold_delta?: number
rial_delta?: number
app_gold?: number
app_cash?: number
tahesab_gold?: number
tahesab_cash?: number
message: string
}
}
/** همگام‌سازی مانده یک مشتری با ته‌حساب */
export const syncCustomerWalletFromTahesab = async (
customerId: number,
opts?: { overwrite?: boolean; keep?: boolean; ask_if_conflict?: boolean }
) =>
await useAxios.post<TahesabWalletSyncResult>(
`/admin/customer/${customerId}/tahesab/sync-wallet`,
{
overwrite: opts?.overwrite ?? false,
keep: opts?.keep ?? false,
ask_if_conflict: opts?.ask_if_conflict ?? true,
}
)
export interface TahesabBulkWalletSyncChunk {
ok: boolean
message: string
total: number
offset: number
processed: number
next_offset: number
done: boolean
success: number
changed: number
needs_decision: number
failed: number
errors: { id: number; name: string; mobile?: string; error: string }[]
decisions: {
id: number
name: string
mobile?: string
app_gold?: number
app_cash?: number
tahesab_gold?: number
tahesab_cash?: number
message: string
}[]
}
/** یک دسته از همگام‌سازی ماندهٔ همهٔ مشتریان دارای کد ته‌حساب */
export const syncAllWalletsFromTahesabChunk = async (params?: {
offset?: number
limit?: number
overwrite?: boolean
}) =>
await useAxios.post<TahesabBulkWalletSyncChunk>('/admin/tahesab/wallet-sync/all', {
offset: params?.offset ?? 0,
limit: params?.limit ?? 20,
overwrite: params?.overwrite ?? false,
})
@@ -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>
@@ -26,8 +26,24 @@
<p class="m-0 text-xs" style="color: var(--admin-text-secondary)">
برای پاک کردن کد، فیلد را خالی بگذارید و ذخیره کنید.
</p>
<p
v-if="provider === 'tahesab'"
class="m-0 text-xs"
style="color: var(--admin-text-secondary)"
>
بعد از ذخیره کد ته‌حساب، اگر موجودی اپ با مانده ته‌حساب فرق داشته باشد از شما تصمیم گرفته می‌شود.
</p>
</div>
</BasicModal>
<TahesabMandeDecisionModal
v-model:visible="decisionVisible"
:app-gold="decision?.app_gold"
:app-cash="decision?.app_cash"
:tahesab-gold="decision?.tahesab_gold"
:tahesab-cash="decision?.tahesab_cash"
@decide="onDecide"
/>
</template>
<script setup lang="ts">
@@ -35,10 +51,15 @@ import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import BasicModal from '@/components/BasicModal.vue'
import { updateCustomer } from '@/api/customer'
import {
syncCustomerWalletFromTahesab,
type TahesabWalletSyncResult,
} from '@/api/tahesab'
import type { Customer } from '@/model/customer'
import type { AccountingProvider } from '@/utils/config'
import { displayAccountingCode, normalizeAccountingCode } from '@/utils/accountingCode'
import { isMobile } from '@core/utils/is'
import TahesabMandeDecisionModal from './TahesabMandeDecisionModal.vue'
const props = defineProps<{
visible: boolean
@@ -53,6 +74,9 @@ const emit = defineEmits<{
const loading = ref(false)
const code = ref('')
const decisionVisible = ref(false)
const decision = ref<TahesabWalletSyncResult['result'] | null>(null)
const savedCustomerId = ref<number | null>(null)
const visibleProxy = computed({
get: () => props.visible,
@@ -79,6 +103,50 @@ function close() {
visibleProxy.value = false
}
async function maybeSyncTahesabWallet(customerId: number, previousCode: string, nextCode: string) {
if (props.provider !== 'tahesab') return
if (!nextCode || nextCode === previousCode) return
try {
const res = await syncCustomerWalletFromTahesab(customerId, { ask_if_conflict: true })
if (res.needs_decision || res.result?.needs_decision) {
decision.value = res.result ?? null
savedCustomerId.value = customerId
decisionVisible.value = true
return
}
if (res.result?.applied) {
message.success(res.message || 'مانده ته‌حساب روی کیف اعمال شد')
} else if (res.message) {
message.info(res.message)
}
} catch (e) {
// خطای همگام‌سازی مانع ذخیره کد نیست
console.error(e)
}
}
async function onDecide(mode: 'overwrite' | 'keep') {
const customerId = savedCustomerId.value
decisionVisible.value = false
if (!customerId) return
try {
const res = await syncCustomerWalletFromTahesab(customerId, {
overwrite: mode === 'overwrite',
keep: mode === 'keep',
ask_if_conflict: false,
})
if (mode === 'keep') {
message.info(res.message || 'موجودی اپ حفظ شد')
} else {
message.success(res.message || 'مانده ته‌حساب روی کیف اعمال شد')
}
emit('success')
} catch (e) {
console.error(e)
}
}
async function submit() {
if (!props.customer?.id) return
if (props.provider !== 'pargar' && props.provider !== 'tahesab' && props.provider !== 'zarin') {
@@ -86,6 +154,7 @@ async function submit() {
return
}
const previousCode = normalizeAccountingCode(currentCodeOf(props.customer))
const normalized = normalizeAccountingCode(code.value)
const payload =
props.provider === 'pargar'
@@ -98,6 +167,7 @@ async function submit() {
message.success('کد حسابداری ذخیره شد.')
emit('success')
close()
await maybeSyncTahesabWallet(props.customer.id, previousCode, normalized)
} catch (e) {
console.error(e)
} finally {
@@ -0,0 +1,97 @@
<template>
<BasicModal
v-model:visible="visibleProxy"
:width="isMobile ? '92vw' : '28rem'"
title="تصمیم برای مانده حساب"
:custom-footer="true"
cancel-text="بعداً"
@close="close"
>
<div class="flex flex-col gap-3 text-sm" style="color: var(--admin-text)">
<p class="m-0" style="color: var(--admin-text-secondary)">
موجودی اپ با مانده ته‌حساب فرق دارد. کدام را اعمال کنیم؟
</p>
<div class="rounded-lg border border-base p-3 space-y-2" style="background: var(--admin-bg-muted)">
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">موجودی اپ (طلا)</span>
<strong dir="ltr">{{ formatGold(appGold) }}</strong>
</div>
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">مانده ته‌حساب (طلا)</span>
<strong dir="ltr">{{ formatGold(tahesabGold) }}</strong>
</div>
<div class="h-px" style="background: var(--admin-border)" />
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">موجودی اپ (ریال)</span>
<strong dir="ltr">{{ formatCash(appCash) }}</strong>
</div>
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">مانده ته‌حساب (ریال)</span>
<strong dir="ltr">{{ formatCash(tahesabCash) }}</strong>
</div>
</div>
<div class="flex flex-col gap-2">
<a-button type="primary" block :loading="loading === 'overwrite'" :disabled="!!loading" @click="choose('overwrite')">
اعمال مانده ته‌حساب روی کیف
</a-button>
<a-button block :loading="loading === 'keep'" :disabled="!!loading" @click="choose('keep')">
نگه داشتن موجودی فعلی اپ
</a-button>
<a-button type="link" block :disabled="!!loading" @click="close">بعداً</a-button>
</div>
</div>
<template #footer><span /></template>
</BasicModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import BasicModal from '@/components/BasicModal.vue'
import { isMobile } from '@core/utils/is'
const props = defineProps<{
visible: boolean
appGold?: number | null
appCash?: number | null
tahesabGold?: number | null
tahesabCash?: number | null
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'decide', mode: 'overwrite' | 'keep'): void
}>()
const loading = ref<'overwrite' | 'keep' | null>(null)
const visibleProxy = computed({
get: () => props.visible,
set: (value: boolean) => emit('update:visible', value),
})
watch(
() => props.visible,
(v) => {
if (!v) loading.value = null
}
)
function formatGold(v?: number | null) {
if (v == null || Number.isNaN(Number(v))) return '—'
return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 })
}
function formatCash(v?: number | null) {
if (v == null || Number.isNaN(Number(v))) return '—'
return Math.round(Number(v)).toLocaleString('fa-IR')
}
function close() {
visibleProxy.value = false
}
function choose(mode: 'overwrite' | 'keep') {
loading.value = mode
emit('decide', mode)
}
</script>
@@ -0,0 +1,72 @@
<template>
<div class="tahesab-wallet-sync">
<div class="tahesab-wallet-sync__head">
<div class="tahesab-wallet-sync__icon" aria-hidden="true">
<Icon icon="vuesax-linear:refresh-circle" :size="18" color="var(--admin-primary, #10913A)" />
</div>
<div class="tahesab-wallet-sync__copy min-w-0 flex-1">
<p class="tahesab-wallet-sync__title">همگام‌سازی با ته‌حساب</p>
<p class="tahesab-wallet-sync__hint">
{{
canSync
? 'مانده طلا و ریال ته‌حساب را با کیف اپ مقایسه می‌کند؛ اگر تداخل باشد شما تصمیم می‌گیرید'
: 'ابتدا کد حسابداری ته‌حساب را برای این مشتری ثبت کنید'
}}
</p>
</div>
</div>
<a-button
type="primary"
block
size="small"
class="tahesab-wallet-sync__btn"
:loading="loading"
:disabled="!canSync || loading"
@click="$emit('sync', 'ask')"
>
بررسی و همگام‌سازی مانده
</a-button>
</div>
</template>
<script setup lang="ts">
import Icon from '@core/components/BaseIcon/src/Icon.vue'
defineProps<{
loading?: boolean
canSync?: boolean
}>()
defineEmits<{
(e: 'sync', mode: 'ask' | 'overwrite' | 'keep'): void
}>()
</script>
<style scoped>
.tahesab-wallet-sync {
@apply mb-2.5 rounded-xl border border-base p-2.5;
background: var(--admin-surface-muted, var(--admin-bg-muted, #f8fafc));
}
.tahesab-wallet-sync__head {
@apply mb-2.5 flex items-start gap-2;
}
.tahesab-wallet-sync__icon {
@apply flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50;
}
.tahesab-wallet-sync__title {
@apply m-0 text-sm font-700;
color: var(--admin-text, inherit);
}
.tahesab-wallet-sync__hint {
@apply m-0 mt-0.5 text-xs leading-5;
color: var(--admin-text-secondary, #6b7280);
}
.tahesab-wallet-sync__btn {
@apply font-600;
}
</style>
@@ -197,6 +197,12 @@
:can-sync="pargarWalletCanSync"
@sync="syncPargarWallet"
/>
<TahesabWalletSyncAction
v-if="tahesabWalletSyncVisible"
:loading="tahesabSyncLoading"
:can-sync="tahesabWalletCanSync"
@sync="syncTahesabWallet"
/>
<CustomerWalletBalancePanel
:key="`wallet-mobile-${walletPanelKey}`"
ref="walletPanelMobile"
@@ -218,6 +224,12 @@
:can-sync="pargarWalletCanSync"
@sync="syncPargarWallet"
/>
<TahesabWalletSyncAction
v-if="tahesabWalletSyncVisible"
:loading="tahesabSyncLoading"
:can-sync="tahesabWalletCanSync"
@sync="syncTahesabWallet"
/>
<CustomerWalletBalancePanel
:key="`wallet-desktop-${walletPanelKey}`"
ref="walletPanelDesktop"
@@ -275,6 +287,14 @@
:customer-id="chargeCustomerId"
@success="onWalletChargeSuccess"
/>
<TahesabMandeDecisionModal
v-model:visible="tahesabDecisionVisible"
:app-gold="tahesabDecision?.app_gold"
:app-cash="tahesabDecision?.app_cash"
:tahesab-gold="tahesabDecision?.tahesab_gold"
:tahesab-cash="tahesabDecision?.tahesab_cash"
@decide="onTahesabMandeDecide"
/>
<BasicInfoModal
v-model:visible="userInfoOrderVisible"
@close="userInfoOrderVisible = false"
@@ -338,7 +358,13 @@ import {
import { displayAccountingCode, isValidAccountingCode } from '@/utils/accountingCode'
import CustomerPresenceBadge from '@/pages/menuPages/customers/base/components/CustomerPresenceBadge.vue'
import { syncCustomerWalletFromPargar, type PargarSyncWalletResult } from '@/api/pargar'
import {
syncCustomerWalletFromTahesab,
type TahesabWalletSyncResult,
} from '@/api/tahesab'
import PargarWalletSyncAction from './components/PargarWalletSyncAction.vue'
import TahesabWalletSyncAction from './components/TahesabWalletSyncAction.vue'
import TahesabMandeDecisionModal from './components/TahesabMandeDecisionModal.vue'
const fnameHelp: DashboardHelp = {
description: 'نام کوچک مشتری که در اپ و پنل نمایش داده می‌شود.',
@@ -734,6 +760,9 @@ const accountingCodeValue = computed(() => {
})
const pargarSyncLoading = ref(false)
const tahesabSyncLoading = ref(false)
const tahesabDecisionVisible = ref(false)
const tahesabDecision = ref<TahesabWalletSyncResult['result'] | null>(null)
const walletPanelDesktop = ref<{ reload?: () => void | Promise<void> } | null>(null)
const walletPanelMobile = ref<{ reload?: () => void | Promise<void> } | null>(null)
const walletPanelKey = ref(0)
@@ -743,6 +772,10 @@ const pargarWalletSyncVisible = computed(
() => accountingProviderSetting.value === 'pargar'
)
const tahesabWalletSyncVisible = computed(
() => accountingProviderSetting.value === 'tahesab'
)
const tahesabDocsVisible = computed(() => accountingProviderSetting.value === 'tahesab')
const zarinDocsVisible = computed(() => accountingProviderSetting.value === 'zarin')
@@ -750,6 +783,10 @@ const pargarWalletCanSync = computed(() =>
isValidAccountingCode(userInfo.value?.pargar_account_id)
)
const tahesabWalletCanSync = computed(() =>
isValidAccountingCode(userInfo.value?.moshtari_code)
)
async function refreshWalletPanels() {
await nextTick()
walletPanelKey.value += 1
@@ -760,6 +797,58 @@ async function refreshWalletPanels() {
])
}
function openTahesabDecision(result?: TahesabWalletSyncResult['result']) {
tahesabDecision.value = result ?? null
tahesabDecisionVisible.value = true
}
async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
if (!userInfo.value?.id || !tahesabWalletCanSync.value || tahesabSyncLoading.value) return
tahesabSyncLoading.value = true
try {
const res = await syncCustomerWalletFromTahesab(userInfo.value.id, {
ask_if_conflict: mode === 'ask',
overwrite: mode === 'overwrite',
keep: mode === 'keep',
})
if (res.needs_decision || res.result?.needs_decision) {
openTahesabDecision(res.result)
return
}
if (res.result?.applied || res.ok) {
message.success(res.message || 'مانده از ته‌حساب روی کیف اعمال شد')
} else {
message.info(res.message || 'تغییری برای اعمال نبود')
}
await refreshWalletPanels()
refreshTransactionTable()
refreshTable()
} catch (e: unknown) {
const data = (e as { response?: { data?: TahesabWalletSyncResult } })?.response?.data
const status = (e as { response?: { status?: number } })?.response?.status
await refreshWalletPanels()
refreshTransactionTable()
refreshTable()
if (data?.result?.applied || data?.ok) {
message.success(data?.message || 'مانده از ته‌حساب روی کیف اعمال شد')
} else if (status !== 422) {
message.warning(data?.message || 'اعمال مانده از ته‌حساب ناموفق بود')
}
} finally {
tahesabSyncLoading.value = false
}
}
async function onTahesabMandeDecide(mode: 'overwrite' | 'keep') {
tahesabDecisionVisible.value = false
await syncTahesabWallet(mode)
}
async function syncPargarWallet(force = false) {
if (!userInfo.value?.id || !pargarWalletCanSync.value || pargarSyncLoading.value) return
pargarSyncLoading.value = true