feat(tahesab): three-way mande decision UI including push to Tahesab

Clarify overwrite vs keep vs push-to-tahesab, warn when app balance is zero, and update colleague wait-callback help text.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 22:30:58 +03:30
co-authored by Cursor
parent e0b89223ac
commit 9ef381f00e
6 changed files with 208 additions and 42 deletions
+8 -1
View File
@@ -254,13 +254,20 @@ export interface TahesabWalletSyncResult {
/** همگام‌سازی مانده یک مشتری با ته‌حساب */
export const syncCustomerWalletFromTahesab = async (
customerId: number,
opts?: { overwrite?: boolean; keep?: boolean; ask_if_conflict?: boolean; always_ask?: boolean }
opts?: {
overwrite?: boolean
keep?: boolean
push_to_tahesab?: boolean
ask_if_conflict?: boolean
always_ask?: boolean
}
) =>
await useAxios.post<TahesabWalletSyncResult>(
`/admin/customer/${customerId}/tahesab/sync-wallet`,
{
overwrite: opts?.overwrite ?? false,
keep: opts?.keep ?? false,
push_to_tahesab: opts?.push_to_tahesab ?? false,
ask_if_conflict: opts?.ask_if_conflict ?? true,
always_ask: opts?.always_ask ?? false,
}
@@ -7,7 +7,7 @@
<AdminFieldHelp :text="accountingTahesabBalanceSyncIntro" size="sm" />
</div>
<p class="tw-bulk__hint">
همگام تکی: اول سندها و اختلاف را می‌بینید، بعد تأیید می‌کنید. دو مانده جمع نمی‌شوند.
همگام تکی: اول سندها و اختلاف را می‌بینید، بعد یکی از سه مسیر را تأیید می‌کنید (ته‌حساب→اپ، اپ→ته‌حساب حتی اگر اپ صفر باشد، یا نگه داشتن). دو مانده جمع نمی‌شوند.
همگام همه فقط کیف خالی را می‌نشاند؛ تداخل برای تصمیم دستی می‌ماند.
</p>
</div>
@@ -154,6 +154,7 @@
<TahesabMandeDecisionModal
v-model:visible="decisionVisible"
source-label="ته‌حساب"
:allow-push="true"
:app-gold="decisionResult?.app_gold"
:app-cash="decisionResult?.app_cash"
:tahesab-gold="decisionResult?.tahesab_gold"
@@ -200,7 +201,7 @@ const failed = ref(0)
const errors = ref<TahesabBulkWalletSyncChunk['errors']>([])
const decisions = ref<TahesabBulkWalletSyncChunk['decisions']>([])
const decidingId = ref<number | null>(null)
const decidingMode = ref<'overwrite' | 'keep' | null>(null)
const decidingMode = ref<'overwrite' | 'keep' | 'push' | null>(null)
const search = ref('')
const page = ref(1)
@@ -209,7 +210,7 @@ const listTotal = ref(0)
const listLoading = ref(false)
const rows = ref<TahesabWalletLinkedRow[]>([])
const syncingId = ref<number | null>(null)
const syncingMode = ref<'ask' | 'overwrite' | 'keep' | null>(null)
const syncingMode = ref<'ask' | 'overwrite' | 'keep' | 'push' | null>(null)
const decisionVisible = ref(false)
const decisionCustomerId = ref<number | null>(null)
const decisionResult = ref<TahesabWalletSyncResult['result'] | null>(null)
@@ -321,7 +322,7 @@ async function openReview(customerId: number) {
}
}
async function onReviewDecide(mode: 'overwrite' | 'keep') {
async function onReviewDecide(mode: 'overwrite' | 'keep' | 'push') {
const customerId = decisionCustomerId.value
decisionVisible.value = false
if (!customerId) return
@@ -337,22 +338,25 @@ function dismissResult() {
showDetails.value = false
}
async function applyDecision(customerId: number, mode: 'overwrite' | 'keep') {
async function applyDecision(customerId: number, mode: 'overwrite' | 'keep' | 'push') {
decidingId.value = customerId
decidingMode.value = mode
try {
const res = await syncCustomerWalletFromTahesab(customerId, {
overwrite: mode === 'overwrite',
keep: mode === 'keep',
push_to_tahesab: mode === 'push',
ask_if_conflict: false,
})
decisions.value = decisions.value.filter(d => d.id !== customerId)
needsDecision.value = Math.max(0, decisions.value.length)
if (mode === 'keep') message.info(res.message || 'موجودی اپ حفظ شد')
else if (mode === 'push') message.success(res.message || 'مانده اپ روی ته‌حساب اعمال شد')
else message.success(res.message || 'مانده ته‌حساب اعمال شد')
await loadList()
} catch {
message.error('اعمال تصمیم ناموفق بود')
} catch (e: unknown) {
const data = (e as { response?: { data?: { message?: string } } })?.response?.data
message.error(data?.message || 'اعمال تصمیم ناموفق بود')
} finally {
decidingId.value = null
decidingMode.value = null
@@ -884,7 +884,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
montzar_open_sell:
'وضعیت واقعی از سرویس خارجی خوانده می‌شود. روشن: ثبت سفارش فروش از طریق قیمت همکار مجاز است. خاموش: ثبت سفارش فروش از طریق قیمت همکار غیرفعال است.',
montzar_order_wait_callback:
'روشن (پیش‌فرض): بعد از ارسال سفارش به همکار، تا کالبک وضعیت (تأیید=۲ / رد=۴ یا ۶) یا استعلام مسیر وضعیت صبر می‌شود و کیف ثبت نمی‌شود. خاموش: مثل قبل با همان پاسخ HTTP همکار سفارش قطعی می‌شود.',
'با منبع قیمت همکار، سفارش همیشه تا کالبک یا استعلام وضعیت در انتظار می‌ماند و کیف بعد از تأیید ثبت می‌شود. خاموش بودن این سوییچ دیگر مسیر قطعی بلافاصله را باز نمی‌کند. در رد همکار، SMS به ادمین‌های دارای ارسال پیامک می‌رود.',
montzar_order_status_path:
'مسیر GET روی دامنه همکار (montzar_base_url) برای استعلام وضعیت اگر کالبک نرسید. پیش‌فرض /server/customer/order — با شماره سفارش همکار می‌شود /server/customer/order/40000.',
montzar_closed:
@@ -39,6 +39,7 @@
<TahesabMandeDecisionModal
v-model:visible="decisionVisible"
:source-label="provider === 'zarin' ? 'زرین' : 'ته‌حساب'"
:allow-push="provider === 'tahesab'"
:app-gold="decision?.app_gold"
:app-cash="decision?.app_cash"
:tahesab-gold="decision?.tahesab_gold ?? decision?.zarin_gold"
@@ -120,8 +121,12 @@ async function saveCode(customerId: number, body: Record<string, string>) {
close()
}
async function applyWalletDecision(customerId: number, mode: 'overwrite' | 'keep') {
async function applyWalletDecision(customerId: number, mode: 'overwrite' | 'keep' | 'push') {
if (props.provider === 'zarin') {
if (mode === 'push') {
message.warning('اعمال مانده اپ روی زرین در این نسخه پشتیبانی نمی‌شود')
return { ok: false, message: 'پشتیبانی نمی‌شود' }
}
return syncCustomerWalletFromZarin(customerId, {
overwrite: mode === 'overwrite',
keep: mode === 'keep',
@@ -131,11 +136,12 @@ async function applyWalletDecision(customerId: number, mode: 'overwrite' | 'keep
return syncCustomerWalletFromTahesab(customerId, {
overwrite: mode === 'overwrite',
keep: mode === 'keep',
push_to_tahesab: mode === 'push',
ask_if_conflict: false,
})
}
async function onDecide(mode: 'overwrite' | 'keep') {
async function onDecide(mode: 'overwrite' | 'keep' | 'push') {
const pending = pendingPayload.value
decisionVisible.value = false
if (!pending) return
@@ -144,6 +150,7 @@ async function onDecide(mode: 'overwrite' | 'keep') {
await saveCode(pending.customerId, pending.body)
const res = await applyWalletDecision(pending.customerId, mode)
if (mode === 'keep') message.info(res.message || 'موجودی اپ حفظ شد')
else if (mode === 'push') message.success(res.message || 'مانده اپ روی ته‌حساب اعمال شد')
else message.success(res.message || 'مانده حسابداری روی کیف اعمال شد')
emit('success')
} catch (e) {
@@ -1,16 +1,24 @@
<template>
<BasicModal
v-model:visible="visibleProxy"
:width="isMobile ? '92vw' : '36rem'"
:width="isMobile ? '92vw' : '40rem'"
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)">
موجودی اپ با مانده {{ sourceLabel }} فرق دارد. دو مانده جمع نمی‌شوند؛ یکی را پس از بررسی سندها تأیید کنید.
<p class="m-0 leading-6" style="color: var(--admin-text-secondary)">
موجودی اپ با مانده {{ sourceLabel }} فرق دارد. دو مانده جمع نمی‌شوند — یکی از سه مسیر زیر را انتخاب کنید.
</p>
<div
v-if="appIsEmpty && allowPush"
class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-900"
>
موجودی اپ صفر است. اگر «اعمال مانده اپ روی ته‌حساب» را بزنید، مانده ته‌حساب با سند تعدیل هم‌تراز صفر اپ می‌شود.
</div>
<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>
@@ -84,12 +92,58 @@
</div>
<div class="flex flex-col gap-2">
<a-button type="primary" block :loading="loading === 'overwrite'" :disabled="!!loading" @click="choose('overwrite')">
اعمال مانده {{ sourceLabel }} روی کیف
</a-button>
<a-button block :loading="loading === 'keep'" :disabled="!!loading" @click="choose('keep')">
نگه داشتن موجودی فعلی اپ
</a-button>
<button
type="button"
class="mande-choice mande-choice--primary"
:disabled="!!loading"
@click="choose('overwrite')"
>
<span class="mande-choice__title">
<a-spin v-if="loading === 'overwrite'" size="small" class="me-1" />
اعمال مانده {{ sourceLabel }} روی کیف اپ
</span>
<span class="mande-choice__hint">
موجودی اپ مثل {{ sourceLabel }} می‌شود. سند تعدیل روی کیف اپ ثبت می‌شود.
</span>
</button>
<button
v-if="allowPush"
type="button"
class="mande-choice"
:class="appIsEmpty ? 'mande-choice--warn' : 'mande-choice--secondary'"
:disabled="!!loading"
@click="choose('push')"
>
<span class="mande-choice__title">
<a-spin v-if="loading === 'push'" size="small" class="me-1" />
اعمال مانده اپ روی {{ sourceLabel }}
<span v-if="appIsEmpty" class="mande-choice__badge">اپ صفر</span>
</span>
<span class="mande-choice__hint">
{{
appIsEmpty
? `مانده ${sourceLabel} با سند تعدیل هم‌تراز صفر اپ می‌شود. کیف اپ تغییر نمی‌کند.`
: `مانده ${sourceLabel} مثل موجودی اپ می‌شود. کیف اپ تغییر نمی‌کند.`
}}
</span>
</button>
<button
type="button"
class="mande-choice"
:disabled="!!loading"
@click="choose('keep')"
>
<span class="mande-choice__title">
<a-spin v-if="loading === 'keep'" size="small" class="me-1" />
نگه داشتن وضعیت فعلی
</span>
<span class="mande-choice__hint">
نه کیف اپ عوض می‌شود نه {{ sourceLabel }}؛ اختلاف برای بعد می‌ماند.
</span>
</button>
<a-button type="link" block :disabled="!!loading" @click="close">بعداً</a-button>
</div>
</div>
@@ -103,27 +157,34 @@ import BasicModal from '@/components/BasicModal.vue'
import { isMobile } from '@core/utils/is'
import type { TahesabPreviewDoc } from '@/api/tahesab'
const props = defineProps<{
visible: boolean
appGold?: number | null
appCash?: number | null
tahesabGold?: number | null
tahesabCash?: number | null
sourceGold?: number | null
sourceCash?: number | null
goldDelta?: number | null
rialDelta?: number | null
sourceLabel?: string
walletDocs?: TahesabPreviewDoc[]
tahesabDocs?: TahesabPreviewDoc[]
}>()
export type MandeDecisionMode = 'overwrite' | 'keep' | 'push'
const props = withDefaults(
defineProps<{
visible: boolean
appGold?: number | null
appCash?: number | null
tahesabGold?: number | null
tahesabCash?: number | null
sourceGold?: number | null
sourceCash?: number | null
goldDelta?: number | null
rialDelta?: number | null
sourceLabel?: string
/** فقط ته‌حساب: اجازهٔ نوشتن مانده اپ روی حسابداری */
allowPush?: boolean
walletDocs?: TahesabPreviewDoc[]
tahesabDocs?: TahesabPreviewDoc[]
}>(),
{ allowPush: false }
)
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'decide', mode: 'overwrite' | 'keep'): void
(e: 'decide', mode: MandeDecisionMode): void
}>()
const loading = ref<'overwrite' | 'keep' | null>(null)
const loading = ref<MandeDecisionMode | null>(null)
const sourceLabel = computed(() => props.sourceLabel || 'ته‌حساب')
const sourceGoldValue = computed(() => props.sourceGold ?? props.tahesabGold)
const sourceCashValue = computed(() => props.sourceCash ?? props.tahesabCash)
@@ -140,6 +201,11 @@ const sourceDocs = computed(() => props.tahesabDocs ?? [])
const showDocs = computed(
() => sourceLabel.value === 'ته‌حساب' || walletDocs.value.length > 0 || sourceDocs.value.length > 0
)
const appIsEmpty = computed(() => {
const g = Math.abs(Number(props.appGold ?? 0))
const c = Math.abs(Number(props.appCash ?? 0))
return g < 0.0005 && c < 1
})
const visibleProxy = computed({
get: () => props.visible,
@@ -186,8 +252,75 @@ function close() {
visibleProxy.value = false
}
function choose(mode: 'overwrite' | 'keep') {
function choose(mode: MandeDecisionMode) {
loading.value = mode
emit('decide', mode)
}
</script>
<style scoped>
.mande-choice {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
width: 100%;
text-align: right;
border-radius: 0.75rem;
border: 1px solid var(--admin-border, #e5e7eb);
background: var(--admin-surface, #fff);
padding: 0.75rem 0.875rem;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.mande-choice:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.mande-choice:not(:disabled):hover {
border-color: var(--admin-primary, #10913a);
}
.mande-choice--primary {
border-color: var(--admin-primary, #10913a);
background: color-mix(in srgb, var(--admin-primary, #10913a) 8%, white);
}
.mande-choice--secondary:not(:disabled):hover {
background: var(--admin-bg-muted, #f8fafc);
}
.mande-choice--warn {
border-color: #f59e0b;
background: #fffbeb;
}
.mande-choice__title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--admin-text, #111827);
}
.mande-choice__hint {
font-size: 0.75rem;
line-height: 1.5;
color: var(--admin-text-secondary, #6b7280);
}
.mande-choice__badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 0.6875rem;
font-weight: 700;
padding: 0.1rem 0.45rem;
}
</style>
+20 -5
View File
@@ -308,6 +308,7 @@
<TahesabMandeDecisionModal
v-model:visible="tahesabDecisionVisible"
:source-label="walletDecisionLabel"
:allow-push="walletDecisionProvider === 'tahesab'"
:app-gold="tahesabDecision?.app_gold"
:app-cash="tahesabDecision?.app_cash"
:tahesab-gold="tahesabDecision?.tahesab_gold ?? tahesabDecision?.zarin_gold"
@@ -855,7 +856,7 @@ function openTahesabDecision(result?: TahesabWalletSyncResult['result'], provide
tahesabDecisionVisible.value = true
}
async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' | 'push' = 'ask') {
if (!userInfo.value?.id || !tahesabWalletCanSync.value || tahesabSyncLoading.value) return
tahesabSyncLoading.value = true
try {
@@ -877,6 +878,7 @@ async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
ask_if_conflict: false,
overwrite: mode === 'overwrite',
keep: mode === 'keep',
push_to_tahesab: mode === 'push',
})
if (res.needs_decision || res.result?.needs_decision) {
@@ -885,7 +887,14 @@ async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
}
if (res.result?.applied || res.ok) {
message.success(res.message || 'مانده از ته‌حساب روی کیف اعمال شد')
message.success(
res.message ||
(mode === 'push'
? 'مانده اپ روی ته‌حساب اعمال شد'
: mode === 'keep'
? 'موجودی اپ حفظ شد'
: 'مانده از ته‌حساب روی کیف اعمال شد')
)
} else {
message.info(res.message || 'تغییری برای اعمال نبود')
}
@@ -902,18 +911,24 @@ async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
refreshTable()
if (data?.result?.applied || data?.ok) {
message.success(data?.message || 'مانده از ته‌حساب روی کیف اعمال شد')
message.success(data?.message || 'مانده با ته‌حساب هم‌تراز شد')
} else if (status !== 422) {
message.warning(data?.message || 'اعمال مانده از ته‌حساب ناموفق بود')
message.warning(data?.message || 'اعمال مانده با ته‌حساب ناموفق بود')
} else if (data?.message) {
message.warning(data.message)
}
} finally {
tahesabSyncLoading.value = false
}
}
async function onTahesabMandeDecide(mode: 'overwrite' | 'keep') {
async function onTahesabMandeDecide(mode: 'overwrite' | 'keep' | 'push') {
tahesabDecisionVisible.value = false
if (walletDecisionProvider.value === 'zarin') {
if (mode === 'push') {
message.warning('اعمال مانده اپ روی زرین در این نسخه پشتیبانی نمی‌شود')
return
}
await syncZarinWallet(mode)
return
}