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