fix accounting fails

This commit is contained in:
2026-08-27 16:15:11 +03:30
parent da5b113406
commit 827adfe756
12 changed files with 937 additions and 166 deletions
+44 -3
View File
@@ -2,17 +2,21 @@ import useAxios from '@core/utils/useAxios'
import type { TableInput } from '@core/type/baseTable'
export interface TahesabRecord {
id: number
id?: number | null
customer_id?: number
amount?: number
desc?: string
type?: string
sync_status?: 'pending' | 'success' | 'failed'
sync_status?: 'pending' | 'success' | 'failed' | 'missing'
sync_log?: string | null
tahesab_id?: string | null
data?: string | null
res?: string | null
created_at?: string
withdraw_id?: number | null
order_id?: number | null
issue_kind?: 'failed' | 'missing'
sync_target?: { type: 'row' | 'withdraw' | 'order'; id: number }
customer?: {
id?: number
name?: string
@@ -21,7 +25,7 @@ export interface TahesabRecord {
}
}
export const getTahesabTable = async (params: TableInput) =>
export const getTahesabTable = async (params: TableInput & { view?: string }) =>
await useAxios.post<TahesabRecord[]>('/admin/tahesab/table', params)
export const getTahesabDetail = async (id: number) =>
@@ -30,6 +34,43 @@ export const getTahesabDetail = async (id: number) =>
export const retryTahesabSanad = async (id: number) =>
await useAxios.post<{ ok: boolean; message: string; tahesab?: TahesabRecord }>(`/admin/tahesab/${id}/retry`)
export interface TahesabPendingSummary {
failed_rows: number
missing_withdraws: number
missing_orders: number
total: number
}
export interface TahesabSyncAllResult {
ok: boolean
retried: number
backfilled_withdraws: number
backfilled_orders: number
failed: number
errors: string[]
summary: TahesabPendingSummary
}
export const getTahesabPendingSummary = async (customerId?: number) =>
await useAxios.get<TahesabPendingSummary>('/admin/tahesab/pending-summary', {
params: customerId ? { customer_id: customerId } : undefined
})
export const getTahesabPendingDocs = async (customerId?: number, limit = 200) =>
await useAxios.get<{ rows: TahesabRecord[]; summary: TahesabPendingSummary }>(
'/admin/tahesab/pending-docs',
{ params: { ...(customerId ? { customer_id: customerId } : {}), limit } }
)
export const syncOneTahesab = async (type: 'row' | 'withdraw' | 'order', id: number) =>
await useAxios.post<{ ok: boolean; message: string }>('/admin/tahesab/sync-one', { type, id })
export const syncAllTahesabPending = async (limit = 80, customerId?: number) =>
await useAxios.post<TahesabSyncAllResult>('/admin/tahesab/sync-all-pending', {
limit,
...(customerId ? { customer_id: customerId } : {})
})
export const registerCustomerInTahesab = async (customerId: number) =>
await useAxios.post<{ ok: boolean; message: string; moshtari_code?: string | number }>(
`/admin/customer/${customerId}/tahesab/register`
+44 -3
View File
@@ -2,17 +2,21 @@ import useAxios from '@core/utils/useAxios'
import type { TableInput } from '@core/type/baseTable'
export interface ZarinRecord {
id: number
id?: number | null
customer_id?: number
amount?: number
desc?: string
type?: string
sync_status?: 'pending' | 'success' | 'failed'
sync_status?: 'pending' | 'success' | 'failed' | 'missing'
sync_log?: string | null
zarin_id?: string | null
data?: string | null
res?: string | null
created_at?: string
withdraw_id?: number | null
order_id?: number | null
issue_kind?: 'failed' | 'missing'
sync_target?: { type: 'row' | 'withdraw' | 'order'; id: number }
customer?: {
id?: number
name?: string
@@ -21,7 +25,7 @@ export interface ZarinRecord {
}
}
export const getZarinTable = async (params: TableInput) =>
export const getZarinTable = async (params: TableInput & { view?: string }) =>
await useAxios.post<ZarinRecord[]>('/admin/zarin/table', params)
export const getZarinDetail = async (id: number) =>
@@ -30,6 +34,43 @@ export const getZarinDetail = async (id: number) =>
export const retryZarinSanad = async (id: number) =>
await useAxios.post<{ ok: boolean; message: string; zarin?: ZarinRecord }>(`/admin/zarin/${id}/retry`)
export interface ZarinPendingSummary {
failed_rows: number
missing_withdraws: number
missing_orders: number
total: number
}
export interface ZarinSyncAllResult {
ok: boolean
retried: number
backfilled_withdraws: number
backfilled_orders: number
failed: number
errors: string[]
summary: ZarinPendingSummary
}
export const getZarinPendingSummary = async (customerId?: number) =>
await useAxios.get<ZarinPendingSummary>('/admin/zarin/pending-summary', {
params: customerId ? { customer_id: customerId } : undefined
})
export const getZarinPendingDocs = async (customerId?: number, limit = 200) =>
await useAxios.get<{ rows: ZarinRecord[]; summary: ZarinPendingSummary }>(
'/admin/zarin/pending-docs',
{ params: { ...(customerId ? { customer_id: customerId } : {}), limit } }
)
export const syncOneZarin = async (type: 'row' | 'withdraw' | 'order', id: number) =>
await useAxios.post<{ ok: boolean; message: string }>('/admin/zarin/sync-one', { type, id })
export const syncAllZarinPending = async (limit = 80, customerId?: number) =>
await useAxios.post<ZarinSyncAllResult>('/admin/zarin/sync-all-pending', {
limit,
...(customerId ? { customer_id: customerId } : {})
})
export const registerCustomerInZarin = async (customerId: number) =>
await useAxios.post<{ ok: boolean; message: string; moshtari_code?: string | number }>(
`/admin/customer/${customerId}/zarin/register`
+1
View File
@@ -56,6 +56,7 @@ export interface Product{
original_image?: File
inventory?: number
description?: string | null
karat?: string | null
}
export interface MethodePrices {
@@ -1,18 +1,116 @@
<template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext title="اسناد ته‌حساب">
<div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<BasicShowContext :title="customerId ? 'اسناد ته‌حساب این مشتری' : 'اسناد ته‌حساب'">
<template #extra>
<a-button
v-if="(pending?.total ?? 0) > 0"
type="primary"
size="small"
:loading="syncing"
@click="runSyncAll"
>
ثبت همه ({{ pending?.total }})
</a-button>
</template>
<div
v-if="(pending?.total ?? 0) > 0"
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<div class="font-700">
{{ pending?.total }} سند جاافتاده / ناموفق برای ته‌حساب
<span v-if="customerId">این مشتری</span>
</div>
<div class="mt-1 opacity-90">
خطا خورده در جدول: {{ pending?.failed_rows }} —
شارژ جاافتاده: {{ pending?.missing_withdraws }} —
سفارش جاافتاده: {{ pending?.missing_orders }}
</div>
</div>
<a-tabs v-model:activeKey="docsView" class="mb-2" @change="onViewChange">
<a-tab-pane key="failed">
<template #tab>
خطا خورده / جاافتاده
<span v-if="(pending?.total ?? 0) > 0">({{ pending?.total }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="ok" tab="سندهای سالم" />
<a-tab-pane key="all" tab="کل اسناد" />
</a-tabs>
<!-- تب خطا: شامل جاافتاده + خطا خورده -->
<div v-if="docsView === 'failed'" class="min-w-0 w-full overflow-x-auto">
<a-spin :spinning="failedLoading">
<a-table
:columns="failedColumns"
:data-source="failedRows"
:pagination="{ pageSize: 20, showSizeChanger: true }"
:row-key="failedRowKey"
size="small"
:locale="{ emptyText: 'مورد خطا خورده یا جاافتاده‌ای نیست' }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'row'">{{ index + 1 }}</template>
<template v-else-if="column.key === 'issue'">
<a-tag :color="record.issue_kind === 'missing' ? 'orange' : 'red'">
{{ tahesabIssueKindLabel(record.issue_kind) }}
</a-tag>
</template>
<template v-else-if="column.key === 'customer'">
{{ record.customer?.name || '-' }}
</template>
<template v-else-if="column.key === 'amount'">
{{ metalAmountLabel(record) }}
</template>
<template v-else-if="column.key === 'rial'">
{{ rialAmountLabel(record) }}
</template>
<template v-else-if="column.key === 'status'">
{{ tahesabStatusLabel(record.sync_status, record.tahesab_id) }}
</template>
<template v-else-if="column.key === 'time'">
{{ record.created_at ? toJalali(record.created_at) : '-' }}
</template>
<template v-else-if="column.key === 'action'">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>جزئیات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.tahesab_id && !record.customer?.moshtari_code && record.customer_id"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
>
<template #title><span>ثبت مشتری</span></template>
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-button
type="link"
size="small"
:loading="syncingRowKey === failedRowKey(record)"
@click="runSyncOne(record)"
>
ثبت
</a-button>
</div>
</template>
</template>
</a-table>
</a-spin>
</div>
<div v-else-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable :key="docsView" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<!-- دکمه ۱: اطلاعات کلی (همیشه نمایان) -->
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>اطلاعات سند</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<!-- دکمه ۲: ثبت مشتری در ته‌حساب (فقط اگر سند هنوز ثبت نشده و مشتری کد ندارد) -->
<a-tooltip
v-if="!record.tahesab_id && !record.customer?.moshtari_code"
placement="bottom"
@@ -23,7 +121,6 @@
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<!-- دکمه ۳: ارسال به ته‌حساب (فقط اگر هنوز در ته‌حساب ثبت نشده) -->
<a-tooltip
v-if="!record.tahesab_id"
placement="bottom"
@@ -33,16 +130,14 @@
<template #title><span>ارسال به ته‌حساب</span></template>
<Icon icon="vuesax-linear:send-2" color="#CA8A04" />
</a-tooltip>
</div>
</template>
</BaseTable>
</div>
<TahesabMobileCardList v-else ref="mobileListRef" />
<TahesabMobileCardList v-else-if="docsView !== 'failed'" ref="mobileListRef" />
</BasicShowContext>
<!-- مودال اطلاعات کلی -->
<BasicModal v-model:visible="infoVisible" title="اطلاعات سند" width="36rem" :footer="null">
<div class="space-y-2 text-sm">
<div
@@ -53,134 +148,222 @@
<span class="text-gray-500">{{ item.label }}</span>
<span class="break-all text-left">{{ item.value ?? '-' }}</span>
</div>
<div v-if="selected?.res" class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap">
<div
v-if="selected?.res"
class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap"
>
{{ selected.res }}
</div>
<div v-if="selected?.sync_log" class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap">
<div
v-if="selected?.sync_log"
class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap"
>
{{ selected.sync_log }}
</div>
<div v-if="canSyncSelected" class="pt-2">
<a-button type="primary" block :loading="!!syncingRowKey" @click="runSyncOne(selected!)">
ثبت در ته‌حساب
</a-button>
</div>
</div>
</BasicModal>
<!-- مودال ارسال به ته‌حساب -->
<TahesabRetryModal
v-model="sendVisible"
mode="retry-sanad"
:tahesab-id="selected?.id"
@success="fetch"
:tahesab-id="selected?.id ?? undefined"
@success="afterRowSync"
/>
<!-- مودال ثبت مشتری در ته‌حساب -->
<TahesabRetryModal
v-model="registerVisible"
mode="register-customer"
:customer-id="selected?.customer_id"
:customer-name="selected?.customer?.name"
@success="fetch"
@success="afterRowSync"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
import { getTahesabTable, type TahesabRecord } from '@/api/tahesab'
import {
getTahesabPendingDocs,
getTahesabPendingSummary,
getTahesabTable,
syncAllTahesabPending,
syncOneTahesab,
type TahesabPendingSummary,
type TahesabRecord
} from '@/api/tahesab'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { isMobile } from '@core/utils/is'
import BasicModal from '@/components/BasicModal.vue'
import TahesabRetryModal from '@/components/TahesabRetryModal.vue'
import TahesabMobileCardList from '@/pages/menuPages/accounting/components/TahesabMobileCardList.vue'
import { tahesabStatusLabel } from '@/pages/menuPages/accounting/utils/tahesabStatus'
import {
tahesabIssueKindLabel,
tahesabStatusLabel
} from '@/pages/menuPages/accounting/utils/tahesabStatus'
import {
metalAmountLabel,
rialAmountLabel,
rialAmountLabel
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
import { toJalali } from '@core/utils/useDataTime'
const props = defineProps<{
customerId?: number
}>()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
const infoVisible = ref(false)
const sendVisible = ref(false)
const registerVisible = ref(false)
const selected = ref<TahesabRecord | null>(null)
const pending = ref<TahesabPendingSummary | null>(null)
const syncing = ref(false)
const docsView = ref<'failed' | 'ok' | 'all'>('failed')
const failedRows = ref<TahesabRecord[]>([])
const failedLoading = ref(false)
const syncingRowKey = ref<string | null>(null)
const columns = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (r: any) => r.index + 1
},
{ title: 'مشتری', dataIndex: 'customer.name', key: 'customer.name', type: 'Text' },
{ title: 'نوع', dataIndex: 'type', key: 'type', type: 'Text' },
{
title: 'مقدار',
dataIndex: 'amount',
key: 'amount',
type: 'Number',
filterable: false,
customRender: (r: any) => metalAmountLabel(r.record),
},
{
title: 'مبلغ ریالی',
dataIndex: 'rial_amount',
key: 'rial_amount',
type: 'Text',
filterable: false,
sortable: false,
customRender: (r: any) => rialAmountLabel(r.record),
},
{ title: 'شرح', dataIndex: 'desc', key: 'desc', type: 'Text' },
{
title: 'وضعیت',
dataIndex: 'sync_status',
key: 'sync_status',
type: 'Text',
filterable: false,
customRender: (r: any) => tahesabStatusLabel(r.record.sync_status, r.record.tahesab_id)
},
{
title: 'زمان',
dataIndex: 'created_at',
key: 'created_at',
type: 'Text',
customRender: (r: any) => toJalali(r.record.created_at)
},
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false
const columns = computed(() => {
const cols: any[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (r: any) => r.index + 1
}
]
if (!props.customerId) {
cols.push({ title: 'مشتری', dataIndex: 'customer.name', key: 'customer.name', type: 'Text' })
}
]
const tblProps = ref({
columns,
api: (params: TableInput) => getTahesabTable(params)
cols.push(
{ title: 'نوع', dataIndex: 'type', key: 'type', type: 'Text' },
{
title: 'مقدار',
dataIndex: 'amount',
key: 'amount',
type: 'Number',
filterable: false,
customRender: (r: any) => metalAmountLabel(r.record)
},
{
title: 'مبلغ ریالی',
dataIndex: 'rial_amount',
key: 'rial_amount',
type: 'Text',
filterable: false,
sortable: false,
customRender: (r: any) => rialAmountLabel(r.record)
},
{ title: 'شرح', dataIndex: 'desc', key: 'desc', type: 'Text' },
{
title: 'وضعیت',
dataIndex: 'sync_status',
key: 'sync_status',
type: 'Text',
filterable: false,
customRender: (r: any) => tahesabStatusLabel(r.record.sync_status, r.record.tahesab_id)
},
{
title: 'زمان',
dataIndex: 'created_at',
key: 'created_at',
type: 'Text',
customRender: (r: any) => toJalali(r.record.created_at)
},
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false
}
)
return cols
})
/** اطلاعاتی که برای هر سند حسابداری نیاز است (بدون جزئیات ته‌حساب‌محور) */
const failedColumns = computed(() => {
const cols: any[] = [
{ title: 'ردیف', key: 'row', width: 60 },
{ title: 'نوع مشکل', key: 'issue', width: 110 }
]
if (!props.customerId) {
cols.push({
title: 'مشتری',
key: 'customer'
})
}
cols.push(
{ title: 'نوع', dataIndex: 'type', key: 'type' },
{ title: 'مقدار', key: 'amount' },
{ title: 'مبلغ ریالی', key: 'rial' },
{ title: 'شرح', dataIndex: 'desc', key: 'desc', ellipsis: true },
{ title: 'وضعیت', key: 'status' },
{ title: 'زمان', key: 'time', width: 140 },
{ title: 'عملیات', key: 'action', width: 140 }
)
return cols
})
const tblProps = computed(() => ({
columns: columns.value,
api: (params: TableInput) => {
if (props.customerId) {
params.filters = params.filters || []
params.filters.push({
field: 'customer_id',
type: 'text',
value: props.customerId,
cr: 'and',
op: 'eq',
hidden: false
})
}
return getTahesabTable({ ...params, view: docsView.value })
}
}))
const infoItems = computed(() => {
const row = selected.value
if (!row) return []
return [
{ label: 'شناسه سند', value: row.id },
{ label: 'شناسه سند', value: row.id ?? '—' },
{ label: 'نوع مشکل', value: row.issue_kind ? tahesabIssueKindLabel(row.issue_kind) : '—' },
{ label: 'مشتری', value: row.customer?.name },
{ label: 'موبایل', value: row.customer?.mobile },
{ label: 'نوع', value: row.type },
{ label: 'مقدار', value: metalAmountLabel(row) },
{ label: 'مبلغ ریالی', value: rialAmountLabel(row) },
{ label: 'شرح', value: row.desc },
{ label: 'سفارش', value: row.order_id || '-' },
{ label: 'برداشت', value: row.withdraw_id || '-' },
{ label: 'زمان ثبت', value: row.created_at ? toJalali(row.created_at) : '-' },
{ label: 'وضعیت', value: tahesabStatusLabel(row.sync_status, row.tahesab_id) },
{ label: 'کد ته‌حساب', value: row.tahesab_id || 'ثبت نشده' }
]
})
const canSyncSelected = computed(() => {
const row = selected.value
if (!row?.sync_target) return false
return !row.tahesab_id
})
function failedRowKey(record: TahesabRecord) {
if (record.sync_target) return `${record.sync_target.type}-${record.sync_target.id}`
if (record.id) return `row-${record.id}`
return `x-${record.withdraw_id || record.order_id || Math.random()}`
}
function openInfo(record: TahesabRecord) {
selected.value = record
infoVisible.value = true
@@ -196,13 +379,130 @@ function openRegister(record: TahesabRecord) {
registerVisible.value = true
}
async function loadPending() {
try {
pending.value = await getTahesabPendingSummary(props.customerId)
} catch {
pending.value = null
}
}
async function loadFailedDocs() {
failedLoading.value = true
try {
const res = await getTahesabPendingDocs(props.customerId, 200)
failedRows.value = res.rows || []
pending.value = res.summary
} catch {
failedRows.value = []
} finally {
failedLoading.value = false
}
}
async function runSyncOne(record: TahesabRecord) {
const target = record.sync_target
if (!target) {
if (record.id) {
openSend(record)
return
}
message.warning('هدف ثبت مشخص نیست')
return
}
const key = failedRowKey(record)
syncingRowKey.value = key
try {
const result = await syncOneTahesab(target.type, target.id)
if (result.ok) {
message.success(result.message || 'ثبت شد')
infoVisible.value = false
await afterRowSync()
} else {
message.error(result.message || 'ثبت ناموفق بود')
}
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || 'خطا در ثبت')
} finally {
syncingRowKey.value = null
}
}
async function runSyncAll() {
Modal.confirm({
title: 'ثبت همه در ته‌حساب',
content: props.customerId
? 'همهٔ سندهای ناموفق/جاافتادهٔ این مشتری ارسال می‌شود (حداکثر ۸۰). ادامه؟'
: 'حداکثر ۸۰ سند ناموفق/جاافتاده به ته‌حساب ارسال می‌شود. ادامه؟',
okText: 'ثبت همه',
cancelText: 'انصراف',
async onOk() {
syncing.value = true
try {
const result = await syncAllTahesabPending(80, props.customerId)
const parts = [
result.retried ? `${result.retried} ارسال مجدد` : '',
result.backfilled_withdraws ? `${result.backfilled_withdraws} شارژ` : '',
result.backfilled_orders ? `${result.backfilled_orders} سفارش` : '',
result.failed ? `${result.failed} ناموفق` : ''
].filter(Boolean)
if (result.ok) {
message.success(parts.join(' — ') || 'همگام‌سازی انجام شد')
} else {
message.warning(parts.join(' — ') || 'بخشی از موارد ناموفق بود')
}
pending.value = result.summary
await afterRowSync()
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || 'خطا در همگام‌سازی')
} finally {
syncing.value = false
}
}
})
}
async function afterRowSync() {
await loadPending()
await fetch()
}
async function onViewChange() {
await fetch()
}
async function fetch() {
if (docsView.value === 'failed') {
await loadFailedDocs()
return
}
if (isMobile.value) {
await nextTick()
await mobileListRef.value?.fetch?.()
return
}
await nextTick()
await reload()
}
watch(
() => props.customerId,
async () => {
docsView.value = 'failed'
await loadPending()
await fetch()
}
)
onMounted(async () => {
await loadPending()
if ((pending.value?.total ?? 0) > 0) {
docsView.value = 'failed'
} else {
docsView.value = 'all'
}
await fetch()
})
defineExpose({ fetch })
</script>
@@ -1,18 +1,115 @@
<template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext title="اسناد زرین">
<div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<BasicShowContext :title="customerId ? 'اسناد زرین این مشتری' : 'اسناد زرین'">
<template #extra>
<a-button
v-if="(pending?.total ?? 0) > 0"
type="primary"
size="small"
:loading="syncing"
@click="runSyncAll"
>
ثبت همه ({{ pending?.total }})
</a-button>
</template>
<div
v-if="(pending?.total ?? 0) > 0"
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<div class="font-700">
{{ pending?.total }} سند جاافتاده / ناموفق برای زرین
<span v-if="customerId">این مشتری</span>
</div>
<div class="mt-1 opacity-90">
خطا خورده در جدول: {{ pending?.failed_rows }} —
شارژ جاافتاده: {{ pending?.missing_withdraws }} —
سفارش جاافتاده: {{ pending?.missing_orders }}
</div>
</div>
<a-tabs v-model:activeKey="docsView" class="mb-2" @change="onViewChange">
<a-tab-pane key="failed">
<template #tab>
خطا خورده / جاافتاده
<span v-if="(pending?.total ?? 0) > 0">({{ pending?.total }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="ok" tab="سندهای سالم" />
<a-tab-pane key="all" tab="کل اسناد" />
</a-tabs>
<div v-if="docsView === 'failed'" class="min-w-0 w-full overflow-x-auto">
<a-spin :spinning="failedLoading">
<a-table
:columns="failedColumns"
:data-source="failedRows"
:pagination="{ pageSize: 20, showSizeChanger: true }"
:row-key="failedRowKey"
size="small"
:locale="{ emptyText: 'مورد خطا خورده یا جاافتاده‌ای نیست' }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'row'">{{ index + 1 }}</template>
<template v-else-if="column.key === 'issue'">
<a-tag :color="record.issue_kind === 'missing' ? 'orange' : 'red'">
{{ zarinIssueKindLabel(record.issue_kind) }}
</a-tag>
</template>
<template v-else-if="column.key === 'customer'">
{{ record.customer?.name || '-' }}
</template>
<template v-else-if="column.key === 'amount'">
{{ metalAmountLabel(record) }}
</template>
<template v-else-if="column.key === 'rial'">
{{ rialAmountLabel(record) }}
</template>
<template v-else-if="column.key === 'status'">
{{ zarinStatusLabel(record.sync_status, record.zarin_id) }}
</template>
<template v-else-if="column.key === 'time'">
{{ record.created_at ? toJalali(record.created_at) : '-' }}
</template>
<template v-else-if="column.key === 'action'">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>جزئیات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.zarin_id && !record.customer?.moshtari_code && record.customer_id"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
>
<template #title><span>ثبت مشتری</span></template>
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-button
type="link"
size="small"
:loading="syncingRowKey === failedRowKey(record)"
@click="runSyncOne(record)"
>
ثبت
</a-button>
</div>
</template>
</template>
</a-table>
</a-spin>
</div>
<div v-else-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable :key="docsView" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<!-- دکمه ۱: اطلاعات کلی (همیشه نمایان) -->
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>اطلاعات سند</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<!-- دکمه ۲: ثبت مشتری در زرین (فقط اگر سند هنوز ثبت نشده و مشتری کد ندارد) -->
<a-tooltip
v-if="!record.zarin_id && !record.customer?.moshtari_code"
placement="bottom"
@@ -23,7 +120,6 @@
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<!-- دکمه ۳: ارسال به زرین (فقط اگر هنوز در زرین ثبت نشده) -->
<a-tooltip
v-if="!record.zarin_id"
placement="bottom"
@@ -33,16 +129,14 @@
<template #title><span>ارسال به زرین</span></template>
<Icon icon="vuesax-linear:send-2" color="#CA8A04" />
</a-tooltip>
</div>
</template>
</BaseTable>
</div>
<ZarinMobileCardList v-else ref="mobileListRef" />
<ZarinMobileCardList v-else-if="docsView !== 'failed'" ref="mobileListRef" />
</BasicShowContext>
<!-- مودال اطلاعات کلی -->
<BasicModal v-model:visible="infoVisible" title="اطلاعات سند" width="36rem" :footer="null">
<div class="space-y-2 text-sm">
<div
@@ -53,134 +147,222 @@
<span class="text-gray-500">{{ item.label }}</span>
<span class="break-all text-left">{{ item.value ?? '-' }}</span>
</div>
<div v-if="selected?.res" class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap">
<div
v-if="selected?.res"
class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap"
>
{{ selected.res }}
</div>
<div v-if="selected?.sync_log" class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap">
<div
v-if="selected?.sync_log"
class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap"
>
{{ selected.sync_log }}
</div>
<div v-if="canSyncSelected" class="pt-2">
<a-button type="primary" block :loading="!!syncingRowKey" @click="runSyncOne(selected!)">
ثبت در زرین
</a-button>
</div>
</div>
</BasicModal>
<!-- مودال ارسال به زرین -->
<ZarinRetryModal
v-model="sendVisible"
mode="retry-sanad"
:zarin-id="selected?.id"
@success="fetch"
:zarin-id="selected?.id ?? undefined"
@success="afterRowSync"
/>
<!-- مودال ثبت مشتری در زرین -->
<ZarinRetryModal
v-model="registerVisible"
mode="register-customer"
:customer-id="selected?.customer_id"
:customer-name="selected?.customer?.name"
@success="fetch"
@success="afterRowSync"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
import { getZarinTable, type ZarinRecord } from '@/api/zarin'
import {
getZarinPendingDocs,
getZarinPendingSummary,
getZarinTable,
syncAllZarinPending,
syncOneZarin,
type ZarinPendingSummary,
type ZarinRecord
} from '@/api/zarin'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { isMobile } from '@core/utils/is'
import BasicModal from '@/components/BasicModal.vue'
import ZarinRetryModal from '@/components/ZarinRetryModal.vue'
import ZarinMobileCardList from '@/pages/menuPages/accounting/components/ZarinMobileCardList.vue'
import { zarinStatusLabel } from '@/pages/menuPages/accounting/utils/zarinStatus'
import {
zarinIssueKindLabel,
zarinStatusLabel
} from '@/pages/menuPages/accounting/utils/zarinStatus'
import {
metalAmountLabel,
rialAmountLabel,
rialAmountLabel
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
import { toJalali } from '@core/utils/useDataTime'
const props = defineProps<{
customerId?: number
}>()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
const infoVisible = ref(false)
const sendVisible = ref(false)
const registerVisible = ref(false)
const selected = ref<ZarinRecord | null>(null)
const pending = ref<ZarinPendingSummary | null>(null)
const syncing = ref(false)
const docsView = ref<'failed' | 'ok' | 'all'>('failed')
const failedRows = ref<ZarinRecord[]>([])
const failedLoading = ref(false)
const syncingRowKey = ref<string | null>(null)
const columns = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (r: any) => r.index + 1
},
{ title: 'مشتری', dataIndex: 'customer.name', key: 'customer.name', type: 'Text' },
{ title: 'نوع', dataIndex: 'type', key: 'type', type: 'Text' },
{
title: 'مقدار',
dataIndex: 'amount',
key: 'amount',
type: 'Number',
filterable: false,
customRender: (r: any) => metalAmountLabel(r.record),
},
{
title: 'مبلغ ریالی',
dataIndex: 'rial_amount',
key: 'rial_amount',
type: 'Text',
filterable: false,
sortable: false,
customRender: (r: any) => rialAmountLabel(r.record),
},
{ title: 'شرح', dataIndex: 'desc', key: 'desc', type: 'Text' },
{
title: 'وضعیت',
dataIndex: 'sync_status',
key: 'sync_status',
type: 'Text',
filterable: false,
customRender: (r: any) => zarinStatusLabel(r.record.sync_status, r.record.zarin_id)
},
{
title: 'زمان',
dataIndex: 'created_at',
key: 'created_at',
type: 'Text',
customRender: (r: any) => toJalali(r.record.created_at)
},
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false
const columns = computed(() => {
const cols: any[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (r: any) => r.index + 1
}
]
if (!props.customerId) {
cols.push({ title: 'مشتری', dataIndex: 'customer.name', key: 'customer.name', type: 'Text' })
}
]
const tblProps = ref({
columns,
api: (params: TableInput) => getZarinTable(params)
cols.push(
{ title: 'نوع', dataIndex: 'type', key: 'type', type: 'Text' },
{
title: 'مقدار',
dataIndex: 'amount',
key: 'amount',
type: 'Number',
filterable: false,
customRender: (r: any) => metalAmountLabel(r.record)
},
{
title: 'مبلغ ریالی',
dataIndex: 'rial_amount',
key: 'rial_amount',
type: 'Text',
filterable: false,
sortable: false,
customRender: (r: any) => rialAmountLabel(r.record)
},
{ title: 'شرح', dataIndex: 'desc', key: 'desc', type: 'Text' },
{
title: 'وضعیت',
dataIndex: 'sync_status',
key: 'sync_status',
type: 'Text',
filterable: false,
customRender: (r: any) => zarinStatusLabel(r.record.sync_status, r.record.zarin_id)
},
{
title: 'زمان',
dataIndex: 'created_at',
key: 'created_at',
type: 'Text',
customRender: (r: any) => toJalali(r.record.created_at)
},
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false
}
)
return cols
})
/** اطلاعاتی که برای هر سند حسابداری نیاز است (بدون جزئیات زرین‌محور) */
const failedColumns = computed(() => {
const cols: any[] = [
{ title: 'ردیف', key: 'row', width: 60 },
{ title: 'نوع مشکل', key: 'issue', width: 110 }
]
if (!props.customerId) {
cols.push({
title: 'مشتری',
key: 'customer'
})
}
cols.push(
{ title: 'نوع', dataIndex: 'type', key: 'type' },
{ title: 'مقدار', key: 'amount' },
{ title: 'مبلغ ریالی', key: 'rial' },
{ title: 'شرح', dataIndex: 'desc', key: 'desc', ellipsis: true },
{ title: 'وضعیت', key: 'status' },
{ title: 'زمان', key: 'time', width: 140 },
{ title: 'عملیات', key: 'action', width: 140 }
)
return cols
})
const tblProps = computed(() => ({
columns: columns.value,
api: (params: TableInput) => {
if (props.customerId) {
params.filters = params.filters || []
params.filters.push({
field: 'customer_id',
type: 'text',
value: props.customerId,
cr: 'and',
op: 'eq',
hidden: false
})
}
return getZarinTable({ ...params, view: docsView.value })
}
}))
const infoItems = computed(() => {
const row = selected.value
if (!row) return []
return [
{ label: 'شناسه سند', value: row.id },
{ label: 'شناسه سند', value: row.id ?? '—' },
{ label: 'نوع مشکل', value: row.issue_kind ? zarinIssueKindLabel(row.issue_kind) : '—' },
{ label: 'مشتری', value: row.customer?.name },
{ label: 'موبایل', value: row.customer?.mobile },
{ label: 'نوع', value: row.type },
{ label: 'مقدار', value: metalAmountLabel(row) },
{ label: 'مبلغ ریالی', value: rialAmountLabel(row) },
{ label: 'شرح', value: row.desc },
{ label: 'سفارش', value: row.order_id || '-' },
{ label: 'برداشت', value: row.withdraw_id || '-' },
{ label: 'زمان ثبت', value: row.created_at ? toJalali(row.created_at) : '-' },
{ label: 'وضعیت', value: zarinStatusLabel(row.sync_status, row.zarin_id) },
{ label: 'کد زرین', value: row.zarin_id || 'ثبت نشده' }
]
})
const canSyncSelected = computed(() => {
const row = selected.value
if (!row?.sync_target) return false
return !row.zarin_id
})
function failedRowKey(record: ZarinRecord) {
if (record.sync_target) return `${record.sync_target.type}-${record.sync_target.id}`
if (record.id) return `row-${record.id}`
return `x-${record.withdraw_id || record.order_id || Math.random()}`
}
function openInfo(record: ZarinRecord) {
selected.value = record
infoVisible.value = true
@@ -196,13 +378,130 @@ function openRegister(record: ZarinRecord) {
registerVisible.value = true
}
async function loadPending() {
try {
pending.value = await getZarinPendingSummary(props.customerId)
} catch {
pending.value = null
}
}
async function loadFailedDocs() {
failedLoading.value = true
try {
const res = await getZarinPendingDocs(props.customerId, 200)
failedRows.value = res.rows || []
pending.value = res.summary
} catch {
failedRows.value = []
} finally {
failedLoading.value = false
}
}
async function runSyncOne(record: ZarinRecord) {
const target = record.sync_target
if (!target) {
if (record.id) {
openSend(record)
return
}
message.warning('هدف ثبت مشخص نیست')
return
}
const key = failedRowKey(record)
syncingRowKey.value = key
try {
const result = await syncOneZarin(target.type, target.id)
if (result.ok) {
message.success(result.message || 'ثبت شد')
infoVisible.value = false
await afterRowSync()
} else {
message.error(result.message || 'ثبت ناموفق بود')
}
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || 'خطا در ثبت')
} finally {
syncingRowKey.value = null
}
}
async function runSyncAll() {
Modal.confirm({
title: 'ثبت همه در زرین',
content: props.customerId
? 'همهٔ سندهای ناموفق/جاافتادهٔ این مشتری ارسال می‌شود (حداکثر ۸۰). ادامه؟'
: 'حداکثر ۸۰ سند ناموفق/جاافتاده به زرین ارسال می‌شود. ادامه؟',
okText: 'ثبت همه',
cancelText: 'انصراف',
async onOk() {
syncing.value = true
try {
const result = await syncAllZarinPending(80, props.customerId)
const parts = [
result.retried ? `${result.retried} ارسال مجدد` : '',
result.backfilled_withdraws ? `${result.backfilled_withdraws} شارژ` : '',
result.backfilled_orders ? `${result.backfilled_orders} سفارش` : '',
result.failed ? `${result.failed} ناموفق` : ''
].filter(Boolean)
if (result.ok) {
message.success(parts.join(' — ') || 'همگام‌سازی انجام شد')
} else {
message.warning(parts.join(' — ') || 'بخشی از موارد ناموفق بود')
}
pending.value = result.summary
await afterRowSync()
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || 'خطا در همگام‌سازی')
} finally {
syncing.value = false
}
}
})
}
async function afterRowSync() {
await loadPending()
await fetch()
}
async function onViewChange() {
await fetch()
}
async function fetch() {
if (docsView.value === 'failed') {
await loadFailedDocs()
return
}
if (isMobile.value) {
await nextTick()
await mobileListRef.value?.fetch?.()
return
}
await nextTick()
await reload()
}
watch(
() => props.customerId,
async () => {
docsView.value = 'failed'
await loadPending()
await fetch()
}
)
onMounted(async () => {
await loadPending()
if ((pending.value?.total ?? 0) > 0) {
docsView.value = 'failed'
} else {
docsView.value = 'all'
}
await fetch()
})
defineExpose({ fetch })
</script>
@@ -1,12 +1,20 @@
export function tahesabStatusLabel(status?: string, tahesabId?: string | null): string {
if (tahesabId) return 'موفق'
if (status === 'missing') return 'جاافتاده'
if (status === 'failed') return 'ناموفق'
if (status === 'success') return 'موفق'
// pending = ردیف محلی ساخته شده ولی هنوز در نرم‌افزار ته‌حساب ثبت نشده (نه «در صف»)
return 'ارسال نشده'
}
export function canRetryTahesab(record: { tahesab_id?: string | null; sync_status?: string }): boolean {
return !record.tahesab_id || record.sync_status === 'failed'
export function tahesabIssueKindLabel(kind?: string): string {
if (kind === 'missing') return 'جاافتاده'
return 'خطا خورده'
}
export function canRetryTahesab(record: {
tahesab_id?: string | null
sync_status?: string
}): boolean {
return !record.tahesab_id || record.sync_status === 'failed' || record.sync_status === 'missing'
}
@@ -1,12 +1,17 @@
export function zarinStatusLabel(status?: string, zarinId?: string | null): string {
if (zarinId) return 'موفق'
if (status === 'missing') return 'جاافتاده'
if (status === 'failed') return 'ناموفق'
if (status === 'success') return 'موفق'
// pending = ردیف محلی ساخته شده ولی هنوز در نرم‌افزار زرین ثبت نشده (نه «در صف»)
return 'ارسال نشده'
}
export function canRetryZarin(record: { zarin_id?: string | null; sync_status?: string }): boolean {
return !record.zarin_id || record.sync_status === 'failed'
export function zarinIssueKindLabel(kind?: string): string {
if (kind === 'missing') return 'جاافتاده'
return 'خطا خورده'
}
export function canRetryZarin(record: { zarin_id?: string | null; sync_status?: string }): boolean {
return !record.zarin_id || record.sync_status === 'failed' || record.sync_status === 'missing'
}
@@ -0,0 +1,18 @@
<template>
<tahesabTbl ref="inner" :customer-id="customerId" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import tahesabTbl from '@/pages/menuPages/accounting/components/tahesabTable.vue'
defineProps<{
customerId?: number
}>()
const inner = ref<{ fetch?: () => void | Promise<void> } | null>(null)
defineExpose({
fetch: () => inner.value?.fetch?.()
})
</script>
@@ -0,0 +1,18 @@
<template>
<zarinTbl ref="inner" :customer-id="customerId" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import zarinTbl from '@/pages/menuPages/accounting/components/zarinTable.vue'
defineProps<{
customerId?: number
}>()
const inner = ref<{ fetch?: () => void | Promise<void> } | null>(null)
defineExpose({
fetch: () => inner.value?.fetch?.()
})
</script>
@@ -249,6 +249,12 @@
<a-tab-pane v-if="pargarWalletSyncVisible" key="لاگ پرگار" tab="لاگ پرگار">
<CustomerPargarLogsTable ref="pargarLogsTable" :customer-id="userInfo?.id" />
</a-tab-pane>
<a-tab-pane v-if="tahesabDocsVisible" key="اسناد ته‌حساب" tab="اسناد ته‌حساب">
<CustomerTahesabDocsTable ref="tahesabDocsTable" :customer-id="userInfo?.id" />
</a-tab-pane>
<a-tab-pane v-if="zarinDocsVisible" key="اسناد زرین" tab="اسناد زرین">
<CustomerZarinDocsTable ref="zarinDocsTable" :customer-id="userInfo?.id" />
</a-tab-pane>
<a-tab-pane
v-if="conditionalOrderEnabled"
key="سفارش شرطی"
@@ -310,6 +316,8 @@ import FundsTable from '@/pages/menuPages/customers/base/components/fundsTable.v
import PiggyFundsTable from '@/pages/menuPages/customers/base/components/piggyFundsTable.vue'
import ReferralsTable from '@/pages/menuPages/customers/base/components/referralsTable.vue'
import CustomerPargarLogsTable from '@/pages/menuPages/customers/base/components/CustomerPargarLogsTable.vue'
import CustomerTahesabDocsTable from '@/pages/menuPages/customers/base/components/CustomerTahesabDocsTable.vue'
import CustomerZarinDocsTable from '@/pages/menuPages/customers/base/components/CustomerZarinDocsTable.vue'
import CustomerConditionalOrderTab from '@/pages/menuPages/customers/base/components/CustomerConditionalOrderTab.vue'
import type { Order } from '@/model/order'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
@@ -384,6 +392,8 @@ const referralTable = ref(null)
const fundTable = ref(null)
const piggyFundTable = ref(null)
const pargarLogsTable = ref<{ fetch?: () => void } | null>(null)
const tahesabDocsTable = ref<{ fetch?: () => void } | null>(null)
const zarinDocsTable = ref<{ fetch?: () => void } | null>(null)
const refreshTable = () => table?.value?.fetch()
const refreshOrderTable = () => orderTable?.value?.fetch()
const refreshTransactionTable = () => transactionTable?.value?.fetch()
@@ -392,6 +402,8 @@ const refreshReferralTable = () => referralTable?.value?.fetch()
const refreshFundTable = () => fundTable?.value?.fetch()
const refreshPiggyFundTable = () => piggyFundTable?.value?.fetch()
const refreshPargarLogsTable = () => pargarLogsTable.value?.fetch?.()
const refreshTahesabDocsTable = () => tahesabDocsTable.value?.fetch?.()
const refreshZarinDocsTable = () => zarinDocsTable.value?.fetch?.()
let updateKey = ref<number>(0)
@@ -560,6 +572,14 @@ async function refreshInfoTab(key: string) {
await nextTick()
refreshPargarLogsTable()
break
case 'اسناد ته‌حساب':
await nextTick()
refreshTahesabDocsTable()
break
case 'اسناد زرین':
await nextTick()
refreshZarinDocsTable()
break
case 'سفارش شرطی':
await refetchCustomerProfile()
break
@@ -723,6 +743,9 @@ const pargarWalletSyncVisible = computed(
() => accountingProviderSetting.value === 'pargar'
)
const tahesabDocsVisible = computed(() => accountingProviderSetting.value === 'tahesab')
const zarinDocsVisible = computed(() => accountingProviderSetting.value === 'zarin')
const pargarWalletCanSync = computed(() =>
isValidAccountingCode(userInfo.value?.pargar_account_id)
)
@@ -351,6 +351,16 @@
show-count
/>
</a-form-item>
<a-form-item name="karat" class="!mb-0 max-w-xs">
<template #label>
<DashboardLabelWithHelp label="عیار" :help="productKaratHelp" size="sm" />
</template>
<BasicInput
v-model:value="form.karat"
placeholder="مثلاً ۲۴ (اختیاری)"
:maxlength="32"
/>
</a-form-item>
</div>
<!-- وزن و واحد -->
@@ -684,6 +694,11 @@ const productDescriptionHelp: DashboardHelp = {
'متن توضیح محصول که در اپ مشتری، بعد از انتخاب محصول در صفحه خرید/فروش نمایش داده می‌شود (اگر سوییچ «نمایش جزئیات اطلاعات محصول» در تنظیمات روشن باشد).',
}
const productKaratHelp: DashboardHelp = {
description:
'عیار نمایشی در مشخصات محصول اپ مشتری (مثلاً ۲۴ یا ۹۹۹). اگر خالی باشد، ردیف عیار در اپ نشان داده نمی‌شود.',
}
const categoryHelp: DashboardHelp = {
description: 'دسته‌بندی محصول در تب‌های اپ (سکه، شمش، آبشده و …).',
}
@@ -457,6 +457,7 @@ function resetData() {
min_physical_delivery_quantity: undefined,
inventory: 0,
description: '',
karat: '',
min_order_cash: undefined,
max_order_cash: undefined,
auto_open_at: dayjs('00:00:00', 'HH:mm:ss'),
@@ -482,6 +483,7 @@ const PRICING_FIELDS = new Set([
const DETAILS_FIELDS = new Set([
'title',
'description',
'karat',
'group_type',
'metal_id',
'amount',