feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps

- 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>
This commit is contained in:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
@@ -83,16 +83,18 @@
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-3">
<a-form-item label="کد محصول">
<a-input v-model:value="form.product_code" />
<!-- D27 — a product linked to the accounting software takes its
title and code from there; the API refuses a change too. -->
<a-form-item label="کد محصول" :help="accountingLocked ? 'از نرم‌افزار حسابداری' : undefined">
<a-input v-model:value="form.product_code" :disabled="accountingLocked" />
</a-form-item>
<a-form-item
label="نام محصول"
:validate-status="err.title ? 'error' : ''"
:help="err.title"
:help="err.title || (accountingLocked ? 'از نرم‌افزار حسابداری — برای تغییر، آن را در حسابداری ویرایش کنید' : undefined)"
>
<a-input v-model:value="form.title" />
<a-input v-model:value="form.title" :disabled="accountingLocked" />
</a-form-item>
<a-form-item label="نوع نمایش">
@@ -286,6 +288,8 @@ const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const id = route.params.id as string | undefined;
const accountingLocked = ref(false);
const accountingWage = ref<number | undefined>(undefined);
const loading = ref(false);
const brands = ref<{ id: string; title: string }[]>([]);
@@ -382,7 +386,8 @@ function showModal(record?: VariantRow, index?: number) {
id: undefined,
title: '',
grams: undefined,
percentage_pay: undefined,
// D27 — the accounting software's sale making charge, as a starting value.
percentage_pay: accountingWage.value,
qty: 0,
is_active: true,
is_pre_order: false,
@@ -470,6 +475,9 @@ onMounted(async () => {
if (id) {
try {
const { data } = await api.get(`product/show/${id}`);
accountingLocked.value = Boolean(data.accounting?.locked);
accountingWage.value =
data.accounting?.sale_wage_percent != null ? Number(data.accounting.sale_wage_percent) : undefined;
Object.assign(form, {
title: data.title ?? '',
product_code: data.product_code ?? '',
@@ -6,6 +6,10 @@
-->
<div id="page-user-list">
<a-card title="لیست محصولات">
<template #extra>
<!-- D27 §12: کارساخته و موجودی از حسابداری -->
<AccountingSyncButton v-if="hasPermission('product.list')" endpoint="admin/products/accounting-sync" @done="tblProducts?.fetch()" />
</template>
<h6>تعداد {{ selectedRows.length }} محصول انتخاب شده است.</h6>
<XTable
@@ -28,6 +32,17 @@
{{ record.show_type_str }}
</template>
<template #accounting="{ record }">
<div v-if="record.accounting_stock" class="acc-stock">
موجودی:
<span :class="stockOf(record) <= 0 ? 'text-danger' : ''">
{{ record.accounting_stock.weight != null ? `${Number(record.accounting_stock.weight)} گرم` : `${Number(record.accounting_stock.count)} عدد` }}
</span>
</div>
<a-tag v-if="!record.ProductImage || !record.ProductImage.length" color="orange">بدون تصویر — در اپ پنهان</a-tag>
<a-tag v-else-if="record.accounting_stock && stockOf(record) <= 0" color="red">ناموجود در حسابداری</a-tag>
</template>
<template #op="{ record }">
<div v-if="!is_branch" class="w-48 flex">
<a-tooltip v-if="hasPermission('product.list.edit')" title="ویرایش">
@@ -102,6 +117,7 @@ import {
ExclamationCircleOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import AccountingSyncButton from '../../../components/accounting/AccountingSyncButton.vue';
import { buildProductListModel } from './productListModel';
import { useUsersStore } from '../../../stores/users';
import { isBranch } from '../../../utils';
@@ -146,6 +162,8 @@ function confirmDelete(id: string) {
return confirmDeleteById(`delete/product/${id}`, { method: 'post' });
}
const stockOf = (r: Record<string, any>) => Number(r.accounting_stock?.weight ?? r.accounting_stock?.count ?? 0);
function toEdit(id: string) {
router.push(`/apps/product/edit/${id}`);
}
@@ -23,6 +23,8 @@ export function buildProductListModel(): XTbl {
})
.renderSlot('show_type'),
);
// D27 — stock in the accounting software, and whether the app shows it at all.
tbl.add(new Xtc('accounting_stock', 'حسابداری / نمایش').renderSlot('accounting').noSort());
tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort());
return tbl;
}
@@ -42,6 +42,9 @@
</div>
</a-card>
<!-- D27 — link and balance in the accounting software. -->
<CustomerAccountingCard :customer-id="id" />
<a-tabs class="mt-4" :default-active-key="defaultTab">
<a-tab-pane key="orders" tab="سفارش ها">
<a-table
@@ -115,6 +118,7 @@ import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { jdate, jdateTime } from '../../utils/filters';
import api from '../../api/client';
import CustomerAccountingCard from '../../components/accounting/CustomerAccountingCard.vue';
const props = withDefaults(
defineProps<{
@@ -13,6 +13,9 @@
-->
<div id="page-user-list">
<a-card :title="title">
<template v-if="accountingSync" #extra>
<AccountingSyncButton endpoint="admin/customers-omde/accounting-sync" @done="tbl?.fetch()" />
</template>
<div v-if="query && query.count && query.total">
{{ query.total }} رکورد یافت شد -
<span class="text-danger">{{ query.count }} رکورد غیر فعال</span>
@@ -241,6 +244,7 @@ import {
} from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import ListPageHeader from '../../../components/common/ListPageHeader.vue';
import AccountingSyncButton from '../../../components/accounting/AccountingSyncButton.vue';
import BulkCustomer from '../../apps/customer/bulkCustomer.vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
@@ -261,8 +265,10 @@ const props = withDefaults(
rejectOnlyStepOne?: boolean;
/** Only OmdeList.vue passes this — see the comment on its call site. */
showAddButton?: boolean;
/** D27 §12 — «سینک با حسابداری» for the whole list; only OmdeList.vue. */
accountingSync?: boolean;
}>(),
{ rejectOnlyStepOne: false, showAddButton: false },
{ rejectOnlyStepOne: false, showAddButton: false, accountingSync: false },
);
const router = useRouter();
@@ -3,9 +3,9 @@
Ported from legacy views/customer/omde/OmdeList.vue (615 lines).
Add button removed per request: hide the "افزودن مشتری عمده" action on
this list. OmdeCustomerList.vue's showAddButton prop still exists in case
it needs to come back.
it needs to come back. The accounting sync button (D27 §12) is on this list only.
-->
<OmdeCustomerList :title="title" :model="model" />
<OmdeCustomerList :title="title" :model="model" accounting-sync />
</template>
<script setup lang="ts">
@@ -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: 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>
@@ -838,6 +838,10 @@ export const treeData: PermissionNode[] = [
"title": "گزارش",
"key": "config.report",
},
{
"title": "اتصال حسابداری",
"key": "config.accounting",
},
]
},
{