- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
699 lines
33 KiB
Vue
699 lines
33 KiB
Vue
<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: 1rem;
|
||
}
|
||
.mt-4 {
|
||
margin-top: 1rem;
|
||
}
|
||
.w-full {
|
||
width: 100%;
|
||
}
|
||
.hint {
|
||
font-size: 12px;
|
||
color: #888;
|
||
margin-top: 4px;
|
||
}
|
||
.warn {
|
||
color: #d4380d;
|
||
}
|
||
.ok {
|
||
color: #389e0d;
|
||
}
|
||
.small {
|
||
font-size: 12px;
|
||
}
|
||
.link {
|
||
margin-inline-start: 8px;
|
||
cursor: pointer;
|
||
}
|
||
pre {
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
background: rgba(0, 0, 0, 0.03);
|
||
padding: 8px;
|
||
font-size: 11px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
</style>
|