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:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user