@@ -0,0 +1,698 @@
< template >
<!--
« اتصال حسابداری » — D27 , docs / ACCOUNTING - INTEGRATION - PLAN . md § 5.
One switch picks the accounting software ( none / Tahesab / Zarrin ) ; the
rest of the screen configures and watches the sync . Tokens are write - only :
the server only ever says whether one is set , never what it is .
-- >
< div >
< a-card v-if = "hasPermission('config.accounting')" title="اتصال به نرمافزار حسابداری" >
< template # extra >
< a-space >
< a-tag v-if = "overview?.busy" color="processing" > همگامسازی در حال اجرا < / a -tag >
< a-button size = "small" @click ="load" > تازهسازی < / a -button >
< / a-space >
< / template >
< a-alert
v-if = "overview && overview.config.secretKeySource !== 'ACCOUNTING_SECRET_KEY'"
type = "warning"
show -icon
class = "mb-4"
message = "متغیر ACCOUNTING_SECRET_KEY روی سرور تنظیم نشده است"
description = "توکن با کلیدی مشتق از JWT_SECRET رمز میشود؛ اگر JWT_SECRET عوض شود باید توکن را دوباره وارد کنید."
/ >
< a-alert
v-if = "overview?.inactiveReason && overview.config.provider !== 'none' && tab !== 'connection'"
type = "error"
show -icon
class = "mb-4"
message = "همگامسازی انجام نمیشود"
:description = "overview.inactiveReason"
/ >
< a-alert
v-else-if = "active && !overview?.config?.mapping?.importCustomerGroups?.length && overview?.config?.features?.customers"
type = "warning"
show -icon
class = "mb-4"
message = "سینک مشتری اجرا نمیشود"
description = "هیچ گروه مشتری برای ورود انتخاب نشده است؛ در تب «نگاشت» گروههایی را که واقعاً مشتری هستند انتخاب و ذخیره کنید."
/ >
< a-spin :spinning = "loading" >
< a-tabs v -model :active-key = "tab" >
<!-- ─ ─ اتصال ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -- >
< a-tab-pane key = "connection" tab = "اتصال" >
< AccountingConnection :overview = "overview" :form = "form" :tokens = "tokens" :saving = "loading" @save ="save" @ clear -token = " clearToken " / >
< / a-tab-pane >
<!-- ─ ─ قابلیت ها و زمان بندی ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -- >
< a-tab-pane key = "features" tab = "قابلیتها و زمانبندی" >
< a-alert
v-if = "form.provider === 'zarrin'"
type = "info"
show -icon
class = "mb-4"
message = "درایور زرین روی نصب واقعی تست نشده است"
description = "طبق تصمیم مالک، پیش از استفادهی واقعی یک دورهی آزمایشی با اسناد موقت لازم است (علامت مانده و فیلد هزینهی سنگ را بررسی کنید)."
/ >
< a-alert
v-if = "active && overview?.config?.provider && (form.features.balances || form.features.products)"
: type = "overview?.realtime?.lastError ? 'warning' : 'success'"
show -icon
class = "mb-4"
message = "همگامسازی لحظهای"
>
< template # description >
< div v-if = "overview?.realtime?.lastOkAt" >
تغییرات حسابداری چند ثانیه یکبار بررسی میشود ؛ آخرین بررسی : {{ jdt ( overview.realtime.lastOkAt ) }} .
< span v-if = "overview.realtime.lastChangeAt" > آخرین تغییر دریافتشده : {{ jdt ( overview.realtime.lastChangeAt ) }} . < / span >
< / div >
< div v-else > هنوز بررسی انجام نشده است ( چند ثانیه بعد از روشنشدن شروع میشود ) . < / div >
< div v-if = "overview?.realtime?.lastError" class="warn" > خطای آخرین بررسی : {{ overview.realtime.lastError }} < / div >
< div class = "hint" >
سفارش و ویرایش مشتری بلافاصله ارسال می شود و موجودی هنگام ثبت سفارش مستقیم از حسابداری خوانده می شود ؛ اگر حسابداری آن لحظه پاسخ ندهد ، آخرین موجودی معلوم ملاک است .
بازه های « دقیقه » ی زیر فقط شبکه ی اطمینان اند ( مشتری جدید ، ویرایش یا حذف در حسابداری ) .
< / div >
< / template >
< / a-alert >
< a-table :data-source = "featureRows" :columns = "featureColumns" :pagination = "false" row -key = " key " size = "middle" >
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key === 'on'" >
< a -switch v -model :checked = "form.features[record.key]" / >
< / template >
< template v-else-if = "column.key === 'interval'" >
< a -input -number
v-if = "record.interval"
v -model :value = "form.intervals[record.interval]"
:min = "1"
:max = "1440"
addon -after = " دقیقه "
/ >
< a-select
v-else-if = "record.key === 'documents'"
v -model :value = "form.documents.triggerStatus"
style = "min-width: 220px"
title = "سند (موقت / پیشفاکتور) وقتی سفارش به این وضعیت برسد ثبت میشود"
>
< a-select-option v-for = "o in triggerOptions" :key="o.value" :value="o.value" > ثبت در : {{ o.label }} < / a -select -option >
< / a-select >
< span v-else > — < / span >
< / template >
< template v-else-if = "column.key === 'state'" >
< div v-if = "record.job && jobState(record.job)" class="hint" >
آخرین اجرا : {{ jdt ( jobState ( record.job ) .lastRunAt ) }}
< span v-if = "jobState(record.job).lastError" class="warn" > — خطا : {{ jobState ( record.job ) .lastError }} < / span >
< span v-else-if = "jobState(record.job).summary" > — {{ summaryText ( jobState ( record.job ) .summary ) }} < / span >
< / div >
< / template >
< template v-else-if = "column.key === 'run'" >
< a -button v-if = "record.job" size="small" :disabled="!active" @click="run(record.job)" > سینک الان < / a -button >
< / template >
< / template >
< / a-table >
< div class = "grid-2 mt-4" >
< a-form-item label = "بازهی گزارش مشتری (روز)" >
< a-input-number v -model :value = "form.report.windowDays" :min = "7" :max = "3650" class = "w-full" / >
< / a-form-item >
< / div >
< div class = "hint mb-4" >
اسناد سفارش همیشه « موقت » ثبت می شوند و تا حسابدار نهایی نکند روی مانده ی حسابداری اثر ندارند .
سقف اعتبار سفارش همچنان با دفتر داخلی اپ چک می شود .
< / div >
< a-button type = "primary" @click ="save" > ذخیره < / a -button >
< / a-tab-pane >
<!-- ─ ─ نگاشت ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -- >
< a-tab-pane key = "mapping" tab = "نگاشت" >
< a-space class = "mb-4" >
< a-button : disabled = "form.provider === 'none'" :loading = "optionsLoading" @click ="loadOptions" > دریافت گروهها از حسابداری < / a -button >
< span v-for = "e in optionErrors" :key="e" class="warn" > {{ e }} < / span >
< / a -space >
< a-form layout = "vertical" >
< div class = "grid-2" >
< a-form-item label = "گروههای حسابی که وارد اپ میشوند (خالی = همه)" >
< a-select v -model :value = "importGroupKeys" mode = "multiple" :options = "customerGroupOptions" allow -clear placeholder = "همهی گروهها" / >
< / a-form-item >
< a-form-item label = "گروه مشتری جدیدی که از اپ ساخته میشود" >
< a-select v -model :value = "newCustomerGroupKey" :options = "customerGroupOptions" allow -clear / >
< / a-form-item >
< a-form-item label = "عیار پایهی گرمهای اپ" >
< a-input-number v -model :value = "form.mapping.karat" :min = "1" :max = "1000" class = "w-full" / >
< / a-form-item >
< / div >
< template v-if = "form.provider === 'zarrin'" >
< a -divider orientation = "right" > کارساخته ( زرین ) < / a-divider >
< div class = "grid-2" >
< a-form-item label = "نوع کارساختهی جدید (گرمی/تعدادی…)" >
< a-select v -model :value = "newTypeKey" :options = "typeOptions" allow -clear / >
< / a-form-item >
< a-form-item label = "گروه ساختهی پیشفرض" >
< a-select v -model :value = "newItemGroupKey" :options = "itemGroupOptions" allow -clear / >
< / a-form-item >
< / div >
< a-table :data-source = "categories" :pagination = "false" row -key = " id " size = "small" class = "mb-4" >
< a-table-column title = "دستهبندی اپ" data -index = " title " / >
< a-table-column title = "گروه ساخته در زرین" >
< template # default = "{ record }" >
< a-select v -model :value = "categoryGroupKeys[record.id]" :options = "itemGroupOptions" allow -clear class = "w-full" placeholder = "پیشفرض" / >
< / template >
< / a-table-column >
< / a-table >
< / template >
< div v-else-if = "form.provider === 'tahesab'" class="hint mb-4" >
تهحساب در API خود ساخت کارساخته ندارد : نام کار با اولین سند سفارش در تهحساب ساخته میشود و از آن به بعد به محصول لینک میشود.
< / div >
< a -button type = "primary" @click ="save" > ذخیره < / a -button >
< / a-form >
< / a-tab-pane >
<!-- ─ ─ لاگ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -- >
< a-tab-pane key = "outbox" : tab = "`لاگ ارسال${outboxBadge}`" >
< a-space class = "mb-4" >
< a-select v -model :value = "outboxStatus" style = "width: 180px" allow -clear placeholder = "همهی وضعیتها" @change ="loadOutbox(1)" >
< a -select -option v-for = "(label, key) in STATUS" :key="key" :value="key" > {{ label }} < / a -select -option >
< / a-select >
< a-button @click ="loadOutbox(outbox.page)" > تازهسازی < / a -button >
< / a-space >
< a-table
:data-source = "outbox.rows"
:columns = "outboxColumns"
row -key = " id "
size = "small"
: pagination = "{ current: outbox.page, pageSize: outbox.limit, total: outbox.total, onChange: loadOutbox }"
>
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key === 'createdAt'" > {{ jdt ( record.createdAt ) }} < / template >
< template v-else-if = "column.key === 'kind'" > {{ KIND [ record.kind ] ? ? record.kind }} < / template >
< template v-else-if = "column.key === 'entity'" > {{ record.entityLabel ? ? record.entityId }} < / template >
< template v-else-if = "column.key === 'status'" >
< a -tag :color = "STATUS_COLOR[record.status]" > { { STATUS [ record . status ] ? ? record . status } } < / a-tag >
< span v-if = "record.provider !== form.provider" class="hint" > ( {{ record.provider }} ) < / span >
< / template >
< template v-else-if = "column.key === 'error'" >
< span class = "warn small" > { { record . lastError } } < / span >
< / template >
< template v-else-if = "column.key === 'actions'" >
< a -space >
< a-popconfirm
v-if = "['FAILED', 'NEEDS_REVIEW', 'PENDING'].includes(record.status)"
title = "پیش از تلاش دوباره، مطمئن شوید سند در حسابداری ثبت نشده است. ادامه؟"
@confirm ="retry(record.id)"
>
< a-button size = "small" > تلاش دوباره < / a-button >
< / a-popconfirm >
< a-popconfirm
v-if = "['FAILED', 'NEEDS_REVIEW', 'PENDING'].includes(record.status)"
title = "این ردیف «حلشده» علامت بخورد (دستی در حسابداری رسیدگی شده)؟"
@confirm ="resolve(record.id)"
>
< a-button size = "small" > حل شده < / a-button >
< / a-popconfirm >
< / a-space >
< / template >
< / template >
< template # expandedRowRender = "{ record }" >
< div class = "grid-2" >
< div >
< b > درخواست < / b >
< pre dir = "ltr" > { { pretty ( record . lastRequest ) } } < / pre >
< / div >
< div >
< b > پاسخ < / b >
< pre dir = "ltr" > { { pretty ( record . lastResponse ) } } < / pre >
< / div >
< / div >
< div class = "hint" > کد مرجع : { { record . externalRef ? ? '—' } } | تلاش : { { record . attempts } } | تلاش بعدی : { { jdt ( record . nextAttemptAt ) } } < / div >
< / template >
< / a-table >
< / a-tab-pane >
<!-- ─ ─ مغایرت ها ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -- >
< a-tab-pane key = "issues" : tab = "`مغایرتها${overview?.openIssues ? ` (${overview.openIssues})` : ''}`" >
< a-table
:data-source = "issues.rows"
row -key = " id "
size = "small"
: pagination = "{ current: issues.page, pageSize: issues.limit, total: issues.total, onChange: loadIssues }"
>
< a-table-column title = "تاریخ" >
< template # default = "{ record }" > { { jdt ( record . updatedAt ) } } < / template >
< / a-table-column >
< a-table-column title = "نرمافزار" data -index = " provider " / >
< a-table-column title = "نوع" >
< template # default = "{ record }" > { { ISSUE [ record . kind ] ? ? record . kind } } < / template >
< / a-table-column >
< a-table-column title = "کد حسابداری" data -index = " externalCode " / >
< a-table-column title = "شرح" data -index = " message " / >
< a-table-column title = "" >
< template # default = "{ record }" >
< a-button size = "small" @click ="resolveIssue(record.id)" > رسیدگی شد < / a -button >
< / template >
< / a-table-column >
< / a-table >
< / a-tab-pane >
<!-- ─ ─ لینک ها ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -- >
< a-tab-pane key = "links" tab = "لینکها" >
< a-space class = "mb-4" >
< a-radio-group v -model :value = "linkType" @change ="loadLinks(1)" >
< a -radio -button value = "CUSTOMER" > مشتری < / a-radio-button >
< a-radio-button value = "PRODUCT" > کارساخته < / a-radio-button >
< / a-radio-group >
< a-input-search v -model :value = "linkQuery" placeholder = "کد یا نام در حسابداری" style = "width: 240px" @search ="loadLinks(1)" / >
< / a-space >
< a-table
:data-source = "links.rows"
row -key = " id "
size = "small"
: pagination = "{ current: links.page, pageSize: links.limit, total: links.total, onChange: loadLinks }"
>
< a-table-column title = "کد حسابداری" data -index = " externalCode " / >
< a-table-column title = "نام در حسابداری" data -index = " externalName " / >
< a-table-column title = "شناسهی اپ" data -index = " entityId " / >
< a-table-column title = "منشأ" >
< template # default = "{ record }" > { { ORIGIN [ record . origin ] ? ? record . origin } } < / template >
< / a-table-column >
< a-table-column title = "آخرین سینک" >
< template # default = "{ record }" > { { jdt ( record . syncedAt ) } } < / template >
< / a-table-column >
< a-table-column title = "" >
< template # default = "{ record }" >
< a-popconfirm title = "لینک حذف شود؟ در سینک بعدی دوباره با موبایل/نام جفت میشود." @confirm ="unlink(record.id)" >
< a -button size = "small" danger > حذف لینک < / a-button >
< / a-popconfirm >
< / template >
< / a-table-column >
< / a-table >
< / a-tab-pane >
<!-- ─ ─ گردش کارساخته ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -- >
< a-tab-pane key = "movement" tab = "گردش کارساخته" >
< a-space class = "mb-4" >
< a-input v -model :value = "movementFrom" dir = "ltr" placeholder = "از ۱۴۰۵/۰۷/۰۱" style = "width: 150px" / >
< a-input v -model :value = "movementTo" dir = "ltr" placeholder = "تا ۱۴۰۵/۰۷/۳۰" style = "width: 150px" / >
< a-button :disabled = "!active" :loading = "movementLoading" @click ="loadMovement" > نمایش < / a -button >
< / a-space >
< a-alert
v-if = "movement.scope === 'recent-documents'"
type = "info"
show -icon
class = "mb-4"
message = "تهحساب گزارش گردش همهی حسابها را ندارد؛ این فهرست از آخرین ۱۰۰۰ سند ثبتشده ساخته شده است."
/ >
< a-table :data-source = "movement.rows" : row -key = " ( _r : any , i : number ) = > i " size=" small ">
<a-table-column title=" تاریخ " data-index=" date " />
<a-table-column title=" کارساخته " data-index=" itemName " />
<a-table-column title=" شرح " data-index=" description " />
<a-table-column title=" وزن " data-index=" weight " />
<a-table-column title=" تعداد " data-index=" count " />
<a-table-column title=" مبلغ " data-index=" amount " />
</a-table>
</a-tab-pane>
</a-tabs>
</a-spin>
</a-card>
</div>
</template>
<script setup lang=" ts ">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import moment from 'moment-jalaali';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
import AccountingConnection from '../../components/accounting/AccountingConnection.vue';
type ProviderName = 'tahesab' | 'zarrin';
interface Group {
id: string;
name: string;
}
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const STATUS: Record<string, string> = {
PENDING: 'در صف',
SENDING: 'در حال ارسال',
SENT: 'ارسال شد',
FAILED: 'ناموفق',
NEEDS_REVIEW: 'نیاز به بررسی',
RESOLVED: 'حلشده',
};
const STATUS_COLOR: Record<string, string> = {
PENDING: 'default',
SENDING: 'processing',
SENT: 'success',
FAILED: 'error',
NEEDS_REVIEW: 'warning',
RESOLVED: 'default',
};
const KIND: Record<string, string> = {
CUSTOMER_PUSH: 'ارسال مشتری',
PRODUCT_PUSH: 'ساخت کارساخته',
ORDER_DOCUMENT: 'سند سفارش',
ORDER_DOCUMENT_DELETE: 'حذف سند سفارش',
};
const ISSUE: Record<string, string> = {
CUSTOMER_NO_MOBILE: 'حساب بدون موبایل',
CUSTOMER_DUPLICATE_MOBILE: 'موبایل تکراری',
CUSTOMER_LINK_CONFLICT: 'تداخل لینک',
PRODUCT_DUPLICATE_TITLE: 'عنوان تکراری محصول',
PRODUCT_CODE_TAKEN: 'کد محصول تکراری',
};
const ORIGIN: Record<string, string> = {
LINKED: 'جفت با موبایل/نام',
IMPORTED: 'وارد شده از حسابداری',
PUSHED: 'ساخته شده از اپ',
MANUAL: 'دستی',
};
const loading = ref(false);
const tab = ref('connection');
const overview = ref<any>(null);
const form = reactive<any>({
provider: 'none',
tahesab: { baseUrl: '', dbName: 'TahesabDB', timeoutMs: 20000, caCert: '' },
zarrin: { baseUrl: '', balanceOwesWhen: 'negative', timeoutMs: 20000, caCert: '' },
features: { customers: false, balances: false, report: false, products: false, documents: false },
intervals: { customersMinutes: 60, balancesMinutes: 10, productsMinutes: 30 },
mapping: { karat: 750 },
report: { windowDays: 365 },
documents: { triggerStatus: 'DELIVERED_TO_AGENT' },
});
const tokens = reactive<Record<ProviderName, string>>({ tahesab: '', zarrin: '' });
const savedProvider = ref('none');
const active = computed(() => savedProvider.value !== 'none');
// ── mapping: groups are stored as {id, name}; the selects use " id | name " keys ─ ─
const options = reactive < { customerGroups : Group [ ] ; karSakhteGroups : Group [ ] ; karSakhteTypes : Group [ ] } > ( {
customerGroups : [ ] ,
karSakhteGroups : [ ] ,
karSakhteTypes : [ ] ,
} ) ;
const categories = ref < { id : string ; title : string } [ ] > ( [ ] ) ;
const optionErrors = ref < string [ ] > ( [ ] ) ;
const optionsLoading = ref ( false ) ;
const keyOf = ( g : Group | null | undefined ) => ( g ? ` ${ g . id } | ${ g . name } ` : undefined ) ;
const groupOf = ( key : string | undefined | null ) : Group | null => {
if ( ! key ) return null ;
const i = key . indexOf ( '|' ) ;
return { id : key . slice ( 0 , i ) , name : key . slice ( i + 1 ) } ;
} ;
const asOptions = ( list : Group [ ] , selected : ( Group | null | undefined ) [ ] ) => {
const all = [ ... list ] ;
for ( const g of selected ) if ( g && ! all . some ( ( x ) => x . id === g . id && x . name === g . name ) ) all . push ( g ) ;
return all . map ( ( g ) => ( { value : keyOf ( g ) , label : g . id ? ` ${ g . name } ( ${ g . id } ) ` : g . name } ) ) ;
} ;
const importGroupKeys = ref < string [ ] > ( [ ] ) ;
const newCustomerGroupKey = ref < string | undefined > ( ) ;
const newItemGroupKey = ref < string | undefined > ( ) ;
const newTypeKey = ref < string | undefined > ( ) ;
const categoryGroupKeys = reactive < Record < string , string | undefined > > ( { } ) ;
const customerGroupOptions = computed ( ( ) =>
asOptions ( options . customerGroups , [ ... importGroupKeys . value . map ( groupOf ) , groupOf ( newCustomerGroupKey . value ) ] ) ,
) ;
const itemGroupOptions = computed ( ( ) =>
asOptions ( options . karSakhteGroups , [ groupOf ( newItemGroupKey . value ) , ... Object . values ( categoryGroupKeys ) . map ( groupOf ) ] ) ,
) ;
const typeOptions = computed ( ( ) => asOptions ( options . karSakhteTypes , [ groupOf ( newTypeKey . value ) ] ) ) ;
// Document rule (plan §12): the order status at which the پیشفاکتور is posted.
const triggerOptions = [
{ value : 'PLACED' , label : 'ثبت سفارش' } ,
{ value : 'ADMIN_APPROVED' , label : 'تأیید مدیر' } ,
{ value : 'SOURCING' , label : 'در حال تأمین' } ,
{ value : 'PREPARING' , label : 'در حال آمادهسازی' } ,
{ value : 'DELIVERED_TO_AGENT' , label : 'تحویل مسئول فروش' } ,
{ value : 'DELIVERED_TO_CUSTOMER' , label : 'تحویل مشتری' } ,
] ;
const featureRows = [
{ key : 'customers' , label : 'سینک مشتریان (ورود/لینک با موبایل، ساخت پس از احراز هویت)' , interval : 'customersMinutes' , job : 'customers' } ,
{ key : 'balances' , label : 'سینک مانده (نمایش به مشتری)' , interval : 'balancesMinutes' , job : 'balances' } ,
{ key : 'report' , label : 'گزارش مشتری از حسابداری' , interval : null , job : null } ,
{ key : 'products' , label : 'کارساخته ↔ محصول و قفل ناموجود' , interval : 'productsMinutes' , job : 'products' } ,
{ key : 'documents' , label : 'ثبت سند سفارش (موقت)' , interval : null , job : 'outbox' } ,
] ;
const featureColumns = [
{ title : 'قابلیت' , dataIndex : 'label' , key : 'label' } ,
{ title : 'فعال' , key : 'on' , width : 80 } ,
{ title : 'بازه' , key : 'interval' , width : 170 } ,
{ title : 'وضعیت' , key : 'state' } ,
{ title : '' , key : 'run' , width : 110 } ,
] ;
const jobState = ( job : string ) => overview . value ? . state ? . jobs ? . [ job ] ;
const summaryText = ( s : Record < string , unknown > ) =>
Object . entries ( s )
. map ( ( [ k , v ] ) => ` ${ k } : ${ v } ` )
. join ( '، ' ) ;
// ── outbox / issues / links / movement ──
const outbox = reactive < any > ( { rows : [ ] , total : 0 , page : 1 , limit : 25 } ) ;
const outboxStatus = ref < string | undefined > ( ) ;
const outboxColumns = [
{ title : 'زمان' , key : 'createdAt' , width : 150 } ,
{ title : 'نوع' , key : 'kind' , width : 120 } ,
{ title : 'مورد' , key : 'entity' } ,
{ title : 'وضعیت' , key : 'status' , width : 140 } ,
{ title : 'خطا' , key : 'error' } ,
{ title : '' , key : 'actions' , width : 190 } ,
] ;
const outboxBadge = computed ( ( ) => {
const o = overview . value ? . outbox ? ? { } ;
const n = ( o . FAILED ? ? 0 ) + ( o . NEEDS _REVIEW ? ? 0 ) ;
return n ? ` ( ${ n } ) ` : '' ;
} ) ;
const issues = reactive < any > ( { rows : [ ] , total : 0 , page : 1 , limit : 25 } ) ;
const links = reactive < any > ( { rows : [ ] , total : 0 , page : 1 , limit : 25 } ) ;
const linkType = ref < 'CUSTOMER' | 'PRODUCT' > ( 'CUSTOMER' ) ;
const linkQuery = ref ( '' ) ;
const movement = reactive < any > ( { rows : [ ] , scope : null } ) ;
const movementFrom = ref ( '' ) ;
const movementTo = ref ( '' ) ;
const movementLoading = ref ( false ) ;
const jdt = ( v ? : string | null ) => ( v ? moment ( v ) . format ( 'jYYYY/jMM/jDD HH:mm' ) : '—' ) ;
const pretty = ( v : unknown ) => ( v === null || v === undefined ? '—' : JSON . stringify ( v , null , 2 ) ) ;
const errMsg = ( e : any , fallback : string ) => {
const m = e ? . response ? . data ? . message ;
return Array . isArray ( m ) ? m . join ( '، ' ) : ( m ? ? fallback ) ;
} ;
function applyConfig ( c : any ) {
form . provider = c . provider ;
savedProvider . value = c . provider ;
for ( const p of [ 'tahesab' , 'zarrin' ] as const ) {
form [ p ] . baseUrl = c [ p ] . baseUrl ;
form [ p ] . timeoutMs = c [ p ] . timeoutMs ;
form [ p ] . caCert = c [ p ] . caCert ? ? '' ;
}
form . tahesab . dbName = c . tahesab . dbName ;
form . zarrin . balanceOwesWhen = c . zarrin . balanceOwesWhen ;
Object . assign ( form . features , c . features ) ;
Object . assign ( form . intervals , c . intervals ) ;
form . mapping . karat = c . mapping . karat ;
form . report . windowDays = c . report . windowDays ;
form . documents . triggerStatus = c . documents ? . triggerStatus ? ? 'DELIVERED_TO_AGENT' ;
importGroupKeys . value = ( c . mapping . importCustomerGroups ? ? [ ] ) . map ( keyOf ) ;
newCustomerGroupKey . value = keyOf ( c . mapping . newCustomerGroup ) ;
newItemGroupKey . value = keyOf ( c . mapping . newKarSakhteGroup ) ;
newTypeKey . value = keyOf ( c . mapping . newKarSakhteType ) ;
for ( const k of Object . keys ( categoryGroupKeys ) ) delete categoryGroupKeys [ k ] ;
for ( const x of c . mapping . categoryGroups ? ? [ ] ) categoryGroupKeys [ x . categoryId ] = keyOf ( x . group ) ;
}
async function load ( ) {
try {
loading . value = true ;
const { data } = await api . get ( 'admin/accounting' ) ;
overview . value = data ;
applyConfig ( data . config ) ;
} catch ( e : any ) {
message . error ( errMsg ( e , 'خطا در دریافت تنظیمات' ) ) ;
} finally {
loading . value = false ;
}
}
async function save ( ) {
const body : any = {
provider : form . provider ,
tahesab : { baseUrl : form . tahesab . baseUrl , dbName : form . tahesab . dbName , timeoutMs : form . tahesab . timeoutMs , caCert : form . tahesab . caCert } ,
zarrin : { baseUrl : form . zarrin . baseUrl , balanceOwesWhen : form . zarrin . balanceOwesWhen , timeoutMs : form . zarrin . timeoutMs , caCert : form . zarrin . caCert } ,
features : form . features ,
intervals : form . intervals ,
report : form . report ,
documents : form . documents ,
mapping : {
karat : form . mapping . karat ,
importCustomerGroups : importGroupKeys . value . map ( groupOf ) . filter ( Boolean ) ,
newCustomerGroup : groupOf ( newCustomerGroupKey . value ) ,
newKarSakhteGroup : groupOf ( newItemGroupKey . value ) ,
newKarSakhteType : groupOf ( newTypeKey . value ) ,
categoryGroups : Object . entries ( categoryGroupKeys )
. filter ( ( [ , k ] ) => ! ! k )
. map ( ( [ categoryId , k ] ) => ( { categoryId , group : groupOf ( k ) } ) ) ,
} ,
} ;
for ( const p of [ 'tahesab' , 'zarrin' ] as const ) if ( tokens [ p ] . trim ( ) ) body [ p ] . token = tokens [ p ] . trim ( ) ;
try {
loading . value = true ;
const { data } = await api . put ( 'admin/accounting/config' , body ) ;
overview . value = data ;
applyConfig ( data . config ) ;
tokens . tahesab = '' ;
tokens . zarrin = '' ;
message . success ( 'تنظیمات ذخیره شد' ) ;
} catch ( e : any ) {
message . error ( errMsg ( e , 'خطا در ذخیره' ) ) ;
} finally {
loading . value = false ;
}
}
async function clearToken ( p : ProviderName ) {
try {
const { data } = await api . put ( 'admin/accounting/config' , { [ p ] : { token : null } } ) ;
overview . value = data ;
message . success ( 'توکن حذف شد' ) ;
} catch ( e : any ) {
message . error ( errMsg ( e , 'خطا' ) ) ;
}
}
async function loadOptions ( ) {
optionsLoading . value = true ;
optionErrors . value = [ ] ;
try {
const { data } = await api . get ( 'admin/accounting/options' , { params : { provider : form . provider } } ) ;
options . customerGroups = data . customerGroups . items ;
options . karSakhteGroups = data . karSakhteGroups . items ;
options . karSakhteTypes = data . karSakhteTypes . items ;
categories . value = data . categories ;
for ( const x of [ data . customerGroups , data . karSakhteGroups , data . karSakhteTypes ] ) if ( ! x . ok ) optionErrors . value . push ( x . error ) ;
} catch ( e : any ) {
optionErrors . value = [ errMsg ( e , 'تنظیمات اتصال را اول ذخیره کنید' ) ] ;
} finally {
optionsLoading . value = false ;
}
}
async function run ( job : string ) {
try {
const { data } = await api . post ( ` admin/accounting/run/ ${ job } ` ) ;
if ( data . started ) message . success ( 'همگامسازی شروع شد؛ نتیجه چند لحظه بعد در همین صفحه دیده میشود' ) ;
else message . warning ( data . message ) ;
setTimeout ( load , 4000 ) ;
} catch ( e : any ) {
message . error ( errMsg ( e , 'خطا' ) ) ;
}
}
async function loadOutbox ( page = 1 ) {
const { data } = await api . get ( 'admin/accounting/outbox' , { params : { page , status : outboxStatus . value } } ) ;
Object . assign ( outbox , data ) ;
}
async function retry ( id : string ) {
try {
await api . post ( ` admin/accounting/outbox/ ${ id } /retry ` ) ;
message . success ( 'دوباره در صف قرار گرفت' ) ;
loadOutbox ( outbox . page ) ;
} catch ( e : any ) {
message . error ( errMsg ( e , 'خطا' ) ) ;
}
}
async function resolve ( id : string ) {
await api . post ( ` admin/accounting/outbox/ ${ id } /resolve ` , { } ) ;
loadOutbox ( outbox . page ) ;
}
async function loadIssues ( page = 1 ) {
const { data } = await api . get ( 'admin/accounting/issues' , { params : { page } } ) ;
Object . assign ( issues , data ) ;
}
async function resolveIssue ( id : string ) {
await api . post ( ` admin/accounting/issues/ ${ id } /resolve ` ) ;
loadIssues ( issues . page ) ;
load ( ) ;
}
async function loadLinks ( page = 1 ) {
const { data } = await api . get ( 'admin/accounting/links' , { params : { page , entityType : linkType . value , q : linkQuery . value || undefined } } ) ;
Object . assign ( links , data ) ;
}
async function unlink ( id : string ) {
await api . delete ( ` admin/accounting/links/ ${ id } ` ) ;
loadLinks ( links . page ) ;
}
async function loadMovement ( ) {
movementLoading . value = true ;
try {
const { data } = await api . get ( 'admin/accounting/movement' , {
params : { from : movementFrom . value || undefined , to : movementTo . value || undefined } ,
} ) ;
Object . assign ( movement , data ) ;
} catch ( e : any ) {
message . error ( errMsg ( e , 'خطا در دریافت گردش' ) ) ;
} finally {
movementLoading . value = false ;
}
}
watch ( tab , ( t ) => {
if ( t === 'outbox' ) loadOutbox ( 1 ) ;
if ( t === 'issues' ) loadIssues ( 1 ) ;
if ( t === 'links' ) loadLinks ( 1 ) ;
if ( t === 'mapping' && form . provider !== 'none' && categories . value . length === 0 ) loadOptions ( ) ;
} ) ;
onMounted ( load ) ;
< / script >
< style scoped >
. mb - 4 {
margin - bottom : 1 rem ;
}
. mt - 4 {
margin - top : 1 rem ;
}
. w - full {
width : 100 % ;
}
. hint {
font - size : 12 px ;
color : # 888 ;
margin - top : 4 px ;
}
. warn {
color : # d4380d ;
}
. ok {
color : # 389 e0d ;
}
. small {
font - size : 12 px ;
}
. link {
margin - inline - start : 8 px ;
cursor : pointer ;
}
pre {
max - height : 260 px ;
overflow : auto ;
background : rgba ( 0 , 0 , 0 , 0.03 ) ;
padding : 8 px ;
font - size : 11 px ;
white - space : pre - wrap ;
word - break : break - all ;
}
< / style >