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
@@ -0,0 +1,743 @@
<template>
<!--
«اتصال» tab of the accounting screen (D27), redesigned 2026-10-06.
One question per step, only what the chosen software needs:
status → 1. which software → 2. how to reach it → 3. its certificate
(only for https, shown as a fact, never as raw PEM) → test and save.
The form object is the page's own (save sends the whole configuration);
this component only edits the connection part of it.
-->
<div class="conn">
<!-- ── status ─────────────────────────────────────────────── -->
<div class="status" :class="`status--${status.tone}`">
<component :is="status.icon" class="status__icon" />
<div class="status__body">
<div class="status__title">
{{ status.title }}
<a-tag v-if="dirty" color="orange" class="status__tag">تغییرات ذخیره نشده</a-tag>
</div>
<div v-if="status.text" class="status__text">{{ status.text }}</div>
<dl v-if="status.rows?.length" class="kv status__rows">
<template v-for="row in status.rows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
</template>
</dl>
</div>
</div>
<!-- ── 1. software ────────────────────────────────────────── -->
<section class="step">
<h3 class="step__title"><span class="step__no">۱</span>نرم‌افزار حسابداری</h3>
<div class="choices" role="radiogroup">
<button
v-for="c in CHOICES"
:key="c.value"
type="button"
role="radio"
:aria-checked="form.provider === c.value"
class="choice"
:class="{ 'choice--on': form.provider === c.value }"
@click="form.provider = c.value"
>
<span class="choice__dot" />
<span class="choice__name">
{{ c.label }}
<a-tag v-if="c.value === 'zarrin'" color="gold" class="choice__tag">آزموده‌نشده</a-tag>
</span>
<span class="choice__desc">{{ c.desc }}</span>
</button>
</div>
<a-alert
v-if="providerChanged"
class="mt"
type="info"
show-icon
message="با ذخیره، نرم‌افزار عوض می‌شود"
description="کارهای در صف نرم‌افزار قبلی متوقف می‌مانند (پاک نمی‌شوند) و لینک‌های هر نرم‌افزار جدا نگه داشته می‌شوند؛ برگشت بی‌خطر است."
/>
</section>
<template v-if="p">
<!-- ── 2. connection details ──────────────────────────────── -->
<section class="step">
<h3 class="step__title"><span class="step__no">۲</span>اطلاعات اتصال به {{ LABEL[p] }}</h3>
<a-form layout="vertical" class="grid">
<a-form-item label="آدرس وب‌سرویس" required>
<a-input v-model:value="form[p].baseUrl" dir="ltr" class="ltr" :placeholder="p === 'tahesab' ? 'https://192.168.1.10:8081' : 'http://192.168.1.10:8000/ZarrinService/accounting'" />
<div class="help">آدرسی که وب‌سرویس {{ LABEL[p] }} روی آن در دسترس است، با پورت.</div>
</a-form-item>
<a-form-item v-if="p === 'tahesab'" label="نام دیتابیس (DBname)" required>
<a-input v-model:value="form.tahesab.dbName" dir="ltr" class="ltr" placeholder="TahesabDB" />
<div class="help">نام دقیق دیتابیس همین شرکت. یک سرور ممکن است چند دیتابیس داشته باشد؛ بعد از تست، نام را در نتیجه چک کنید.</div>
</a-form-item>
<a-form-item v-else label="علامت مانده‌ی بدهکار">
<a-select v-model:value="form.zarrin.balanceOwesWhen">
<a-select-option value="negative">منفی یعنی بدهکار</a-select-option>
<a-select-option value="positive">مثبت یعنی بدهکار</a-select-option>
</a-select>
<div class="help">در دوره‌ی آزمایشی با یک حساب نمونه بررسی شود.</div>
</a-form-item>
<a-form-item label="توکن وب‌سرویس" required class="grid__full">
<div v-if="tokenSaved && !editingToken[p]" class="token">
<LockOutlined class="token__icon" />
<span v-if="pub(p)?.tokenReadable">توکن ذخیره شده است و نمایش داده نمی‌شود.</span>
<span v-else class="text-danger">توکن ذخیره‌شده قابل‌خواندن نیست؛ دوباره وارد کنید.</span>
<a-space class="token__actions">
<a-button size="small" @click="editingToken[p] = true">تغییر توکن</a-button>
<a-popconfirm title="توکن حذف شود؟ تا توکن جدید وارد نشود اتصال کار نمی‌کند." ok-text="حذف" cancel-text="انصراف" @confirm="emit('clearToken', p)">
<a-button size="small" danger>حذف</a-button>
</a-popconfirm>
</a-space>
</div>
<template v-else>
<a-input-password v-model:value="tokens[p]" dir="ltr" class="ltr" autocomplete="new-password" placeholder="توکن را اینجا وارد کنید" />
<div class="help">
بعد از ذخیره، توکن رمز می‌شود و دیگر نمایش داده نمی‌شود.
<a v-if="tokenSaved" class="link" @click="cancelTokenEdit">انصراف و نگه‌داشتن توکن فعلی</a>
</div>
</template>
</a-form-item>
</a-form>
<a-collapse ghost class="advanced">
<a-collapse-panel key="adv" header="تنظیمات پیشرفته">
<a-form layout="vertical">
<a-form-item label="حداکثر زمان انتظار برای پاسخ">
<a-input-number v-model:value="timeoutSec" :min="3" :max="120" addon-after="ثانیه" />
<div class="help">اگر سرور حسابداری کند است بیشترش کنید. پیش‌فرض ۲۰ ثانیه.</div>
</a-form-item>
</a-form>
</a-collapse-panel>
</a-collapse>
</section>
<!-- ── 3. certificate (https only) ────────────────────────── -->
<section v-if="isHttps" class="step">
<h3 class="step__title"><span class="step__no">۳</span>گواهی امنیتی سرور</h3>
<p class="step__lead">
اتصال همیشه رمزنگاری‌شده و بررسی‌شده است. نصب‌های معمول {{ LABEL[p] }} گواهی خودساخته دارند؛ در این حالت یک بار گواهی سرور را بررسی و تأیید کنید.
</p>
<!-- trusted (pinned) -->
<div v-if="form[p].caCert" class="cert cert--ok">
<SafetyCertificateOutlined class="cert__icon" />
<div class="cert__body">
<div class="cert__title">
گواهی سرور تأیید شده است
<a-tag v-if="certUnsaved" color="orange">ذخیره نشده</a-tag>
<a-tag v-if="certInfo?.expired" color="red">منقضی شده</a-tag>
</div>
<dl v-if="certInfo" class="kv">
<dt>صادرشده برای</dt>
<dd dir="ltr" class="latin">{{ cn(certInfo.subject) }}</dd>
<dt>معتبر تا</dt>
<dd>{{ jdate(certInfo.validTo) }}</dd>
<dt>اثرانگشت</dt>
<dd dir="ltr" class="mono">{{ certInfo.fingerprint256 }}</dd>
</dl>
<div v-else class="help">مشخصات گواهی بعد از ذخیره نمایش داده می‌شود.</div>
</div>
<a-space direction="vertical" class="cert__actions">
<a-button size="small" :loading="checking" @click="checkCert">بررسی دوباره</a-button>
<a-button size="small" danger @click="form[p].caCert = ''">برداشتن</a-button>
</a-space>
</div>
<!-- just checked, waiting for a decision -->
<div v-else-if="checked && !checked.trustedBySystem" class="cert cert--ask">
<ExclamationCircleOutlined class="cert__icon" />
<div class="cert__body">
<div class="cert__title">این گواهی را تأیید می‌کنید؟</div>
<div class="help">اثرانگشت زیر را با گواهی نصب‌شده روی سرور حسابداری مقایسه کنید. اگر یکی است تأیید کنید.</div>
<dl class="kv">
<dt>صادرشده برای</dt>
<dd dir="ltr" class="latin">{{ cn(checked.subject) }}</dd>
<dt>معتبر تا</dt>
<dd>{{ jdate(checked.validTo) }}</dd>
<dt>اثرانگشت</dt>
<dd dir="ltr" class="mono">{{ checked.fingerprint256 }}</dd>
</dl>
<a-alert v-if="checked.expired" type="error" show-icon message="این گواهی منقضی شده است." class="mt" />
</div>
<a-space direction="vertical" class="cert__actions">
<a-button type="primary" size="small" @click="trustChecked">تأیید گواهی</a-button>
<a-button size="small" @click="checked = null">انصراف</a-button>
</a-space>
</div>
<!-- public certificate: nothing to do -->
<div v-else-if="checked?.trustedBySystem" class="cert cert--ok">
<SafetyCertificateOutlined class="cert__icon" />
<div class="cert__body">
<div class="cert__title">گواهی سرور معتبر است</div>
<div class="help">این سرور گواهی معتبر عمومی دارد؛ کاری لازم نیست.</div>
</div>
</div>
<!-- nothing yet -->
<div v-else class="cert">
<SafetyCertificateOutlined class="cert__icon cert__icon--muted" />
<div class="cert__body">
<div class="cert__title">گواهی‌ای تأیید نشده</div>
<div class="help">اگر «تست اتصال» خطای گواهی داد، اینجا گواهی سرور را بررسی کنید.</div>
</div>
<a-button :loading="checking" :disabled="!form[p].baseUrl" @click="checkCert">بررسی گواهی سرور</a-button>
</div>
<a-collapse ghost class="advanced">
<a-collapse-panel key="pem" header="وارد کردن دستی گواهی (PEM)">
<a-textarea v-model:value="form[p].caCert" dir="ltr" class="ltr mono" :rows="5" placeholder="-----BEGIN CERTIFICATE-----" />
</a-collapse-panel>
</a-collapse>
</section>
</template>
<!-- ── test result ────────────────────────────────────────── -->
<section v-if="p && result" class="step">
<a-alert v-if="result.ok" type="success" show-icon :message="`اتصال به ${LABEL[p]} برقرار است`">
<template #description>
<dl class="kv kv--inline">
<template v-for="row in resultRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
</template>
</dl>
<div v-if="dbMismatch" class="text-danger mt">
دیتابیسی که وصل شد <bdi class="latin">{{ result.info.dbName }}</bdi> است، نه <bdi class="latin">{{ form.tahesab.dbName }}</bdi>. نام دیتابیس را چک کنید.
</div>
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
</template>
</a-alert>
<a-alert v-else type="error" show-icon message="اتصال برقرار نشد">
<template #description>
<div>{{ result.error }}</div>
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
</template>
</a-alert>
</section>
<!-- ── actions ────────────────────────────────────────────── -->
<div class="bar">
<span class="bar__note">
<template v-if="dirty">تغییرات هنوز ذخیره نشده‌اند.</template>
<template v-else-if="p">«تست اتصال» مقادیر همین فرم را امتحان می‌کند، حتی پیش از ذخیره.</template>
</span>
<a-space>
<a-button v-if="p" :loading="testing" :disabled="!canTest" @click="test">تست اتصال</a-button>
<a-button type="primary" :loading="saving" :disabled="!dirty" @click="emit('save')">ذخیره</a-button>
</a-space>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
CheckCircleFilled,
CloseCircleFilled,
ExclamationCircleOutlined,
LockOutlined,
MinusCircleFilled,
SafetyCertificateOutlined,
} from '@ant-design/icons-vue';
import moment from 'moment-jalaali';
import api from '../../api/client';
type ProviderName = 'tahesab' | 'zarrin';
const props = defineProps<{
overview: any;
form: any;
tokens: Record<ProviderName, string>;
saving: boolean;
}>();
const emit = defineEmits<{ (e: 'save'): void; (e: 'clearToken', p: ProviderName): void }>();
const LABEL: Record<ProviderName, string> = { tahesab: 'ته‌حساب', zarrin: 'زرین' };
const CHOICES: { value: 'none' | ProviderName; label: string; desc: string }[] = [
{ value: 'none', label: 'بدون اتصال', desc: 'هیچ اطلاعاتی با حسابداری رد و بدل نمی‌شود.' },
{ value: 'tahesab', label: 'ته‌حساب', desc: 'اتصال به وب‌سرویس نرم‌افزار ته‌حساب.' },
{ value: 'zarrin', label: 'زرین', desc: 'اتصال به وب‌سرویس نرم‌افزار زرین.' },
];
const p = computed<ProviderName | null>(() => (props.form.provider === 'none' ? null : props.form.provider));
const saved = computed(() => props.overview?.config ?? null);
const pub = (name: ProviderName) => saved.value?.[name];
const tokenSaved = computed(() => !!(p.value && pub(p.value)?.tokenSet));
const isHttps = computed(() => !!p.value && /^https:\/\//i.test(props.form[p.value].baseUrl || ''));
// ── dirty: the connection part of the form against what is saved ──
const snapshot = (src: any, provider: string) =>
JSON.stringify(
provider === 'none'
? { provider }
: {
provider,
baseUrl: src[provider]?.baseUrl ?? '',
caCert: src[provider]?.caCert ?? '',
timeoutMs: src[provider]?.timeoutMs,
dbName: provider === 'tahesab' ? src.tahesab?.dbName : undefined,
balanceOwesWhen: provider === 'zarrin' ? src.zarrin?.balanceOwesWhen : undefined,
},
);
const dirty = computed(() => {
if (!saved.value) return false;
if (props.form.provider !== saved.value.provider) return true;
if (!p.value) return false;
return snapshot(props.form, p.value) !== snapshot(saved.value, p.value) || !!props.tokens[p.value]?.trim();
});
const providerChanged = computed(() => !!saved.value && saved.value.provider !== 'none' && saved.value.provider !== props.form.provider);
// ── status card ──
interface StatusRow {
label: string;
value: string;
ltr?: boolean;
}
const jdt = (v?: string | null) => (v ? moment(v).format('jYYYY/jMM/jDD ساعت HH:mm') : '');
const status = computed(() => {
const ov = props.overview;
if (!ov) return { tone: 'off', icon: MinusCircleFilled, title: '…', text: '', rows: [] as StatusRow[] };
const c = ov.config;
if (c.provider === 'none') {
return { tone: 'off', icon: MinusCircleFilled, title: 'اتصال خاموش است', text: 'هیچ اطلاعاتی با نرم‌افزار حسابداری رد و بدل نمی‌شود.' };
}
const label = LABEL[c.provider as ProviderName];
if (ov.inactiveReason) {
return { tone: 'bad', icon: CloseCircleFilled, title: `اتصال به ${label} کار نمی‌کند`, text: ov.inactiveReason };
}
const rt = ov.realtime;
if (rt?.lastError) {
return { tone: 'bad', icon: CloseCircleFilled, title: `${label}: ارتباط برقرار نیست`, text: rt.lastError };
}
const conn = c[c.provider];
const rows: StatusRow[] = [{ label: 'آدرس', value: conn.baseUrl, ltr: true }];
if (c.provider === 'tahesab') rows.push({ label: 'دیتابیس', value: c.tahesab.dbName, ltr: true });
if (rt?.lastOkAt) rows.push({ label: 'آخرین ارتباط موفق', value: jdt(rt.lastOkAt) });
return { tone: 'ok', icon: CheckCircleFilled, title: `متصل به ${label}`, text: '', rows };
});
// ── token ──
const editingToken = reactive<Record<ProviderName, boolean>>({ tahesab: false, zarrin: false });
function cancelTokenEdit() {
if (!p.value) return;
props.tokens[p.value] = '';
editingToken[p.value] = false;
}
watch(
() => props.overview,
() => {
editingToken.tahesab = false;
editingToken.zarrin = false;
checked.value = null;
},
);
// ── timeout shown in seconds ──
const timeoutSec = computed({
get: () => (p.value ? Math.round((props.form[p.value].timeoutMs ?? 20000) / 1000) : 20),
set: (v: number) => {
if (p.value) props.form[p.value].timeoutMs = Math.max(3, Math.min(120, Number(v) || 20)) * 1000;
},
});
// ── certificate ──
const checking = ref(false);
const checked = ref<any>(null);
const cn = (subject: string) => (subject.match(/CN=([^\n,]+)/)?.[1] ?? subject.replace(/\n/g, ', '));
const jdate = (v: string) => (v ? moment(new Date(v)).format('jYYYY/jMM/jDD') : '—');
const certUnsaved = computed(() => !!p.value && (props.form[p.value].caCert || '') !== (pub(p.value)?.caCert || ''));
const certInfo = computed(() => {
if (!p.value) return null;
if (!certUnsaved.value) return pub(p.value)?.caInfo ?? null;
return checked.value && checked.value.pem === props.form[p.value].caCert ? checked.value : null;
});
async function checkCert() {
if (!p.value) return;
checking.value = true;
try {
const { data } = await api.post('admin/accounting/certificate', { baseUrl: props.form[p.value].baseUrl });
checked.value = data;
if (props.form[p.value].caCert && data.pem.trim() === props.form[p.value].caCert.trim()) {
message.success('گواهی سرور همان گواهی تأییدشده است');
} else if (props.form[p.value].caCert) {
// A different certificate than the trusted one: ask again.
props.form[p.value].caCert = '';
}
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'گواهی دریافت نشد');
} finally {
checking.value = false;
}
}
function trustChecked() {
if (!p.value || !checked.value) return;
props.form[p.value].caCert = checked.value.pem;
}
// ── test ──
const testing = ref(false);
const result = ref<any>(null);
const testedWith = ref('');
const fingerprint = () => (p.value ? JSON.stringify([props.form.provider, props.form[p.value], props.tokens[p.value]]) : '');
const resultStale = computed(() => !!result.value && testedWith.value !== fingerprint());
const canTest = computed(() => !!p.value && !!props.form[p.value].baseUrl && (tokenSaved.value || !!props.tokens[p.value]?.trim()));
watch(p, () => (result.value = null));
async function test() {
if (!p.value) return;
const name = p.value;
testing.value = true;
try {
const connection: Record<string, unknown> = {
baseUrl: props.form[name].baseUrl,
timeoutMs: props.form[name].timeoutMs,
caCert: props.form[name].caCert,
token: props.tokens[name] || undefined,
};
if (name === 'tahesab') connection.dbName = props.form.tahesab.dbName;
else connection.balanceOwesWhen = props.form.zarrin.balanceOwesWhen;
testedWith.value = fingerprint();
const { data } = await api.post('admin/accounting/test', { provider: name, connection });
result.value = data;
} catch (e: any) {
const m = e?.response?.data?.message;
result.value = { ok: false, error: Array.isArray(m) ? m.join('، ') : (m ?? 'خطا در تست اتصال') };
} finally {
testing.value = false;
}
}
const INFO_LABELS: Record<string, { label: string; ltr?: boolean }> = {
version: { label: 'نسخه', ltr: true },
dbType: { label: 'نوع دیتابیس', ltr: true },
dbName: { label: 'دیتابیس', ltr: true },
customerGroups: { label: 'گروه‌های حساب' },
message: { label: 'پیام سرور' },
};
const resultRows = computed(() => {
const info = result.value?.info ?? {};
const rows = Object.entries(INFO_LABELS)
.filter(([k]) => info[k] !== undefined && info[k] !== null && info[k] !== '')
.map(([k, v]) => ({ label: v.label, value: String(info[k]), ltr: v.ltr }));
rows.push({ label: 'زمان پاسخ', value: `${(result.value?.ms ?? 0).toLocaleString('fa-IR')} میلی‌ثانیه`, ltr: false });
return rows;
});
const dbMismatch = computed(
() =>
p.value === 'tahesab' &&
!!result.value?.info?.dbName &&
String(result.value.info.dbName).toLowerCase() !== String(props.form.tahesab.dbName || '').toLowerCase(),
);
</script>
<style scoped>
.conn {
max-width: 920px;
}
.mt {
margin-top: 12px;
}
.help {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
margin-top: 4px;
line-height: 1.8;
}
.link {
margin-inline-start: 8px;
cursor: pointer;
}
.text-danger {
color: #cf1322;
}
.ltr {
direction: ltr;
text-align: left;
}
.ltr :deep(input),
.ltr :deep(textarea) {
direction: ltr;
text-align: left;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
/* status */
.status {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 16px 18px;
border-radius: 10px;
border: 1px solid;
margin-bottom: 24px;
}
.status__icon {
font-size: 22px;
margin-top: 2px;
}
.status__title {
font-weight: 700;
font-size: 15px;
}
.status__tag {
margin-inline-start: 8px;
font-weight: 400;
}
.status__text {
font-size: 13px;
margin-top: 4px;
color: rgba(0, 0, 0, 0.65);
overflow-wrap: anywhere;
}
.status__rows {
margin-top: 6px;
}
/* URLs, database names, fingerprints: Latin digits, left-to-right */
.latin,
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
unicode-bidi: isolate;
}
.status--ok {
background: #f6ffed;
border-color: #b7eb8f;
}
.status--ok .status__icon {
color: #52c41a;
}
.status--bad {
background: #fff2f0;
border-color: #ffccc7;
}
.status--bad .status__icon {
color: #ff4d4f;
}
.status--off {
background: #fafafa;
border-color: #f0f0f0;
}
.status--off .status__icon {
color: #bfbfbf;
}
/* steps */
.step {
padding: 4px 0 24px;
margin-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.step__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 700;
margin: 0 0 14px;
}
.step__no {
display: inline-grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #1f2a5c;
color: #fff;
font-size: 13px;
}
.step__lead {
margin: -4px 0 14px;
color: rgba(0, 0, 0, 0.6);
font-size: 13px;
line-height: 1.9;
}
/* provider choice */
.choices {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.choice {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
text-align: start;
padding: 14px 16px;
border: 1px solid #d9d9d9;
border-radius: 10px;
background: #fff;
cursor: pointer;
font: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.choice:hover {
border-color: #8c9bd1;
}
.choice:focus-visible {
outline: 2px solid #1f2a5c;
outline-offset: 2px;
}
.choice--on {
border-color: #1f2a5c;
box-shadow: 0 0 0 1px #1f2a5c;
background: #f5f7ff;
}
.choice__dot {
grid-row: span 2;
width: 16px;
height: 16px;
margin-top: 3px;
border-radius: 50%;
border: 2px solid #bfbfbf;
}
.choice--on .choice__dot {
border: 5px solid #1f2a5c;
}
.choice__name {
font-weight: 700;
}
.choice__tag {
margin-inline-start: 6px;
font-weight: 400;
}
.choice__desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
}
/* form grid */
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
}
.grid__full {
grid-column: 1 / -1;
}
.advanced {
margin-top: 4px;
}
.advanced :deep(.ant-collapse-header) {
padding-inline: 0 !important;
color: rgba(0, 0, 0, 0.6);
}
/* token */
.token {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.token__icon {
color: #52c41a;
}
.token__actions {
margin-inline-start: auto;
}
/* certificate */
.cert {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 16px;
border: 1px solid #f0f0f0;
border-radius: 10px;
background: #fafafa;
}
.cert--ok {
background: #f6ffed;
border-color: #d9f7be;
}
.cert--ask {
background: #fffbe6;
border-color: #ffe58f;
}
.cert__icon {
font-size: 22px;
color: #52c41a;
margin-top: 2px;
}
.cert--ask .cert__icon {
color: #faad14;
}
.cert__icon--muted {
color: #bfbfbf;
}
.cert__body {
flex: 1;
min-width: 0;
}
.cert__title {
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.cert__actions {
flex-shrink: 0;
}
/* key/value lists */
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 14px;
margin: 8px 0 0;
font-size: 12.5px;
}
.kv dt {
color: rgba(0, 0, 0, 0.5);
}
.kv dd {
margin: 0;
text-align: start;
}
.kv dd[dir='ltr'] {
text-align: right;
}
/* action bar */
.bar {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.bar__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
@media (max-width: 760px) {
.choices,
.grid {
grid-template-columns: 1fr;
}
.cert {
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,76 @@
<template>
<!--
D27, plan §12 — «سینک با حسابداری» on a list page (wholesale customers,
products). Shown only while an accounting software is connected; runs the
sync on the server, waits for it and reports what happened.
-->
<a-button v-if="connected" :loading="busy" @click="run">
<template #icon><SyncOutlined /></template>
سینک با {{ provider || 'حسابداری' }}
</a-button>
</template>
<script setup lang="ts">
import { h, onMounted, ref } from 'vue';
import { Modal, message } from 'ant-design-vue';
import { SyncOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
const props = defineProps<{ endpoint: string }>();
const emit = defineEmits<{ (e: 'done'): void }>();
const connected = ref(false);
const provider = ref<string | null>(null);
const busy = ref(false);
const JOB_LABELS: Record<string, string> = { customers: 'مشتریان', balances: 'مانده‌ها', products: 'کارساخته‌ها و موجودی' };
const KEY_LABELS: Record<string, string> = {
scanned: 'بررسی‌شده',
inScope: 'در گروه‌های انتخابی',
refreshed: 'به‌روز',
linked: 'لینک‌شده',
imported: 'وارد شده',
issues: 'مغایرت',
updated: 'به‌روز',
items: 'کارساخته',
stockErrors: 'خطای موجودی',
};
const describe = (summary: unknown) =>
summary && typeof summary === 'object'
? Object.entries(summary as Record<string, unknown>)
.map(([k, v]) => `${KEY_LABELS[k] ?? k}: ${v}`)
.join('، ')
: '';
onMounted(async () => {
try {
const { data } = await api.get('admin/accounting-status');
connected.value = !!data?.connected;
provider.value = data?.provider ?? null;
} catch {
connected.value = false;
}
});
async function run() {
busy.value = true;
try {
const { data } = await api.post(props.endpoint);
const rows = Object.entries(data.results as Record<string, { ok: boolean; summary?: unknown; error?: string }>).map(
([job, r]) =>
h('p', { style: r.ok ? '' : 'color:#d4380d' }, `${JOB_LABELS[job] ?? job}: ${r.ok ? describe(r.summary) || 'انجام شد' : `خطا — ${r.error}`}`),
);
(data.ok ? Modal.success : Modal.warning)({
title: data.ok ? 'سینک با حسابداری انجام شد' : 'سینک با حسابداری کامل انجام نشد',
content: h('div', rows),
okText: 'باشه',
});
emit('done');
} catch (e: any) {
message.error(e?.response?.data?.message || 'سینک با حسابداری انجام نشد');
} finally {
busy.value = false;
}
}
</script>
@@ -0,0 +1,142 @@
<template>
<!--
D27 — the customer's side in the accounting software: which account it is
linked to, and both balances side by side (the customer sees the
accounting one; the credit limit is checked against the app's own).
-->
<a-card v-if="visible" size="small" title="حسابداری" class="mt-4">
<a-spin :spinning="loading">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="کد حساب">
<template v-if="info?.link">
{{ info.link.externalCode }}
<a-tag class="ms-2">{{ ORIGIN[info.link.origin] ?? info.link.origin }}</a-tag>
</template>
<span v-else class="muted">لینک نشده</span>
</a-descriptions-item>
<a-descriptions-item label="مانده‌ی حسابداری (گرم / ریال)">
<template v-if="info?.accounting">
<span :class="sign(info.accounting.gold)">{{ num(info.accounting.gold, 3) }}</span> /
<span :class="sign(info.accounting.rial)">{{ num(info.accounting.rial, 0) }}</span>
<div class="muted small">{{ jdt(info.accounting.at) }}</div>
</template>
<span v-else class="muted">—</span>
</a-descriptions-item>
<a-descriptions-item label="مانده‌ی دفتر اپ (گرم / ریال)">
<span :class="sign(info?.internal?.gold)">{{ num(info?.internal?.gold, 3) }}</span> /
<span :class="sign(info?.internal?.rial)">{{ num(info?.internal?.rial, 0) }}</span>
<div class="muted small">مبنای سقف اعتبار</div>
</a-descriptions-item>
</a-descriptions>
<div v-if="lastJob" class="muted small mt-2">
آخرین ارسال: {{ STATUS[lastJob.status] ?? lastJob.status }} — {{ jdt(lastJob.updatedAt) }}
<span v-if="lastJob.lastError" class="warn"> — {{ lastJob.lastError }}</span>
</div>
<a-space class="mt-2">
<a-input v-model:value="code" dir="ltr" placeholder="کد حساب در حسابداری" style="width: 200px" />
<a-button size="small" :disabled="!code.trim()" @click="link">لینک دستی</a-button>
<a-popconfirm v-if="info?.link" title="لینک حذف شود؟" @confirm="unlink">
<a-button size="small" danger>حذف لینک</a-button>
</a-popconfirm>
</a-space>
</a-spin>
</a-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import moment from 'moment-jalaali';
import api from '../../api/client';
import { useUsersStore } from '../../stores/users';
const props = defineProps<{ customerId: string }>();
const ORIGIN: Record<string, string> = {
LINKED: 'جفت با موبایل',
IMPORTED: 'وارد شده از حسابداری',
PUSHED: 'ساخته شده از اپ',
MANUAL: 'دستی',
};
const STATUS: Record<string, string> = {
PENDING: 'در صف',
SENDING: 'در حال ارسال',
SENT: 'ارسال شد',
FAILED: 'ناموفق',
NEEDS_REVIEW: 'نیاز به بررسی',
RESOLVED: 'حل‌شده',
};
const users = useUsersStore();
const info = ref<any>(null);
const loading = ref(false);
const code = ref('');
const visible = computed(() => Boolean(users.hasPermission('config.accounting')) && info.value && info.value.provider !== 'none');
const lastJob = computed(() => info.value?.jobs?.[0]);
const num = (v: unknown, places: number) => (v === null || v === undefined ? '—' : Number(v).toLocaleString('fa-IR', { maximumFractionDigits: places }));
const sign = (v: unknown) => (Number(v) > 0 ? 'debt' : Number(v) < 0 ? 'credit' : '');
const jdt = (v?: string | null) => (v ? moment(v).format('jYYYY/jMM/jDD HH:mm') : '');
async function load() {
if (!users.hasPermission('config.accounting')) return;
loading.value = true;
try {
const { data } = await api.get(`admin/accounting/customers/${props.customerId}`);
info.value = data;
} catch {
info.value = null;
} finally {
loading.value = false;
}
}
async function link() {
try {
await api.post('admin/accounting/links', { entityType: 'CUSTOMER', entityId: props.customerId, externalCode: code.value.trim() });
message.success('لینک شد');
code.value = '';
load();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا');
}
}
async function unlink() {
if (!info.value?.link) return;
await api.delete(`admin/accounting/links/${info.value.link.id}`);
load();
}
onMounted(load);
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.ms-2 {
margin-inline-start: 0.5rem;
}
.muted {
color: #888;
}
.small {
font-size: 12px;
}
.warn {
color: #d4380d;
}
.debt {
color: #cf1322;
}
.credit {
color: #389e0d;
}
</style>
@@ -463,6 +463,14 @@ export const adminMenu: NavMenuNode[] = [
i18n: 'مدیریت کیف پول',
permissionId:"config.wallet"
},
// D27 — Tahesab / Zarrin integration.
{
url: '/config/manage/accounting',
name: 'accounting Integration',
slug: 'manage-accounting',
i18n: 'اتصال حسابداری',
permissionId:"config.accounting"
},
// Moved under "پیکر بندی" per request — same node shape as before
// (own submenu / plain leaf), just relocated in the tree.
{
+21
View File
@@ -101,6 +101,27 @@ export const adminRoute: RouteRecordRaw[] = [
no_scroll: true
}
},
{
path: '/config/manage/accounting',
component: () => import('../views/manage/accounting.vue'),
meta: {
breadcrumb: [
{
title: 'خانه',
url: '/'
},
{
title: 'اتصال حسابداری',
active: true
}
],
pageTitle: 'اتصال حسابداری',
rule: 'editor',
role: 'config.accounting',
parent: 'manage',
no_scroll: true
}
},
{
path: '/config/manage/report',
component: () => import('../views/manage/report.vue'),
@@ -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",
},
]
},
{