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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user