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
+27
-4
@@ -38,6 +38,20 @@ JWT_CUSTOMER_SECRET=
|
||||
# Customer token lifetime (default 30d).
|
||||
JWT_CUSTOMER_EXPIRES_IN=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accounting software integration (Tahesab / Zarrin, D27). Everything else —
|
||||
# which software, address, token, mappings — is set in the admin panel
|
||||
# («پیکر بندی ← اتصال حسابداری»), and the integration is off until then.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encrypts the accounting token at rest. Set a long random value. Blank falls
|
||||
# back to a key derived from JWT_SECRET (with a boot warning); changing that
|
||||
# key later means re-entering the token in the admin panel.
|
||||
ACCOUNTING_SECRET_KEY=
|
||||
# "off" keeps this API instance out of the background sync (only needed when
|
||||
# several instances run and you want exactly one doing it; a lease already
|
||||
# stops two from running the same job).
|
||||
ACCOUNTING_WORKER=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Central IAM — delegated admin login (docs/iam-sso.md)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -88,12 +102,21 @@ AUTH_SESSION_TRACKING=
|
||||
OTP_MASTER_CODE=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMS gateway (Trez). Leave blank to disable sending: the server still boots,
|
||||
# messages are logged instead of sent, and customers cannot receive a login
|
||||
# code (D20). Never commit real values.
|
||||
# SMS gateway. The default "relay" driver works with everything blank (D26).
|
||||
# A failed SMS never stops the server or an operation (D20).
|
||||
# ---------------------------------------------------------------------------
|
||||
# "trez" (default) or "none" to disable explicitly.
|
||||
# "relay" (default), "trez", or "none" to disable explicitly.
|
||||
SMS_PROVIDER=
|
||||
# relay — the owner's createsms gateway. All four are optional.
|
||||
# URL and secret have built-in defaults.
|
||||
SMS_RELAY_URL=
|
||||
SMS_RELAY_SECRET=
|
||||
# The "host" field. Blank sends this machine's first external IPv4 — inside
|
||||
# Docker that is the container's address.
|
||||
SMS_RELAY_HOST=
|
||||
# "kave" (default, Kavenegar) or "default" to let the gateway pick the line.
|
||||
SMS_RELAY_PLATFORM=
|
||||
# trez — only used with SMS_PROVIDER=trez. Never commit real values.
|
||||
# Override only for staging; defaults to the provider's live endpoint.
|
||||
SMS_TREZ_URL=
|
||||
SMS_TREZ_USERNAME=
|
||||
|
||||
+19
-9
@@ -39,10 +39,9 @@ Everything else is optional and may be left blank. Two you may want now:
|
||||
# real release (see "Before the real release" below).
|
||||
OTP_MASTER_CODE=1379
|
||||
|
||||
# Leave blank and SMS is logged, not sent. The server still starts.
|
||||
SMS_TREZ_USERNAME=
|
||||
SMS_TREZ_PASSWORD=
|
||||
SMS_TREZ_SENDER_LINE=
|
||||
# SMS goes through the "relay" gateway with nothing to set (D26).
|
||||
# SMS_PROVIDER=none turns sending off; the server still starts either way.
|
||||
SMS_PROVIDER=
|
||||
```
|
||||
|
||||
### Step 2 — build
|
||||
@@ -115,7 +114,9 @@ before it is a production release. The first three are blocking.
|
||||
| **1** | **Set `JWT_CUSTOMER_SECRET` to its own random value**, different from `JWT_SECRET`. | Not a cosmetic default. Unset, the API signs customer and sales-expert **app** tokens with `JWT_SECRET` — *the same key admin tokens use* — and the two token realms stop being cryptographically separate. The only remaining barrier is the `typ` check in `jwt.strategy.ts`, and a sales expert's app token carries their **AdminUser id** in `sub`. Two independent defences collapse into one. A loud boot banner fires while it is missing. **REL-03 (Critical).** |
|
||||
| **2** | **Remove `OTP_MASTER_CODE` from the environment.** | While set, **any active customer signs in with that one fixed code**. Unset or empty disables it completely; the API prints a banner at boot for as long as it is armed. **REL-01 (Critical).** |
|
||||
| **3** | **Set real `JWT_SECRET`.** | Without it the API falls back to a well-known development string, so anyone can forge an admin token. |
|
||||
| **4** | **Configure the SMS credentials** (`SMS_TREZ_USERNAME`, `SMS_TREZ_PASSWORD`, `SMS_TREZ_SENDER_LINE`). | Until then **no customer can receive a real login code** — messages are written to the log instead of sent. The server still boots (D20). **REL-02.** |
|
||||
| **4** | **Confirm SMS is going out.** The default `relay` gateway needs no credentials (D26); check `docker compose logs api` for `SMS provider: relay` and send yourself a login code. | If the gateway refuses the `host` value (inside Docker it is the container's IP), set `SMS_RELAY_HOST`. A failed send is only a log line — the server still boots (D20). **REL-02.** |
|
||||
|
||||
| **5** | **Accounting integration (D27), only if it will be switched on:** set `ACCOUNTING_SECRET_KEY`; count products with no image (they disappear from the app — query in ISSUES ACC-05) and photograph them first; for Zarrin, run the trial on temporary documents first (ACC-01). | The integration itself is off until an admin turns it on in «اتصال حسابداری»; nothing changes before that except the no-image rule, which applies to every product. |
|
||||
|
||||
After changing any of these: `docker compose up -d --build` and confirm the
|
||||
`OTP_MASTER_CODE` banner is **gone** from `docker compose logs api`.
|
||||
@@ -189,14 +190,21 @@ container.
|
||||
| `ADMIN_PANEL_PORT`, `USER_APP_PORT`, `USER_APP_V2_PORT` | 5180–5182 | Dev servers only. |
|
||||
| `RUN_MIGRATIONS` | `true` | Set `false` to skip the boot-time migration run. |
|
||||
| `RUN_SEED` | `true` | Set `false` to skip seeding. |
|
||||
| `ACCOUNTING_SECRET_KEY` | *(derived from `JWT_SECRET`)* | Encrypts the accounting-software token the admin enters (D27). Set its own long random value; changing it later means re-entering the token. |
|
||||
| `ACCOUNTING_WORKER` | `on` | `off` keeps this instance out of the accounting background sync. |
|
||||
| `PROFILE_ADMIN_MOBILES` | *(blank)* | Comma-separated admin mobiles told when a customer completes their profile. The reference hardcoded four numbers; blank means nobody is notified and the upload still succeeds. |
|
||||
|
||||
### SMS — all optional; blank means "log, do not send"
|
||||
### SMS — all optional
|
||||
|
||||
`SMS_PROVIDER` (`trez` default, or `none`), `SMS_TREZ_URL`,
|
||||
`SMS_TREZ_USERNAME`, `SMS_TREZ_PASSWORD`, `SMS_TREZ_SENDER_LINE`.
|
||||
`SMS_PROVIDER`: `relay` (default), `trez`, or `none` (D26).
|
||||
|
||||
With these unset the server **still starts normally** and logs a warning; a
|
||||
- **relay**: `SMS_RELAY_URL`, `SMS_RELAY_SECRET` (both have built-in
|
||||
defaults), `SMS_RELAY_HOST` (blank = the machine's first external IPv4),
|
||||
`SMS_RELAY_PLATFORM` (`kave` default, or `default` to let the gateway pick).
|
||||
- **trez**: `SMS_TREZ_URL`, `SMS_TREZ_USERNAME`, `SMS_TREZ_PASSWORD`,
|
||||
`SMS_TREZ_SENDER_LINE`.
|
||||
|
||||
The server **always starts**; a misconfigured provider is a warning, and a
|
||||
failed or skipped SMS never blocks an operation, including customer creation.
|
||||
|
||||
### ⚠ `OTP_MASTER_CODE` — delivery only
|
||||
@@ -405,4 +413,6 @@ Recorded in `docs/parity/DECISIONS.md`:
|
||||
the lowest-risk option, change nothing protected.
|
||||
- **D20** — SMS can never block a boot or an operation; `OTP_MASTER_CODE` for
|
||||
the delivery.
|
||||
- **D26** — SMS goes through the owner's `relay` gateway by default; Trez is
|
||||
kept as a selectable driver.
|
||||
- **D11 is cancelled** — superseded by D13.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+22
-7
@@ -15,19 +15,28 @@ JWT_CUSTOMER_EXPIRES_IN=30d
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMS gateway — Trez panel (port of henza_server/app/Models/Sms.php:57-82)
|
||||
# ---------------------------------------------------------------------------
|
||||
# NEVER commit real values. The reference hardcoded its live production
|
||||
# Trez: NEVER commit real values. The reference hardcoded its live production
|
||||
# credentials in the source (SEC-12); those are treated as compromised and are
|
||||
# not reused. Set the real values in .env, which is gitignored.
|
||||
#
|
||||
# In production the server REFUSES TO START when the three required variables
|
||||
# below are unset, because without them no customer can receive a login code.
|
||||
# Outside production it only warns, and OTP codes are logged locally instead.
|
||||
# The server always starts (D20). A misconfigured provider only warns, and
|
||||
# outside production OTP codes are then logged locally instead.
|
||||
# With nothing set, the relay driver SENDS REAL SMS (D26) — set
|
||||
# SMS_PROVIDER=none on a development machine that must not.
|
||||
|
||||
# "trez" (default) or "none" to disable sending entirely.
|
||||
SMS_PROVIDER=trez
|
||||
# "relay" (default, D26), "trez", or "none" to disable sending entirely.
|
||||
SMS_PROVIDER=relay
|
||||
# relay — all optional; URL and secret have built-in defaults.
|
||||
SMS_RELAY_URL=
|
||||
SMS_RELAY_SECRET=
|
||||
# The "host" field; blank sends this machine's first external IPv4.
|
||||
SMS_RELAY_HOST=
|
||||
# "kave" (default, Kavenegar) or "default" to let the gateway pick the line.
|
||||
SMS_RELAY_PLATFORM=
|
||||
# trez — only used with SMS_PROVIDER=trez.
|
||||
# Defaults to the reference endpoint when unset; override for staging.
|
||||
SMS_TREZ_URL=
|
||||
# Required in production:
|
||||
# Required for trez:
|
||||
SMS_TREZ_USERNAME=
|
||||
SMS_TREZ_PASSWORD=
|
||||
# The panel's sender line — the `PhoneNumber` form field.
|
||||
@@ -36,3 +45,9 @@ SMS_TREZ_SENDER_LINE=
|
||||
# hardcoded six mobile numbers across its controllers (STUB-05); empty means
|
||||
# no admin notifications are sent.
|
||||
SMS_ADMIN_RECIPIENTS=
|
||||
|
||||
# Accounting integration (D27) — configured in the admin panel; these two only
|
||||
# protect the stored token and control the background worker.
|
||||
ACCOUNTING_SECRET_KEY=
|
||||
# "off" keeps this instance out of the background sync.
|
||||
ACCOUNTING_WORKER=
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
-- Accounting integration (D27): Tahesab / Zarrin, chosen in the admin panel.
|
||||
-- Plan: docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
||||
--
|
||||
-- Adds the link table (app row <-> accounting record), the outbox the push
|
||||
-- jobs run from, the issue list pulls report into, and the cached balance and
|
||||
-- stock columns read back from the accounting software. Nothing existing
|
||||
-- changes meaning: goldDebtGrams/fiatDebtRial stay the internal ledger caches.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Customer" ADD COLUMN "accountingBalanceAt" TIMESTAMP(3),
|
||||
ADD COLUMN "accountingGoldBalance" DECIMAL(14,4),
|
||||
ADD COLUMN "accountingRialBalance" DECIMAL(20,2);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "accountingStockAt" TIMESTAMP(3),
|
||||
ADD COLUMN "accountingStockCount" DECIMAL(14,4),
|
||||
ADD COLUMN "accountingStockWeight" DECIMAL(14,4);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"entityType" "AccountingEntityType" NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"externalCode" TEXT NOT NULL,
|
||||
"externalName" TEXT,
|
||||
"origin" TEXT NOT NULL,
|
||||
"snapshot" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccountingLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingOutbox" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"kind" "AccountingOutboxKind" NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"status" "AccountingOutboxStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastError" TEXT,
|
||||
"externalRef" TEXT,
|
||||
"progress" JSONB,
|
||||
"lastRequest" JSONB,
|
||||
"lastResponse" JSONB,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"resolvedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AccountingOutbox_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingIssue" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"externalCode" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"message" TEXT NOT NULL,
|
||||
"data" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "AccountingIssue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode");
|
||||
|
||||
|
||||
-- The new admin screen's permission. The seeded super-admin user type (85,
|
||||
-- prisma/seed.ts) gets every rid in ALL_RIDS on a fresh seed; an existing
|
||||
-- database has to be granted the new one here.
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT 'config.accounting', 85
|
||||
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -400,6 +400,14 @@ model Customer {
|
||||
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
||||
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
|
||||
|
||||
/// Balance as last read from the accounting software (D27), in the app's own
|
||||
/// sign convention (positive = owes). Shown to the customer when the
|
||||
/// integration is on; the credit-limit check still uses goldDebtGrams above.
|
||||
/// Null until the first successful sync.
|
||||
accountingGoldBalance Decimal? @db.Decimal(14, 4)
|
||||
accountingRialBalance Decimal? @db.Decimal(20, 2)
|
||||
accountingBalanceAt DateTime?
|
||||
|
||||
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
|
||||
/// ADDED to the variant's making charge when pricing an order line
|
||||
/// (henza_pwa src/Mixins/product/index.ts:69-75:
|
||||
@@ -557,6 +565,14 @@ model Product {
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
/// Stock of the linked کارساخته as last read from the accounting software
|
||||
/// (D27). Weight for gram-type items, count for count-type ones — whichever
|
||||
/// the software reports. At zero or below the product is unavailable in the
|
||||
/// app (pre-order variants excepted). Null = not linked or never synced.
|
||||
accountingStockWeight Decimal? @db.Decimal(14, 4)
|
||||
accountingStockCount Decimal? @db.Decimal(14, 4)
|
||||
accountingStockAt DateTime?
|
||||
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
collections CollectionProduct[]
|
||||
@@ -911,6 +927,115 @@ model Setting {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Accounting integration (D27) — the shop's accounting software, Tahesab or
|
||||
// Zarrin, chosen in the admin panel. Configuration lives in the Setting row
|
||||
// `accounting.config` (never served by the generic `const` routes). Plan:
|
||||
// docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
||||
//
|
||||
// `provider` is the driver name ('tahesab' | 'zarrin'), a plain string like
|
||||
// SMS_PROVIDER so a new driver needs no migration. Links are per provider, so
|
||||
// switching provider and back loses nothing.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AccountingEntityType {
|
||||
CUSTOMER
|
||||
PRODUCT
|
||||
}
|
||||
|
||||
/// One app row ↔ one record in the accounting software (customer account or
|
||||
/// کارساخته).
|
||||
model AccountingLink {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
entityType AccountingEntityType
|
||||
entityId String
|
||||
/// The record's code in the accounting software (Tahesab Moshtari_Code /
|
||||
/// Zarrin idHesab; for products the Zarrin idKar or the Tahesab Name_Kar).
|
||||
externalCode String
|
||||
externalName String?
|
||||
/// How the link came to be: LINKED (matched by mobile/name), IMPORTED (app
|
||||
/// row created from the accounting record), PUSHED (accounting record created
|
||||
/// from the app row) or MANUAL (an admin typed the code).
|
||||
origin String
|
||||
/// The record as last read, for display only.
|
||||
snapshot Json?
|
||||
syncedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([provider, entityType, entityId])
|
||||
@@unique([provider, entityType, externalCode])
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
enum AccountingOutboxKind {
|
||||
CUSTOMER_PUSH
|
||||
PRODUCT_PUSH
|
||||
ORDER_DOCUMENT
|
||||
ORDER_DOCUMENT_DELETE
|
||||
}
|
||||
|
||||
enum AccountingOutboxStatus {
|
||||
PENDING
|
||||
/// A call was in flight. Found at startup, it means the process died mid-call
|
||||
/// and the outcome is unknown — see AccountingOutboxWorker.
|
||||
SENDING
|
||||
SENT
|
||||
FAILED
|
||||
/// Unsafe to retry automatically (e.g. a Zarrin call timed out: Zarrin has no
|
||||
/// idempotency key, so a blind retry could post the document twice).
|
||||
NEEDS_REVIEW
|
||||
/// Closed by an admin from the log screen.
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
/// Work to push to the accounting software. Written in the SAME transaction as
|
||||
/// the change that causes it, so an outage of the accounting software never
|
||||
/// loses an event and never blocks the app.
|
||||
model AccountingOutbox {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind AccountingOutboxKind
|
||||
entityId String
|
||||
status AccountingOutboxStatus @default(PENDING)
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lastError String?
|
||||
/// What the accounting software returned as the document/record id.
|
||||
externalRef String?
|
||||
/// Per-step progress of multi-call jobs (a Zarrin document is one call per
|
||||
/// line), so a failure resumes where it stopped.
|
||||
progress Json?
|
||||
/// Last request/response, for the log screen. Never contains the token.
|
||||
lastRequest Json?
|
||||
lastResponse Json?
|
||||
sentAt DateTime?
|
||||
resolvedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([kind, entityId])
|
||||
}
|
||||
|
||||
/// Something a pull found that needs a human: an account with no mobile, two
|
||||
/// accounts sharing one mobile, a کارساخته group with no category mapping.
|
||||
model AccountingIssue {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind String
|
||||
externalCode String
|
||||
entityId String?
|
||||
message String
|
||||
data Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
resolvedAt DateTime?
|
||||
|
||||
@@unique([provider, kind, externalCode])
|
||||
@@index([resolvedAt])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Promotions / discounts — legacy views/promotion/*, served by
|
||||
// PromotionController (`product/variant/promotion`). The redesigned schema had
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Accounting integration (D27) — end to end against Tahesab's PUBLIC TEST
|
||||
* server (https://api-test.tahesab.ir), on a THROWAWAY database.
|
||||
*
|
||||
* Runs the real services (Nest application context), not mocks:
|
||||
* 1. config: provider switch, token sealed, generic `const` routes fenced off
|
||||
* 2. connection test
|
||||
* 3. F1 verified customer → pushed (searched by mobile first, so re-runs reuse
|
||||
* the same test account) → linked
|
||||
* 4. F1 customer pull (one group only) imports accounts into the app
|
||||
* 5. F2 balance pull + the figure /customer/me now shows
|
||||
* 6. F5 a product with no image is not in the catalog and cannot be ordered
|
||||
* 7. F3 an order reaching DELIVERED_TO_AGENT queues a document; the worker
|
||||
* posts it as TEMPORARY; the rows in Tahesab move exactly the order's
|
||||
* gold (karat-with-charge rule)
|
||||
* 8. F6 «گزارش» reads the Tahesab statement
|
||||
* 9. F3 cancelling the order deletes the (still temporary) document
|
||||
* 10. F4 item pull: Tahesab items become hidden products
|
||||
*
|
||||
* The document is created temporary and deleted again; the only lasting trace
|
||||
* on the test server is one test account (mobile 09990001111) that later runs
|
||||
* reuse.
|
||||
*
|
||||
* Usage (database name must contain "test" or "e2e" — it is written freely):
|
||||
* DATABASE_URL=postgresql://…/goldbank_acc_e2e TAHESAB_TEST_TOKEN=<token from the docs page> \
|
||||
* ACCOUNTING_SECRET_KEY=x npx ts-node --transpile-only scripts/test-accounting-tahesab.ts
|
||||
*/
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { OrderStatus, Prisma, PrismaClient, VerificationStep } from '@prisma/client';
|
||||
|
||||
process.env.ACCOUNTING_WORKER = 'off';
|
||||
process.env.IAM_ENABLED = 'false';
|
||||
|
||||
const DB = process.env.DATABASE_URL ?? '';
|
||||
const TOKEN = process.env.TAHESAB_TEST_TOKEN ?? '';
|
||||
if (!/test|e2e/i.test(new URL(DB.replace(/^postgresql/, 'http')).pathname)) {
|
||||
console.error('Refusing: DATABASE_URL must point to a throwaway database (name containing "test" or "e2e").');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!TOKEN) {
|
||||
console.error('TAHESAB_TEST_TOKEN is required (the bearer token shown on https://tahesab.ir/APiDoc/).');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const chk = (c: unknown, m: string) => {
|
||||
if (c) {
|
||||
pass += 1;
|
||||
console.log(` PASS ${m}`);
|
||||
} else {
|
||||
fail += 1;
|
||||
console.log(` FAIL ${m}`);
|
||||
}
|
||||
};
|
||||
const step = (t: string) => console.log(`\n${t}`);
|
||||
|
||||
async function main() {
|
||||
const prisma0 = new PrismaClient();
|
||||
const { runMigrations } = await import('../src/prisma/migrate');
|
||||
await runMigrations(prisma0);
|
||||
await prisma0.$disconnect();
|
||||
|
||||
const { AppModule } = await import('../src/app.module');
|
||||
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
|
||||
const { PrismaService } = await import('../src/prisma/prisma.service');
|
||||
const { AccountingConfigService } = await import('../src/modules/accounting-sync/accounting-config.service');
|
||||
const { AccountingAdminService } = await import('../src/modules/accounting-sync/accounting-admin.service');
|
||||
const { AccountingSyncService, factorCodeFor } = await import('../src/modules/accounting-sync/accounting-sync.service');
|
||||
const { AccountingWorkerService } = await import('../src/modules/accounting-sync/accounting-worker.service');
|
||||
const { AdminCustomersService } = await import('../src/modules/admin-customers/admin-customers.service');
|
||||
const { AdminMiscService } = await import('../src/modules/admin-misc/admin-misc.service');
|
||||
const { OrdersService } = await import('../src/modules/orders/orders.service');
|
||||
const { CatalogService } = await import('../src/modules/catalog/catalog.service');
|
||||
const { CustomersService } = await import('../src/modules/customers/customers.service');
|
||||
const { CustomerReportService } = await import('../src/modules/customer-report/customer-report.service');
|
||||
|
||||
const prisma = app.get(PrismaService);
|
||||
const config = app.get(AccountingConfigService);
|
||||
const admin = app.get(AccountingAdminService);
|
||||
const sync = app.get(AccountingSyncService);
|
||||
const worker = app.get(AccountingWorkerService);
|
||||
const customersAdmin = app.get(AdminCustomersService);
|
||||
const misc = app.get(AdminMiscService);
|
||||
const orders = app.get(OrdersService);
|
||||
const catalog = app.get(CatalogService);
|
||||
const customers = app.get(CustomersService);
|
||||
const report = app.get(CustomerReportService);
|
||||
|
||||
// ── 1 ──
|
||||
step('1. configuration');
|
||||
await config.save({
|
||||
provider: 'tahesab',
|
||||
tahesab: { baseUrl: 'https://api-test.tahesab.ir', dbName: 'TahesabDB', token: TOKEN, timeoutMs: 30000 },
|
||||
features: { customers: true, balances: true, report: true, products: true, documents: true },
|
||||
mapping: { importCustomerGroups: [{ id: '0', name: 'مشتري' }], newCustomerGroup: { id: '2', name: 'بنکدار' }, karat: 750 },
|
||||
});
|
||||
const pub = await config.getPublic();
|
||||
chk(pub.provider === 'tahesab', 'provider switch saved');
|
||||
chk(pub.tahesab.tokenSet && pub.tahesab.tokenReadable, 'token stored and readable by the server');
|
||||
chk(!JSON.stringify(pub).includes(TOKEN), 'the admin view never contains the token');
|
||||
const row = await prisma.setting.findUnique({ where: { key: 'accounting.config' } });
|
||||
chk(!JSON.stringify(row?.value).includes(TOKEN), 'the token is not stored in plain text');
|
||||
const consts = await misc.listConsts();
|
||||
chk(!consts.some((c: { key: string }) => c.key.startsWith('accounting.')), 'GET const does not list accounting.* rows');
|
||||
|
||||
// ── 2 ──
|
||||
step('2. connection test');
|
||||
const t = await admin.test('tahesab');
|
||||
chk(t.ok, `health: ${JSON.stringify(t).slice(0, 160)}`);
|
||||
|
||||
// ── 3 ──
|
||||
step('3. F1 push a verified customer');
|
||||
const mobile = '09990001111';
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { mobile },
|
||||
update: {},
|
||||
create: { fullName: 'gold-bank smoke', firstName: 'gold-bank', lastName: 'smoke', mobile, nationalCode: mobile, passwordHash: '' },
|
||||
});
|
||||
await prisma.customer.update({ where: { id: customer.id }, data: { verificationStep: VerificationStep.STEP_TWO_SUBMITTED } });
|
||||
await customersAdmin.verify({ customer_id: customer.id, sts_verify_step_two: 2 });
|
||||
const queued = await prisma.accountingOutbox.findFirst({ where: { kind: 'CUSTOMER_PUSH', entityId: customer.id } });
|
||||
chk(queued?.status === 'PENDING', 'VERIFIED queues a CUSTOMER_PUSH in the same transaction');
|
||||
await worker.tick('outbox');
|
||||
const pushed = await prisma.accountingOutbox.findUnique({ where: { id: queued!.id } });
|
||||
chk(pushed?.status === 'SENT', `push sent (${pushed?.status} ${pushed?.lastError ?? ''})`);
|
||||
const link = await prisma.accountingLink.findFirst({ where: { entityType: 'CUSTOMER', entityId: customer.id } });
|
||||
chk(!!link?.externalCode, `customer linked to Tahesab account ${link?.externalCode} (${link?.origin})`);
|
||||
|
||||
// ── 4 ──
|
||||
step('4. F1 pull (GID 0 «مشتري» only)');
|
||||
const before = await prisma.customer.count();
|
||||
const pulled = await sync.pullCustomers((await config.active())!);
|
||||
const after = await prisma.customer.count();
|
||||
chk(pulled.scanned > 0, `scanned ${pulled.scanned} accounts, ${pulled.inScope} in scope`);
|
||||
chk(after - before === pulled.imported, `imported ${pulled.imported}, linked ${pulled.linked}, issues ${pulled.issues}`);
|
||||
const imported = await prisma.accountingLink.findFirst({ where: { origin: 'IMPORTED' } });
|
||||
if (imported) {
|
||||
const c = await prisma.customer.findUnique({ where: { id: imported.entityId } });
|
||||
chk(c?.mobile?.startsWith('09') && c.passwordHash === '' && c.goldCreditLimitGrams.eq(0), 'imported customer keyed by mobile, OTP-only, credit 0');
|
||||
}
|
||||
|
||||
// ── 5 ──
|
||||
step('5. F2 balances');
|
||||
const b = await sync.pullBalances((await config.active())!);
|
||||
chk(b.updated > 0, `balances updated for ${b.updated}/${b.linked} linked customers`);
|
||||
const me = await customers.getOrThrow(customer.id);
|
||||
chk(me.balanceSource === 'accounting', `/customer/me shows the accounting balance (${String(me.goldDebtGrams)} g)`);
|
||||
|
||||
// ── 6 ──
|
||||
step('6. F5 no image, no app');
|
||||
const bare = await prisma.product.create({
|
||||
data: { title: 'gb-e2e-noimage', variants: { create: { title: 'v', rashWeightGrams: 1, defaultWagePercent: 10, qty: 5 } } },
|
||||
include: { variants: true },
|
||||
});
|
||||
chk(!(await catalog.listProducts()).some((p) => p.id === bare.id), 'imageless product is not in the catalog');
|
||||
let refused = false;
|
||||
try {
|
||||
await catalog.getVariantOrThrow(bare.variants[0].id);
|
||||
} catch {
|
||||
refused = true;
|
||||
}
|
||||
chk(refused, 'imageless product cannot be ordered');
|
||||
|
||||
// ── 7 ──
|
||||
step('7. F3 order document');
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
title: 'gb-e2e-انگشتر',
|
||||
images: { create: { url: '/uploads/e2e.jpg' } },
|
||||
variants: {
|
||||
create: [
|
||||
{ title: '4.2g', rashWeightGrams: '4.2', defaultWagePercent: 10, qty: 10 },
|
||||
{ title: '10g', rashWeightGrams: '10', defaultWagePercent: 18.5, qty: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { variants: true },
|
||||
});
|
||||
chk((await catalog.listProducts()).some((p) => p.id === product.id), 'product with an image is in the catalog');
|
||||
const order = await orders.createOrder(
|
||||
{
|
||||
items: [
|
||||
{ productVariantId: product.variants[0].id, quantity: 2, wagePercent: 10 },
|
||||
{ productVariantId: product.variants[1].id, quantity: 1, wagePercent: 18.5, flatRialCharge: 500000 },
|
||||
],
|
||||
} as never,
|
||||
customer.id,
|
||||
);
|
||||
await orders.updateStatus(order.id, OrderStatus.DELIVERED_TO_AGENT);
|
||||
const docJob = await prisma.accountingOutbox.findFirst({ where: { kind: 'ORDER_DOCUMENT', entityId: order.id } });
|
||||
chk(docJob?.status === 'PENDING', 'delivery queued the document with the debt');
|
||||
await worker.tick('outbox');
|
||||
const docSent = await prisma.accountingOutbox.findUnique({ where: { id: docJob!.id } });
|
||||
chk(docSent?.status === 'SENT', `document posted (${docSent?.status} ${docSent?.lastError ?? ''})`);
|
||||
const a = (await config.active())!;
|
||||
const factorCode = factorCodeFor(order.id);
|
||||
// Whatever happens below, the test document must not stay on the shared server.
|
||||
cleanup = async () => {
|
||||
await a.provider.deleteDocument(a.settings, { factorCode, ref: null, customerCode: link!.externalCode }).catch(() => undefined);
|
||||
};
|
||||
chk(await a.provider.documentExists(a.settings, { factorCode, ref: null }), `Tahesab finds ${factorCode}`);
|
||||
const st = await a.provider.getStatement(a.settings, { code: link!.externalCode, from: toJ(), to: toJ() });
|
||||
const rows = st.filter((r) => r.documentRef === factorCode);
|
||||
chk(rows.length === 2 && rows.every((r) => r.temporary), `2 temporary rows in Tahesab (${rows.length})`);
|
||||
const moved = rows.reduce((s, r) => s.add(r.goldOut), new Prisma.Decimal(0));
|
||||
const total = new Prisma.Decimal(order.totalGoldWeightGrams);
|
||||
chk(moved.sub(total).abs().lte('0.001'), `rows move ${moved.toFixed(3)} g = order total ${total.toFixed(3)} g`);
|
||||
|
||||
// ── 8 ──
|
||||
step('8. F6 report from Tahesab');
|
||||
const sum = (await report.summary(customer.id, 1)) as { source?: string; result: { factor_Code: string }[] };
|
||||
chk(sum.source === 'accounting', 'summary comes from the accounting software');
|
||||
chk(sum.result.some((r) => r.factor_Code === factorCode), 'the new document is in the statement');
|
||||
const other = (await report.detail(customer.id, 'not-mine-000000000000')) as { result: unknown[] };
|
||||
chk(other.result.length === 0, 'SEC-16: an id outside the account matches nothing');
|
||||
|
||||
// ── 9 ──
|
||||
step('9. F3 cancellation removes the temporary document');
|
||||
await orders.updateStatus(order.id, OrderStatus.CANCELLED);
|
||||
await worker.tick('outbox');
|
||||
const del = await prisma.accountingOutbox.findFirst({ where: { kind: 'ORDER_DOCUMENT_DELETE', entityId: order.id } });
|
||||
chk(del?.status === 'SENT', `delete job sent (${del?.status} ${del?.lastError ?? ''})`);
|
||||
chk(!(await a.provider.documentExists(a.settings, { factorCode, ref: null })), 'document gone from Tahesab');
|
||||
|
||||
// ── 10 ──
|
||||
step('10. F4 item pull');
|
||||
const items = await sync.pullProducts(a);
|
||||
chk(items.items > 0, `items ${items.items}: imported ${items.imported}, linked ${items.linked}, updated ${items.updated}`);
|
||||
const itemLink = await prisma.accountingLink.findFirst({ where: { entityType: 'PRODUCT', origin: 'IMPORTED' } });
|
||||
if (itemLink) {
|
||||
chk(!(await catalog.listProducts()).some((p) => p.id === itemLink.entityId), 'an imported item stays hidden until it gets an image');
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
await app.close();
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
|
||||
let cleanup: () => Promise<void> = async () => undefined;
|
||||
|
||||
function toJ() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { toJalali } = require('../src/modules/accounting-sync/jalali');
|
||||
return toJalali(new Date());
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
Logger.error(err);
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { CustomerReportModule } from './modules/customer-report/customer-report.
|
||||
import { SmsModule } from './modules/sms/sms.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { AccountingModule } from './modules/accounting/accounting.module';
|
||||
import { AccountingSyncModule } from './modules/accounting-sync/accounting-sync.module';
|
||||
import { PricingModule } from './modules/pricing/pricing.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { IamModule } from './modules/iam/iam.module';
|
||||
@@ -66,6 +67,8 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
|
||||
SalesAgentModule,
|
||||
OrdersModule,
|
||||
AccountingModule,
|
||||
// Tahesab / Zarrin integration (D27) — inert until switched on in the admin panel
|
||||
AccountingSyncModule,
|
||||
PricingModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { AccountingAdminService } from './accounting-admin.service';
|
||||
|
||||
/**
|
||||
* «اتصال حسابداری» (D27) — every route behind `config.accounting`. Nothing
|
||||
* here ever returns a token: the configuration comes back with tokens reduced
|
||||
* to `tokenSet` / `tokenReadable` flags.
|
||||
*/
|
||||
@Controller('admin/accounting')
|
||||
@RequirePermission('config.accounting')
|
||||
export class AccountingAdminController {
|
||||
constructor(private readonly admin: AccountingAdminService) {}
|
||||
|
||||
@Get()
|
||||
overview() {
|
||||
return this.admin.overview();
|
||||
}
|
||||
|
||||
@Put('config')
|
||||
save(@Body() body: Record<string, unknown>) {
|
||||
return this.admin.save(body);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(200)
|
||||
test(@Body() body: { provider?: string; connection?: Record<string, unknown> }) {
|
||||
return this.admin.test(body?.provider, body?.connection);
|
||||
}
|
||||
|
||||
@Post('certificate')
|
||||
@HttpCode(200)
|
||||
certificate(@Body() body: { baseUrl?: string }) {
|
||||
return this.admin.certificate(body?.baseUrl);
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
options(@Query('provider') provider?: string) {
|
||||
return this.admin.options(provider);
|
||||
}
|
||||
|
||||
@Post('run/:job')
|
||||
@HttpCode(200)
|
||||
run(@Param('job') job: string) {
|
||||
return this.admin.run(job);
|
||||
}
|
||||
|
||||
@Get('outbox')
|
||||
outbox(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.outbox(q);
|
||||
}
|
||||
|
||||
@Post('outbox/:id/retry')
|
||||
@HttpCode(200)
|
||||
retry(@Param('id') id: string) {
|
||||
return this.admin.retry(id);
|
||||
}
|
||||
|
||||
@Post('outbox/:id/resolve')
|
||||
@HttpCode(200)
|
||||
resolve(@Param('id') id: string, @CurrentAdmin() me: AuthenticatedAdmin, @Body() body: { note?: string }) {
|
||||
return this.admin.resolve(id, me.id, body?.note);
|
||||
}
|
||||
|
||||
@Get('issues')
|
||||
issues(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.issues(q);
|
||||
}
|
||||
|
||||
@Post('issues/:id/resolve')
|
||||
@HttpCode(200)
|
||||
resolveIssue(@Param('id') id: string) {
|
||||
return this.admin.resolveIssue(id);
|
||||
}
|
||||
|
||||
@Get('links')
|
||||
links(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.links(q);
|
||||
}
|
||||
|
||||
@Post('links')
|
||||
@HttpCode(200)
|
||||
link(@Body() body: Record<string, unknown>) {
|
||||
return this.admin.manualLink(body);
|
||||
}
|
||||
|
||||
@Delete('links/:id')
|
||||
unlink(@Param('id') id: string) {
|
||||
return this.admin.unlink(id);
|
||||
}
|
||||
|
||||
@Get('customers/:id')
|
||||
customer(@Param('id') id: string) {
|
||||
return this.admin.customer(id);
|
||||
}
|
||||
|
||||
@Get('products/status')
|
||||
productsStatus(@Query('ids') ids: string) {
|
||||
return this.admin.productsStatus(ids);
|
||||
}
|
||||
|
||||
@Get('movement')
|
||||
movement(@Query() q: Record<string, unknown>) {
|
||||
return this.admin.movement(q);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AccountingEntityType, AccountingOutboxStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { cleanUrl } from './accounting-config';
|
||||
import { AccountingConfigService, JobName } from './accounting-config.service';
|
||||
import { fetchServerCertificate } from './accounting-http';
|
||||
import { AccountingSyncService } from './accounting-sync.service';
|
||||
import { AccountingWorkerService, PULL_FEATURE } from './accounting-worker.service';
|
||||
import { parseJalali, toJalali } from './jalali';
|
||||
import { ACCOUNTING_PROVIDERS, ProviderName, isProviderName } from './providers';
|
||||
|
||||
const JOBS: JobName[] = ['customers', 'balances', 'products', 'outbox'];
|
||||
const STATUSES: AccountingOutboxStatus[] = ['PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'];
|
||||
|
||||
function page(v: unknown) {
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
function limit(v: unknown, max = 100) {
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n > 0 ? Math.min(n, max) : 25;
|
||||
}
|
||||
|
||||
/** Backs the «اتصال حسابداری» admin screen (plan §5). */
|
||||
@Injectable()
|
||||
export class AccountingAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AccountingConfigService,
|
||||
private readonly sync: AccountingSyncService,
|
||||
private readonly worker: AccountingWorkerService,
|
||||
) {}
|
||||
|
||||
async overview() {
|
||||
const [config, state, counts, openIssues, raw] = await Promise.all([
|
||||
this.config.getPublic(),
|
||||
this.config.getState(),
|
||||
this.prisma.accountingOutbox.groupBy({ by: ['status'], _count: { _all: true } }),
|
||||
this.prisma.accountingIssue.count({ where: { resolvedAt: null } }),
|
||||
this.config.get(),
|
||||
]);
|
||||
return {
|
||||
config,
|
||||
inactiveReason: this.config.inactiveReason(raw),
|
||||
state,
|
||||
busy: this.worker.busy,
|
||||
realtime: this.worker.realtime,
|
||||
outbox: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||
openIssues,
|
||||
};
|
||||
}
|
||||
|
||||
async save(body: Record<string, unknown>) {
|
||||
const { providerChanged } = await this.config.save(body);
|
||||
return { ...(await this.overview()), providerChanged };
|
||||
}
|
||||
|
||||
private async settingsOf(name: unknown) {
|
||||
const config = await this.config.get();
|
||||
const provider = isProviderName(name) ? name : config.provider;
|
||||
if (provider === 'none') throw new BadRequestException('نرمافزار حسابداری انتخاب نشده است');
|
||||
const settings = this.config.settingsFor(config, provider);
|
||||
if (!settings) throw new BadRequestException('آدرس یا توکن این نرمافزار تنظیم نشده (یا توکن قابلخواندن نیست)');
|
||||
return { provider: ACCOUNTING_PROVIDERS[provider], settings, config };
|
||||
}
|
||||
|
||||
/** For the sync buttons on the lists: connected or not, nothing more. */
|
||||
async connectionStatus() {
|
||||
const config = await this.config.get();
|
||||
const reason = this.config.inactiveReason(config);
|
||||
return {
|
||||
connected: !reason,
|
||||
provider: reason || config.provider === 'none' ? null : ACCOUNTING_PROVIDERS[config.provider].label,
|
||||
};
|
||||
}
|
||||
|
||||
/** The list-page sync buttons (plan §12): run now, wait, report. */
|
||||
async syncNow(jobs: ('customers' | 'balances' | 'products')[]) {
|
||||
const reason = this.config.inactiveReason(await this.config.get());
|
||||
if (reason) throw new BadRequestException(`سینک با حسابداری انجام نمیشود: ${reason}`);
|
||||
const results = await this.worker.runPullsNow(jobs);
|
||||
if (!results) throw new BadRequestException('یک همگامسازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید');
|
||||
return { ok: Object.values(results).every((r) => r.ok), results };
|
||||
}
|
||||
|
||||
/** Tests the connection as typed in the form (`connection`), falling back to what is saved. */
|
||||
async test(name: unknown, connection?: Record<string, unknown>) {
|
||||
if (!isProviderName(name)) throw new BadRequestException('نرمافزار حسابداری نامعتبر است');
|
||||
const provider = ACCOUNTING_PROVIDERS[name];
|
||||
const settings = this.config.draftSettings(await this.config.get(), name, connection);
|
||||
if (!settings) throw new BadRequestException('آدرس و توکن را وارد کنید');
|
||||
const started = Date.now();
|
||||
try {
|
||||
const info = await provider.health(settings);
|
||||
return { ok: true, provider: provider.label, ms: Date.now() - started, info };
|
||||
} catch (err) {
|
||||
return { ok: false, provider: provider.label, ms: Date.now() - started, error: this.sync.describeError(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The certificate the server at `baseUrl` presents, for the admin to compare
|
||||
* and pin (a self-signed Tahesab install cannot be verified otherwise).
|
||||
*/
|
||||
async certificate(baseUrl: unknown) {
|
||||
const url = cleanUrl(baseUrl, 'آدرس');
|
||||
if (!url) throw new BadRequestException('آدرس را وارد کنید');
|
||||
try {
|
||||
return await fetchServerCertificate(url, 10_000);
|
||||
} catch (err) {
|
||||
throw new BadRequestException(`گواهی دریافت نشد: ${this.sync.describeError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dropdown options for the mapping tab; each list fails on its own. */
|
||||
async options(name: unknown) {
|
||||
const { provider, settings } = await this.settingsOf(name);
|
||||
const safe = async <T>(fn: () => Promise<T>) => {
|
||||
try {
|
||||
return { ok: true as const, items: await fn() };
|
||||
} catch (err) {
|
||||
return { ok: false as const, items: [] as never[], error: this.sync.describeError(err) };
|
||||
}
|
||||
};
|
||||
const [customerGroups, karSakhteGroups, karSakhteTypes] = await Promise.all([
|
||||
safe(() => provider.listCustomerGroups(settings)),
|
||||
safe(() => provider.listKarSakhteGroups(settings)),
|
||||
safe(() => provider.listKarSakhteTypes(settings)),
|
||||
]);
|
||||
const categories = await this.prisma.category.findMany({ select: { id: true, title: true }, orderBy: { title: 'asc' } });
|
||||
return { provider: provider.name, customerGroups, karSakhteGroups, karSakhteTypes, categories };
|
||||
}
|
||||
|
||||
async run(job: string) {
|
||||
if (!JOBS.includes(job as JobName)) throw new BadRequestException('کار نامعتبر است');
|
||||
const config = await this.config.get();
|
||||
const reason = this.config.inactiveReason(config);
|
||||
if (reason) throw new BadRequestException(`همگامسازی انجام نمیشود: ${reason}`);
|
||||
if (job !== 'outbox' && !config.features[PULL_FEATURE[job as Exclude<JobName, 'outbox'>]]) {
|
||||
throw new BadRequestException('این قابلیت در تب «قابلیتها و زمانبندی» خاموش است؛ اول روشن و ذخیره کنید');
|
||||
}
|
||||
const started = this.worker.runNow(job as JobName);
|
||||
return { started, message: started ? 'شروع شد' : 'یک همگامسازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید' };
|
||||
}
|
||||
|
||||
// ── outbox log ──────────────────────────────────────────────────────
|
||||
|
||||
async outbox(q: Record<string, unknown>) {
|
||||
const where: Prisma.AccountingOutboxWhereInput = {};
|
||||
if (typeof q.status === 'string' && STATUSES.includes(q.status as AccountingOutboxStatus)) {
|
||||
where.status = q.status as AccountingOutboxStatus;
|
||||
}
|
||||
if (typeof q.kind === 'string' && q.kind) where.kind = q.kind as Prisma.AccountingOutboxWhereInput['kind'];
|
||||
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
|
||||
const p = page(q.page);
|
||||
const l = limit(q.limit);
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.accountingOutbox.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
|
||||
this.prisma.accountingOutbox.count({ where }),
|
||||
]);
|
||||
const labels = await this.labelsFor(rows.map((r) => ({ kind: r.kind, entityId: r.entityId })));
|
||||
return { rows: rows.map((r) => ({ ...r, entityLabel: labels.get(r.entityId) ?? null })), total, page: p, limit: l };
|
||||
}
|
||||
|
||||
private async labelsFor(items: { kind: string; entityId: string }[]) {
|
||||
const orderIds = items.filter((i) => i.kind.startsWith('ORDER')).map((i) => i.entityId);
|
||||
const customerIds = items.filter((i) => i.kind === 'CUSTOMER_PUSH').map((i) => i.entityId);
|
||||
const productIds = items.filter((i) => i.kind === 'PRODUCT_PUSH').map((i) => i.entityId);
|
||||
const [orders, customers, products] = await Promise.all([
|
||||
orderIds.length ? this.prisma.order.findMany({ where: { id: { in: orderIds } }, select: { id: true, orderNumber: true, customer: { select: { fullName: true } } } }) : [],
|
||||
customerIds.length ? this.prisma.customer.findMany({ where: { id: { in: customerIds } }, select: { id: true, fullName: true, mobile: true } }) : [],
|
||||
productIds.length ? this.prisma.product.findMany({ where: { id: { in: productIds } }, select: { id: true, title: true } }) : [],
|
||||
]);
|
||||
const m = new Map<string, string>();
|
||||
for (const o of orders) m.set(o.id, `سفارش ${o.orderNumber} — ${o.customer.fullName}`);
|
||||
for (const c of customers) m.set(c.id, `${c.fullName} (${c.mobile})`);
|
||||
for (const p of products) m.set(p.id, p.title);
|
||||
return m;
|
||||
}
|
||||
|
||||
async retry(id: string) {
|
||||
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('ردیف پیدا نشد');
|
||||
if (row.status === 'SENT' || row.status === 'SENDING') throw new BadRequestException('این ردیف قابل تلاش دوباره نیست');
|
||||
return this.prisma.accountingOutbox.update({
|
||||
where: { id },
|
||||
data: { status: 'PENDING', nextAttemptAt: new Date(), attempts: row.status === 'PENDING' ? row.attempts : 0 },
|
||||
});
|
||||
}
|
||||
|
||||
async resolve(id: string, adminId: string, note?: string) {
|
||||
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('ردیف پیدا نشد');
|
||||
if (row.status === 'SENDING') throw new BadRequestException('در حال ارسال است');
|
||||
return this.prisma.accountingOutbox.update({
|
||||
where: { id },
|
||||
data: { status: 'RESOLVED', resolvedById: adminId, lastError: note ? `حلشده: ${note}` : row.lastError },
|
||||
});
|
||||
}
|
||||
|
||||
// ── issues ──────────────────────────────────────────────────────────
|
||||
|
||||
async issues(q: Record<string, unknown>) {
|
||||
const where: Prisma.AccountingIssueWhereInput = q.resolved === '1' ? { resolvedAt: { not: null } } : { resolvedAt: null };
|
||||
const p = page(q.page);
|
||||
const l = limit(q.limit);
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.accountingIssue.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (p - 1) * l, take: l }),
|
||||
this.prisma.accountingIssue.count({ where }),
|
||||
]);
|
||||
return { rows, total, page: p, limit: l };
|
||||
}
|
||||
|
||||
resolveIssue(id: string) {
|
||||
return this.prisma.accountingIssue.update({ where: { id }, data: { resolvedAt: new Date() } });
|
||||
}
|
||||
|
||||
// ── links ───────────────────────────────────────────────────────────
|
||||
|
||||
async links(q: Record<string, unknown>) {
|
||||
const config = await this.config.get();
|
||||
const where: Prisma.AccountingLinkWhereInput = {};
|
||||
if (q.entityType === 'CUSTOMER' || q.entityType === 'PRODUCT') where.entityType = q.entityType;
|
||||
where.provider = isProviderName(q.provider) ? q.provider : config.provider === 'none' ? undefined : config.provider;
|
||||
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
|
||||
if (typeof q.q === 'string' && q.q.trim()) {
|
||||
where.OR = [{ externalCode: { contains: q.q.trim() } }, { externalName: { contains: q.q.trim() } }];
|
||||
}
|
||||
const p = page(q.page);
|
||||
const l = limit(q.limit);
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.accountingLink.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
|
||||
this.prisma.accountingLink.count({ where }),
|
||||
]);
|
||||
return { rows, total, page: p, limit: l };
|
||||
}
|
||||
|
||||
async manualLink(body: Record<string, unknown>) {
|
||||
const config = await this.config.get();
|
||||
if (config.provider === 'none') throw new BadRequestException('نرمافزار حسابداری انتخاب نشده است');
|
||||
const provider = config.provider as ProviderName;
|
||||
const entityType = body.entityType === 'PRODUCT' ? AccountingEntityType.PRODUCT : AccountingEntityType.CUSTOMER;
|
||||
const entityId = String(body.entityId ?? '').trim();
|
||||
const externalCode = String(body.externalCode ?? '').trim();
|
||||
if (!entityId || !externalCode) throw new BadRequestException('شناسه و کد حسابداری الزامی است');
|
||||
|
||||
const exists =
|
||||
entityType === 'CUSTOMER'
|
||||
? await this.prisma.customer.findUnique({ where: { id: entityId }, select: { id: true } })
|
||||
: await this.prisma.product.findUnique({ where: { id: entityId }, select: { id: true } });
|
||||
if (!exists) throw new NotFoundException('ردیف اپ پیدا نشد');
|
||||
|
||||
const taken = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_externalCode: { provider, entityType, externalCode } },
|
||||
});
|
||||
if (taken && taken.entityId !== entityId) throw new BadRequestException('این کد به ردیف دیگری لینک است');
|
||||
|
||||
return this.prisma.accountingLink.upsert({
|
||||
where: { provider_entityType_entityId: { provider, entityType, entityId } },
|
||||
update: { externalCode, origin: 'MANUAL', syncedAt: new Date() },
|
||||
create: { provider, entityType, entityId, externalCode, origin: 'MANUAL', externalName: (body.externalName as string) || null },
|
||||
});
|
||||
}
|
||||
|
||||
async unlink(id: string) {
|
||||
await this.prisma.accountingLink.delete({ where: { id } }).catch(() => {
|
||||
throw new NotFoundException('لینک پیدا نشد');
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── per-row panels ──────────────────────────────────────────────────
|
||||
|
||||
async customer(id: string) {
|
||||
const config = await this.config.get();
|
||||
const c = await this.prisma.customer.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
goldDebtGrams: true,
|
||||
fiatDebtRial: true,
|
||||
accountingGoldBalance: true,
|
||||
accountingRialBalance: true,
|
||||
accountingBalanceAt: true,
|
||||
},
|
||||
});
|
||||
if (!c) throw new NotFoundException('مشتری یافت نشد');
|
||||
const link =
|
||||
config.provider === 'none'
|
||||
? null
|
||||
: await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: config.provider, entityType: 'CUSTOMER', entityId: id } },
|
||||
});
|
||||
const fresh = link ? await this.sync.customerBalance(id) : null;
|
||||
const jobs = await this.prisma.accountingOutbox.findMany({
|
||||
where: { entityId: id, kind: 'CUSTOMER_PUSH' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
});
|
||||
return {
|
||||
provider: config.provider,
|
||||
link,
|
||||
internal: { gold: c.goldDebtGrams, rial: c.fiatDebtRial },
|
||||
accounting: fresh
|
||||
? { gold: fresh.gold, rial: fresh.rial, at: fresh.at }
|
||||
: c.accountingBalanceAt
|
||||
? { gold: c.accountingGoldBalance, rial: c.accountingRialBalance, at: c.accountingBalanceAt }
|
||||
: null,
|
||||
jobs,
|
||||
};
|
||||
}
|
||||
|
||||
async productsStatus(idsCsv: string) {
|
||||
const ids = String(idsCsv ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 500);
|
||||
const config = await this.config.get();
|
||||
if (ids.length === 0) return {};
|
||||
const [products, links] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
accountingStockWeight: true,
|
||||
accountingStockCount: true,
|
||||
accountingStockAt: true,
|
||||
_count: { select: { images: true } },
|
||||
},
|
||||
}),
|
||||
config.provider === 'none'
|
||||
? []
|
||||
: this.prisma.accountingLink.findMany({ where: { provider: config.provider, entityType: 'PRODUCT', entityId: { in: ids } } }),
|
||||
]);
|
||||
const linkBy = new Map(links.map((l) => [l.entityId, l]));
|
||||
return Object.fromEntries(
|
||||
products.map((p) => {
|
||||
const l = linkBy.get(p.id);
|
||||
return [
|
||||
p.id,
|
||||
{
|
||||
linked: !!l,
|
||||
locked: !!l && config.features.products,
|
||||
code: l?.externalCode ?? null,
|
||||
origin: l?.origin ?? null,
|
||||
stockWeight: p.accountingStockWeight,
|
||||
stockCount: p.accountingStockCount,
|
||||
stockAt: p.accountingStockAt,
|
||||
hasImage: p._count.images > 0,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async movement(q: Record<string, unknown>) {
|
||||
const a = await this.config.active();
|
||||
if (!a) throw new BadRequestException('اتصال حسابداری فعال نیست');
|
||||
const to = parseJalali(q.to as string) ?? toJalali(new Date());
|
||||
const from = parseJalali(q.from as string) ?? toJalali(new Date(Date.now() - 30 * 24 * 3600 * 1000));
|
||||
const rows = await a.provider.getKarSakhteMovement(a.settings, from, to);
|
||||
return { provider: a.provider.label, scope: a.provider.capabilities.movementScope, rows };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
ACCOUNTING_CONFIG_KEY,
|
||||
ACCOUNTING_STATE_KEY,
|
||||
AccountingConfig,
|
||||
StoredConnection,
|
||||
cleanBool,
|
||||
cleanCa,
|
||||
cleanGroup,
|
||||
cleanGroups,
|
||||
cleanInt,
|
||||
cleanUrl,
|
||||
documentStatusRank,
|
||||
withDefaults,
|
||||
} from './accounting-config';
|
||||
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
|
||||
import { openSecret, sealSecret, secretKeySource } from './secret-box';
|
||||
import { parsePemCertificates } from './accounting-http';
|
||||
|
||||
/** The first certificate of a pasted bundle, for display; null when none parses. */
|
||||
function describeCertificate(pem: string | null) {
|
||||
const [c] = parsePemCertificates(pem);
|
||||
if (!c) return null;
|
||||
return {
|
||||
subject: c.subject,
|
||||
subjectAltName: c.subjectAltName ?? null,
|
||||
validTo: c.validTo,
|
||||
expired: new Date(c.validTo).getTime() < Date.now(),
|
||||
fingerprint256: c.fingerprint256,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActiveProvider {
|
||||
provider: AccountingProvider;
|
||||
settings: ProviderSettings;
|
||||
config: AccountingConfig;
|
||||
}
|
||||
|
||||
export type JobName = 'customers' | 'balances' | 'products' | 'outbox';
|
||||
|
||||
export interface JobState {
|
||||
lastRunAt?: string;
|
||||
lastOkAt?: string;
|
||||
lastError?: string | null;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AccountingState {
|
||||
jobs: Partial<Record<JobName, JobState>>;
|
||||
}
|
||||
|
||||
/** How long a read config is reused before going back to the database. */
|
||||
const CACHE_MS = 5_000;
|
||||
|
||||
@Injectable()
|
||||
export class AccountingConfigService implements OnModuleInit {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private cache: { at: number; config: AccountingConfig } | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') {
|
||||
this.logger.warn(
|
||||
'⚠️ ACCOUNTING_SECRET_KEY تنظیم نشده است. توکن نرمافزار حسابداری با کلیدی مشتق از JWT_SECRET رمز میشود؛ ' +
|
||||
'با عوضشدن JWT_SECRET توکن ذخیرهشده قابلخواندن نمیماند و باید دوباره وارد شود. (D27)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async get(): Promise<AccountingConfig> {
|
||||
if (this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.config;
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_CONFIG_KEY } });
|
||||
const config = withDefaults(row?.value);
|
||||
this.cache = { at: Date.now(), config };
|
||||
return config;
|
||||
}
|
||||
|
||||
/** The configuration as the admin screen sees it: tokens reduced to flags. */
|
||||
async getPublic() {
|
||||
const c = await this.get();
|
||||
const publicConn = (conn: StoredConnection) => {
|
||||
const { tokenSealed, ...rest } = conn;
|
||||
return {
|
||||
...rest,
|
||||
tokenSet: !!tokenSealed,
|
||||
tokenReadable: !!tokenSealed && openSecret(tokenSealed) !== null,
|
||||
// What the pinned certificate is, so the screen can show it instead of PEM.
|
||||
caInfo: describeCertificate(rest.caCert),
|
||||
};
|
||||
};
|
||||
return {
|
||||
...c,
|
||||
tahesab: publicConn(c.tahesab),
|
||||
zarrin: publicConn(c.zarrin),
|
||||
secretKeySource: secretKeySource(),
|
||||
providers: Object.values(ACCOUNTING_PROVIDERS).map((p) => ({
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
capabilities: p.capabilities,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an admin's edit. Anything not sent is kept. A token is replaced
|
||||
* only by a non-empty string and removed only by an explicit null — so the
|
||||
* form can be saved without ever having seen the token.
|
||||
*/
|
||||
async save(input: Record<string, any>): Promise<{ providerChanged: boolean }> {
|
||||
const current = await this.get();
|
||||
const next: AccountingConfig = JSON.parse(JSON.stringify(current));
|
||||
|
||||
if (input.provider !== undefined) {
|
||||
if (input.provider !== 'none' && !isProviderName(input.provider)) {
|
||||
throw new BadRequestException('نرمافزار حسابداری نامعتبر است');
|
||||
}
|
||||
next.provider = input.provider;
|
||||
}
|
||||
|
||||
const conn = (key: ProviderName) => {
|
||||
const src = input[key];
|
||||
if (!src || typeof src !== 'object') return;
|
||||
const label = ACCOUNTING_PROVIDERS[key].label;
|
||||
const dst: StoredConnection = next[key];
|
||||
if (src.baseUrl !== undefined) dst.baseUrl = cleanUrl(src.baseUrl, `آدرس ${label}`);
|
||||
if (src.caCert !== undefined) dst.caCert = cleanCa(src.caCert);
|
||||
if (src.timeoutMs !== undefined) dst.timeoutMs = cleanInt(src.timeoutMs, 'مهلت پاسخ (میلیثانیه)', 3000, 120000, dst.timeoutMs);
|
||||
if (src.token === null) dst.tokenSealed = null;
|
||||
else if (typeof src.token === 'string' && src.token.trim()) {
|
||||
try {
|
||||
dst.tokenSealed = sealSecret(src.token.trim());
|
||||
} catch (err) {
|
||||
throw new BadRequestException((err as Error).message);
|
||||
}
|
||||
}
|
||||
};
|
||||
conn('tahesab');
|
||||
conn('zarrin');
|
||||
|
||||
if (input.tahesab?.dbName !== undefined) {
|
||||
const db = String(input.tahesab.dbName ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_]{1,64}$/.test(db)) throw new BadRequestException('نام دیتابیس تهحساب نامعتبر است');
|
||||
next.tahesab.dbName = db;
|
||||
}
|
||||
if (input.zarrin?.balanceOwesWhen !== undefined) {
|
||||
if (input.zarrin.balanceOwesWhen !== 'negative' && input.zarrin.balanceOwesWhen !== 'positive') {
|
||||
throw new BadRequestException('علامت ماندهی زرین نامعتبر است');
|
||||
}
|
||||
next.zarrin.balanceOwesWhen = input.zarrin.balanceOwesWhen;
|
||||
}
|
||||
|
||||
if (input.features && typeof input.features === 'object') {
|
||||
for (const k of Object.keys(next.features) as (keyof AccountingConfig['features'])[]) {
|
||||
next.features[k] = cleanBool(input.features[k], next.features[k]);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.intervals && typeof input.intervals === 'object') {
|
||||
const i = input.intervals;
|
||||
next.intervals.customersMinutes = cleanInt(i.customersMinutes, 'بازهی سینک مشتری', 5, 1440, next.intervals.customersMinutes);
|
||||
next.intervals.balancesMinutes = cleanInt(i.balancesMinutes, 'بازهی سینک مانده', 1, 1440, next.intervals.balancesMinutes);
|
||||
next.intervals.productsMinutes = cleanInt(i.productsMinutes, 'بازهی سینک کارساخته', 5, 1440, next.intervals.productsMinutes);
|
||||
}
|
||||
|
||||
if (input.mapping && typeof input.mapping === 'object') {
|
||||
const m = input.mapping;
|
||||
if (m.importCustomerGroups !== undefined) next.mapping.importCustomerGroups = cleanGroups(m.importCustomerGroups);
|
||||
if (m.newCustomerGroup !== undefined) next.mapping.newCustomerGroup = cleanGroup(m.newCustomerGroup);
|
||||
if (m.newKarSakhteGroup !== undefined) next.mapping.newKarSakhteGroup = cleanGroup(m.newKarSakhteGroup);
|
||||
if (m.newKarSakhteType !== undefined) next.mapping.newKarSakhteType = cleanGroup(m.newKarSakhteType);
|
||||
if (m.categoryGroups !== undefined) {
|
||||
next.mapping.categoryGroups = (Array.isArray(m.categoryGroups) ? m.categoryGroups : [])
|
||||
.map((x: any) => ({ categoryId: String(x?.categoryId ?? '').trim(), group: cleanGroup(x?.group) }))
|
||||
.filter((x: any) => x.categoryId && x.group);
|
||||
}
|
||||
if (m.karat !== undefined) next.mapping.karat = cleanInt(m.karat, 'عیار پایه', 1, 1000, next.mapping.karat);
|
||||
}
|
||||
|
||||
if (input.documents && typeof input.documents === 'object' && input.documents.triggerStatus !== undefined) {
|
||||
if (!documentStatusRank(String(input.documents.triggerStatus))) {
|
||||
throw new BadRequestException('وضعیت ثبت سند سفارش نامعتبر است');
|
||||
}
|
||||
next.documents.triggerStatus = input.documents.triggerStatus;
|
||||
}
|
||||
|
||||
if (input.report && typeof input.report === 'object') {
|
||||
next.report.windowDays = cleanInt(input.report.windowDays, 'بازهی گزارش (روز)', 7, 3650, next.report.windowDays);
|
||||
}
|
||||
|
||||
// Selecting a software that has no address/token would switch the sync
|
||||
// off without a word (the worker finds nothing usable).
|
||||
if (next.provider !== 'none' && (!next[next.provider].baseUrl || !next[next.provider].tokenSealed)) {
|
||||
const label = ACCOUNTING_PROVIDERS[next.provider].label;
|
||||
throw new BadRequestException(`«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`);
|
||||
}
|
||||
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: ACCOUNTING_CONFIG_KEY },
|
||||
update: { value: next as unknown as Prisma.InputJsonValue },
|
||||
create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
this.cache = null;
|
||||
return { providerChanged: current.provider !== next.provider };
|
||||
}
|
||||
|
||||
/** Decrypted settings for one provider; null when it is not usable. */
|
||||
settingsFor(config: AccountingConfig, name: ProviderName): ProviderSettings | null {
|
||||
const conn = config[name];
|
||||
if (!conn.baseUrl) return null;
|
||||
const token = openSecret(conn.tokenSealed);
|
||||
if (!token) return null;
|
||||
return {
|
||||
baseUrl: conn.baseUrl,
|
||||
token,
|
||||
caCert: conn.caCert,
|
||||
timeoutMs: conn.timeoutMs,
|
||||
dbName: name === 'tahesab' ? config.tahesab.dbName : undefined,
|
||||
balanceOwesWhen: name === 'zarrin' ? config.zarrin.balanceOwesWhen : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings as the admin form currently shows them, saved or not, so «تست
|
||||
* اتصال» checks what is on screen. A blank token means "the saved one".
|
||||
* Validated like a save; nothing is stored.
|
||||
*/
|
||||
draftSettings(config: AccountingConfig, name: ProviderName, input: Record<string, any> | undefined): ProviderSettings | null {
|
||||
const src = input && typeof input === 'object' ? input : {};
|
||||
const label = ACCOUNTING_PROVIDERS[name].label;
|
||||
const saved = config[name];
|
||||
const baseUrl = src.baseUrl !== undefined ? cleanUrl(src.baseUrl, `آدرس ${label}`) : saved.baseUrl;
|
||||
const token = typeof src.token === 'string' && src.token.trim() ? src.token.trim() : openSecret(saved.tokenSealed);
|
||||
if (!baseUrl || !token) return null;
|
||||
let dbName = config.tahesab.dbName;
|
||||
if (name === 'tahesab' && src.dbName !== undefined) {
|
||||
dbName = String(src.dbName ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_]{1,64}$/.test(dbName)) throw new BadRequestException('نام دیتابیس تهحساب نامعتبر است');
|
||||
}
|
||||
let balanceOwesWhen = config.zarrin.balanceOwesWhen;
|
||||
if (name === 'zarrin' && (src.balanceOwesWhen === 'negative' || src.balanceOwesWhen === 'positive')) {
|
||||
balanceOwesWhen = src.balanceOwesWhen;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
caCert: src.caCert !== undefined ? cleanCa(src.caCert) : saved.caCert,
|
||||
timeoutMs: cleanInt(src.timeoutMs, 'مهلت پاسخ (میلیثانیه)', 3000, 120000, saved.timeoutMs),
|
||||
dbName: name === 'tahesab' ? dbName : undefined,
|
||||
balanceOwesWhen: name === 'zarrin' ? balanceOwesWhen : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Why nothing syncs, in words for the admin screen; null when a provider is usable. */
|
||||
inactiveReason(config: AccountingConfig): string | null {
|
||||
if (config.provider === 'none') return 'سوییچ روی «بدون اتصال» است';
|
||||
const label = ACCOUNTING_PROVIDERS[config.provider].label;
|
||||
const conn = config[config.provider];
|
||||
if (!conn.baseUrl || !conn.tokenSealed) return `«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`;
|
||||
if (!this.settingsFor(config, config.provider)) return `توکن ذخیرهشدهی «${label}» قابلخواندن نیست؛ دوباره وارد کنید`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The selected, fully configured provider — or null when the switch is off. */
|
||||
async active(): Promise<ActiveProvider | null> {
|
||||
const config = await this.get();
|
||||
if (config.provider === 'none') return null;
|
||||
const settings = this.settingsFor(config, config.provider);
|
||||
if (!settings) return null;
|
||||
return { provider: ACCOUNTING_PROVIDERS[config.provider], settings, config };
|
||||
}
|
||||
|
||||
/** Active provider with this feature switched on, or null. */
|
||||
async activeFor(feature: keyof AccountingConfig['features']): Promise<ActiveProvider | null> {
|
||||
const a = await this.active();
|
||||
return a && a.config.features[feature] ? a : null;
|
||||
}
|
||||
|
||||
// ── job bookkeeping (shown on the admin screen) ───────────────────────
|
||||
|
||||
async getState(): Promise<AccountingState> {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_STATE_KEY } });
|
||||
const v = (row?.value ?? {}) as Partial<AccountingState>;
|
||||
return { jobs: v.jobs ?? {} };
|
||||
}
|
||||
|
||||
async recordJob(job: JobName, patch: JobState) {
|
||||
const state = await this.getState();
|
||||
state.jobs[job] = { ...(state.jobs[job] ?? {}), ...patch };
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: ACCOUNTING_STATE_KEY },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: { key: ACCOUNTING_STATE_KEY, value: state as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ExternalGroup, ProviderName } from './providers/accounting-provider';
|
||||
|
||||
/**
|
||||
* The admin-editable configuration of the accounting integration (D27),
|
||||
* stored as one Setting row. Every `accounting.*` Setting key is refused by
|
||||
* the generic `const` routes (admin-misc), so the only way in or out is the
|
||||
* dedicated, `config.accounting`-guarded screen — and even that never returns
|
||||
* a token.
|
||||
*/
|
||||
|
||||
export const ACCOUNTING_SETTING_PREFIX = 'accounting.';
|
||||
export const ACCOUNTING_CONFIG_KEY = 'accounting.config';
|
||||
export const ACCOUNTING_STATE_KEY = 'accounting.state';
|
||||
|
||||
/** Keys the generic `const` settings routes must never read or write. */
|
||||
export function isAccountingSettingKey(key: string) {
|
||||
return key.trim().toLowerCase().startsWith(ACCOUNTING_SETTING_PREFIX);
|
||||
}
|
||||
|
||||
export type ProviderChoice = 'none' | ProviderName;
|
||||
|
||||
export interface StoredConnection {
|
||||
baseUrl: string;
|
||||
/** AES-GCM sealed (secret-box.ts). Never leaves the server. */
|
||||
tokenSealed: string | null;
|
||||
/** PEM CA of a self-signed installation; verification is never turned off. */
|
||||
caCert: string | null;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface AccountingFeatures {
|
||||
/** F1 — customer import/link and push. */
|
||||
customers: boolean;
|
||||
/** F2 — balance pull; the customer sees the accounting balance. */
|
||||
balances: boolean;
|
||||
/** F6 — «گزارش» reads the accounting statement. */
|
||||
report: boolean;
|
||||
/** F4 — کارساخته ↔ product sync and the out-of-stock lock. */
|
||||
products: boolean;
|
||||
/** F3 — order documents. */
|
||||
documents: boolean;
|
||||
}
|
||||
|
||||
export interface AccountingConfig {
|
||||
provider: ProviderChoice;
|
||||
tahesab: StoredConnection & { dbName: string };
|
||||
zarrin: StoredConnection & { balanceOwesWhen: 'negative' | 'positive' };
|
||||
features: AccountingFeatures;
|
||||
intervals: {
|
||||
customersMinutes: number;
|
||||
balancesMinutes: number;
|
||||
productsMinutes: number;
|
||||
};
|
||||
mapping: {
|
||||
/** Account groups imported into the app; empty = every group. */
|
||||
importCustomerGroups: ExternalGroup[];
|
||||
/** Group a customer created from the app is put in. */
|
||||
newCustomerGroup: ExternalGroup | null;
|
||||
/** Zarrin: group (گروه ساخته) for a product created in the admin… */
|
||||
newKarSakhteGroup: ExternalGroup | null;
|
||||
/** …overridden per app category. */
|
||||
categoryGroups: { categoryId: string; group: ExternalGroup }[];
|
||||
/** Zarrin: item type (گرمی/تعدادی…) for a product created in the admin. */
|
||||
newKarSakhteType: ExternalGroup | null;
|
||||
/** Purity the app's grams are in. */
|
||||
karat: number;
|
||||
};
|
||||
/** How far back «گزارش» reads the accounting statement. */
|
||||
report: { windowDays: number };
|
||||
/** F3 — the order status at (or past) which the temporary document (پیشفاکتور) is posted. */
|
||||
documents: { triggerStatus: DocumentTriggerStatus };
|
||||
}
|
||||
|
||||
/** Order statuses in the order an order passes them; SUSPENDED/CANCELLED never trigger. */
|
||||
export const DOCUMENT_TRIGGER_STATUSES = [
|
||||
'PLACED',
|
||||
'ADMIN_APPROVED',
|
||||
'SOURCING',
|
||||
'PREPARING',
|
||||
'DELIVERED_TO_AGENT',
|
||||
'DELIVERED_TO_CUSTOMER',
|
||||
] as const;
|
||||
export type DocumentTriggerStatus = (typeof DOCUMENT_TRIGGER_STATUSES)[number];
|
||||
|
||||
/** 1-based place of a status in DOCUMENT_TRIGGER_STATUSES; 0 = never triggers. */
|
||||
export function documentStatusRank(status: string): number {
|
||||
return DOCUMENT_TRIGGER_STATUSES.indexOf(status as DocumentTriggerStatus) + 1;
|
||||
}
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function defaultConfig(): AccountingConfig {
|
||||
return {
|
||||
provider: 'none',
|
||||
tahesab: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, dbName: 'TahesabDB' },
|
||||
zarrin: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, balanceOwesWhen: 'negative' },
|
||||
features: { customers: false, balances: false, report: false, products: false, documents: false },
|
||||
intervals: { customersMinutes: 60, balancesMinutes: 10, productsMinutes: 30 },
|
||||
mapping: {
|
||||
importCustomerGroups: [],
|
||||
newCustomerGroup: null,
|
||||
newKarSakhteGroup: null,
|
||||
categoryGroups: [],
|
||||
newKarSakhteType: null,
|
||||
karat: 750,
|
||||
},
|
||||
report: { windowDays: 365 },
|
||||
// The point the debt is recognised — the behaviour before the rule existed.
|
||||
documents: { triggerStatus: 'DELIVERED_TO_AGENT' },
|
||||
};
|
||||
}
|
||||
|
||||
/** Stored JSON → a complete config, tolerating rows written by older code. */
|
||||
export function withDefaults(raw: unknown): AccountingConfig {
|
||||
const d = defaultConfig();
|
||||
const r = (raw && typeof raw === 'object' ? raw : {}) as Partial<AccountingConfig>;
|
||||
return {
|
||||
provider: r.provider === 'tahesab' || r.provider === 'zarrin' ? r.provider : 'none',
|
||||
tahesab: { ...d.tahesab, ...(r.tahesab ?? {}) },
|
||||
zarrin: { ...d.zarrin, ...(r.zarrin ?? {}) },
|
||||
features: { ...d.features, ...(r.features ?? {}) },
|
||||
intervals: { ...d.intervals, ...(r.intervals ?? {}) },
|
||||
mapping: { ...d.mapping, ...(r.mapping ?? {}) },
|
||||
report: { ...d.report, ...(r.report ?? {}) },
|
||||
documents: { ...d.documents, ...(r.documents ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// ── input validation ────────────────────────────────────────────────────
|
||||
|
||||
export function cleanUrl(v: unknown, label: string): string {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!s) return '';
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(s);
|
||||
} catch {
|
||||
throw new BadRequestException(`${label}: آدرس معتبر نیست`);
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
throw new BadRequestException(`${label}: آدرس باید با http:// یا https:// شروع شود`);
|
||||
}
|
||||
if (u.username || u.password) throw new BadRequestException(`${label}: آدرس نباید نام کاربری/رمز داشته باشد`);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function cleanCa(v: unknown): string | null {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!s) return null;
|
||||
if (!s.includes('-----BEGIN CERTIFICATE-----')) {
|
||||
throw new BadRequestException('گواهی CA باید با قالب PEM (BEGIN CERTIFICATE) باشد');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function cleanInt(v: unknown, label: string, min: number, max: number, fallback: number): number {
|
||||
if (v === undefined || v === null || v === '') return fallback;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min || n > max) {
|
||||
throw new BadRequestException(`${label}: عدد صحیح بین ${min} و ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function cleanGroup(v: unknown): ExternalGroup | null {
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const g = v as Record<string, unknown>;
|
||||
const id = g.id === undefined || g.id === null ? '' : String(g.id).trim();
|
||||
const name = typeof g.name === 'string' ? g.name.trim() : '';
|
||||
if (!id && !name) return null;
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
export function cleanGroups(v: unknown): ExternalGroup[] {
|
||||
return Array.isArray(v) ? v.map(cleanGroup).filter((g): g is ExternalGroup => !!g) : [];
|
||||
}
|
||||
|
||||
export function cleanBool(v: unknown, fallback: boolean): boolean {
|
||||
return typeof v === 'boolean' ? v : fallback;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { AccountingHooksService } from './accounting-hooks.service';
|
||||
import { AccountingConfig, defaultConfig } from './accounting-config';
|
||||
|
||||
type Row = { id: string; provider: string; kind: string; entityId: string; status: string };
|
||||
|
||||
/** Just enough of a Prisma transaction client for the outbox rules. */
|
||||
function fakeTx(rows: Row[]) {
|
||||
const match = (r: Row, where: any) =>
|
||||
(!where.provider || r.provider === where.provider) &&
|
||||
(!where.kind || r.kind === where.kind) &&
|
||||
(!where.entityId || r.entityId === where.entityId) &&
|
||||
(!where.status ||
|
||||
(where.status.in ? where.status.in.includes(r.status) : true) &&
|
||||
(where.status.notIn ? !where.status.notIn.includes(r.status) : true) &&
|
||||
(where.status.not ? r.status !== where.status.not : true));
|
||||
return {
|
||||
accountingOutbox: {
|
||||
findFirst: async ({ where }: any) => rows.find((r) => match(r, where)) ?? null,
|
||||
findMany: async ({ where }: any) => rows.filter((r) => match(r, where)),
|
||||
create: async ({ data }: any) => {
|
||||
rows.push({ id: String(rows.length + 1), status: 'PENDING', ...data });
|
||||
},
|
||||
update: async ({ where, data }: any) => {
|
||||
Object.assign(rows.find((r) => r.id === where.id)!, data);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hooks(trigger: AccountingConfig['documents']['triggerStatus'], provider: 'tahesab' | 'zarrin' = 'tahesab') {
|
||||
const c = defaultConfig();
|
||||
c.provider = provider;
|
||||
c.features.documents = true;
|
||||
c.documents.triggerStatus = trigger;
|
||||
const config = { get: async () => c } as any;
|
||||
const worker = { kickSoon: jest.fn() } as any;
|
||||
return new AccountingHooksService({} as any, config, {} as any, worker);
|
||||
}
|
||||
|
||||
const docs = (rows: Row[], kind = 'ORDER_DOCUMENT') => rows.filter((r) => r.kind === kind);
|
||||
|
||||
describe('document rule (پیشفاکتور at a chosen status)', () => {
|
||||
it('rule «ثبت سفارش»: the document is queued as the order is placed', async () => {
|
||||
const rows: Row[] = [];
|
||||
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('default rule: nothing before «تحویل مسئول فروش», then once', async () => {
|
||||
const rows: Row[] = [];
|
||||
const h = hooks('DELIVERED_TO_AGENT');
|
||||
for (const s of ['PLACED', 'ADMIN_APPROVED', 'PREPARING']) await h.orderStatusReached(fakeTx(rows) as any, 'o1', s);
|
||||
expect(docs(rows)).toHaveLength(0);
|
||||
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_AGENT');
|
||||
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an order that jumps past the rule status still gets its document', async () => {
|
||||
const rows: Row[] = [];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a suspended order never triggers', async () => {
|
||||
const rows: Row[] = [];
|
||||
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'SUSPENDED');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rolled back below the rule: a waiting document is dropped, a sent one gets a delete job', async () => {
|
||||
const waiting: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'PENDING' }];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(waiting) as any, 'o1', 'ADMIN_APPROVED');
|
||||
expect(waiting[0].status).toBe('RESOLVED');
|
||||
|
||||
const sent: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' }];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(sent) as any, 'o1', 'ADMIN_APPROVED');
|
||||
expect(docs(sent, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Tahesab: reaching the rule again after a withdrawal queues a new document behind the delete', async () => {
|
||||
const rows: Row[] = [
|
||||
{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
|
||||
{ id: '2', provider: 'tahesab', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
|
||||
];
|
||||
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
|
||||
expect(docs(rows)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Zarrin: never queued twice (no duplicate guard in its API)', async () => {
|
||||
const rows: Row[] = [
|
||||
{ id: '1', provider: 'zarrin', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
|
||||
{ id: '2', provider: 'zarrin', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
|
||||
];
|
||||
await hooks('PREPARING', 'zarrin').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
|
||||
expect(docs(rows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing while the document feature is off', async () => {
|
||||
const rows: Row[] = [];
|
||||
const h = hooks('PLACED');
|
||||
((await (h as any).config.get()) as AccountingConfig).features.documents = false;
|
||||
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountingEntityType, AccountingOutboxKind, AccountingOutboxStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingFeatures, documentStatusRank } from './accounting-config';
|
||||
import { AccountingSyncService } from './accounting-sync.service';
|
||||
import { AccountingWorkerService } from './accounting-worker.service';
|
||||
import { ACCOUNTING_PROVIDERS } from './providers';
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaService;
|
||||
|
||||
const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING'];
|
||||
|
||||
/**
|
||||
* The narrow surface the rest of the app calls into (D27): orders, customer
|
||||
* admin, product admin, catalog. Every write goes through the caller's own
|
||||
* transaction client, so the outbox row commits or rolls back with the change
|
||||
* that caused it. Nothing here talks to the accounting software.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountingHooksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AccountingConfigService,
|
||||
private readonly sync: AccountingSyncService,
|
||||
private readonly worker: AccountingWorkerService,
|
||||
) {}
|
||||
|
||||
private async enqueue(tx: Tx, feature: keyof AccountingFeatures, kind: AccountingOutboxKind, entityId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features[feature]) return false;
|
||||
const open = await tx.accountingOutbox.findFirst({
|
||||
where: { provider: c.provider, kind, entityId, status: { in: OPEN } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (open) return false;
|
||||
await tx.accountingOutbox.create({ data: { provider: c.provider, kind, entityId } });
|
||||
this.worker.kickSoon();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 — the order is now in `status` (created, advanced, or rolled back).
|
||||
* The owner's rule (`documents.triggerStatus`, «اتصال حسابداری») says from
|
||||
* which status on the temporary document (پیشفاکتور) belongs in the books:
|
||||
* at or past it the document is queued once; back below it, a document
|
||||
* already sent is withdrawn like a cancellation.
|
||||
*/
|
||||
async orderStatusReached(tx: Tx, orderId: string, status: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.documents) return;
|
||||
const rank = documentStatusRank(status);
|
||||
if (!rank) return;
|
||||
if (rank >= documentStatusRank(c.documents.triggerStatus)) await this.queueOrderDocument(tx, c.provider, orderId);
|
||||
else await this.orderCancelled(tx, orderId);
|
||||
}
|
||||
|
||||
private async queueOrderDocument(tx: Tx, provider: string, orderId: string) {
|
||||
const existing = await tx.accountingOutbox.findFirst({
|
||||
where: { provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { notIn: ['RESOLVED'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
// Withdrawn and wanted again: Tahesab deletes first (older job), then
|
||||
// the new one posts — and a duplicate is refused by its Factor_Code key.
|
||||
// Zarrin has no such key, so its document is never queued twice.
|
||||
const deleting = await tx.accountingOutbox.findFirst({
|
||||
where: { provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { in: ['PENDING', 'SENDING'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!(provider === 'tahesab' && deleting)) return;
|
||||
}
|
||||
await this.enqueue(tx, 'documents', 'ORDER_DOCUMENT', orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 — the order was cancelled. A document still waiting is simply dropped;
|
||||
* one that may already be in the books gets a delete job, which itself only
|
||||
* deletes a document that is still temporary (worker).
|
||||
*/
|
||||
async orderCancelled(tx: Tx, orderId: string) {
|
||||
const docs = await tx.accountingOutbox.findMany({
|
||||
where: { kind: 'ORDER_DOCUMENT', entityId: orderId, status: { not: 'RESOLVED' } },
|
||||
});
|
||||
for (const doc of docs) {
|
||||
if (doc.status === 'PENDING') {
|
||||
await tx.accountingOutbox.update({
|
||||
where: { id: doc.id },
|
||||
data: { status: 'RESOLVED', lastError: 'سفارش پیش از ارسال سند لغو شد' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const open = await tx.accountingOutbox.findFirst({
|
||||
where: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { not: 'RESOLVED' } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!open) {
|
||||
await tx.accountingOutbox.create({
|
||||
data: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** F1 — an admin marked the customer VERIFIED. */
|
||||
async customerVerified(tx: Tx, customerId: string) {
|
||||
await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
|
||||
}
|
||||
|
||||
/** F1 — a linked customer's contact details changed. */
|
||||
async customerEdited(tx: Tx, customerId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.customers) return;
|
||||
const link = await tx.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: c.provider, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (link) await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
|
||||
}
|
||||
|
||||
/** F4 — a product was created in the admin panel. */
|
||||
async productCreated(tx: Tx, productId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !ACCOUNTING_PROVIDERS[c.provider].capabilities.createKarSakhte) return;
|
||||
await this.enqueue(tx, 'products', 'PRODUCT_PUSH', productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 — title and code of a linked product belong to the accounting software
|
||||
* (D27); the admin form locks them. Null when the product is not linked.
|
||||
*/
|
||||
async productLink(productId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.products) return null;
|
||||
return this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: c.provider, entityType: AccountingEntityType.PRODUCT, entityId: productId } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Order time: the product with its stock read live from the accounting
|
||||
* software, or as last stored when that cannot answer in a moment.
|
||||
*/
|
||||
liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(product: T) {
|
||||
return this.sync.liveStock(product);
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 — is this product out of stock in the accounting software? Only
|
||||
* meaningful while the product sync is on; stock fields of products that are
|
||||
* not linked under the active provider are cleared by every product pull.
|
||||
*/
|
||||
async stockBlocked(product: {
|
||||
accountingStockAt: Date | null;
|
||||
accountingStockWeight: Prisma.Decimal | null;
|
||||
accountingStockCount: Prisma.Decimal | null;
|
||||
}) {
|
||||
if (!product.accountingStockAt) return false;
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.products) return false;
|
||||
const stock = product.accountingStockWeight ?? product.accountingStockCount;
|
||||
return stock !== null && new Prisma.Decimal(stock).lte(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as https from 'https';
|
||||
import { AddressInfo } from 'net';
|
||||
import { AccountingHttpError, fetchServerCertificate, httpRequest, pinnedFingerprints } from './accounting-http';
|
||||
|
||||
// Throwaway self-signed test certificates (EC P-256, valid to 2126), issued
|
||||
// for *.invalid names so connecting to 127.0.0.1 never matches them — the
|
||||
// same situation as a Tahesab install whose certificate says `localhost`.
|
||||
const CERT_A = `-----BEGIN CERTIFICATE-----
|
||||
MIIBxTCCAWugAwIBAgIUNaqVnWRRUyN8hOGPQtARjZymRcUwCgYIKoZIzj0EAwIw
|
||||
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWEuaW52YWxpZDAgFw0yNjEwMDUx
|
||||
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
|
||||
ZXN0LWEuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFC+h8KB1h41
|
||||
56L9+l8nHbMZ5hrrp9nwqmz5RoAqrkVI8Ii5RFYUjssaH2Y0P9+B9L9SvijfB5Xi
|
||||
Wwk6eVzGKtSjeTB3MB0GA1UdDgQWBBSokIrPzA6ur33vyVeBY+2/NWHx1TAfBgNV
|
||||
HSMEGDAWgBSokIrPzA6ur33vyVeBY+2/NWHx1TAPBgNVHRMBAf8EBTADAQH/MCQG
|
||||
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1hLmludmFsaWQwCgYIKoZIzj0EAwID
|
||||
SAAwRQIgCLZVDlrE2aO8poWAdZMOd+obOeB23TU1Jfh3nnbEDF0CIQCB5+8PNRQe
|
||||
aWzW6sgCcDWdiga81Nhm7FxYj4AorbPO1w==
|
||||
-----END CERTIFICATE-----`;
|
||||
const KEY_A = `-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOQ//h9ZRCK1KaiXW
|
||||
Ij3FN+77RDPYSWgY6DwZqC5x0A6hRANCAARQvofCgdYeNeei/fpfJx2zGeYa66fZ
|
||||
8Kps+UaAKq5FSPCIuURWFI7LGh9mND/fgfS/Ur4o3weV4lsJOnlcxirU
|
||||
-----END PRIVATE KEY-----`;
|
||||
const CERT_B = `-----BEGIN CERTIFICATE-----
|
||||
MIIBxjCCAWugAwIBAgIUMO+HE/U7Z4EXTnw9ucoKjRz/u7MwCgYIKoZIzj0EAwIw
|
||||
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWIuaW52YWxpZDAgFw0yNjEwMDUx
|
||||
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
|
||||
ZXN0LWIuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABI2Naohejia1
|
||||
fQnFx33/5jasQJudFTmAdPEHxlIyBlUUi2IoaMWbSyMBBfgy+dX6gM7QetK/99ex
|
||||
rt/d5OQNyvqjeTB3MB0GA1UdDgQWBBSgpkkpJptOajy+1aSgPZ8gre559TAfBgNV
|
||||
HSMEGDAWgBSgpkkpJptOajy+1aSgPZ8gre559TAPBgNVHRMBAf8EBTADAQH/MCQG
|
||||
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1iLmludmFsaWQwCgYIKoZIzj0EAwID
|
||||
SQAwRgIhALJMbkGHT256tdNcVJfnB0eqxivGD7hooljD6epmvTgbAiEA1r/cX59l
|
||||
0bu56aMEi0TeoQAAQRiEpdBIwe0F/YjWXL4=
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
describe('accounting HTTP client — TLS with a pinned certificate', () => {
|
||||
let server: https.Server;
|
||||
let url: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = https.createServer({ cert: CERT_A, key: KEY_A }, (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"Status":"OK"}');
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
url = `https://127.0.0.1:${(server.address() as AddressInfo).port}/`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
const call = (ca: string | null) => httpRequest({ method: 'POST', url, body: '{}', timeoutMs: 5000, ca });
|
||||
|
||||
it('rejects a self-signed server when nothing is pinned, without claiming it may have been received', async () => {
|
||||
const err = await call(null).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(AccountingHttpError);
|
||||
expect(err.ambiguous).toBe(false);
|
||||
expect(err.message).toContain('بررسی گواهی سرور');
|
||||
});
|
||||
|
||||
it('accepts exactly the pinned certificate even though it names another host', async () => {
|
||||
await expect(call(CERT_A)).resolves.toEqual({ status: 200, body: '{"Status":"OK"}' });
|
||||
});
|
||||
|
||||
it('still rejects when a different certificate is pinned', async () => {
|
||||
const err = await call(CERT_B).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(AccountingHttpError);
|
||||
expect(err.message).toContain('یکی نیست');
|
||||
});
|
||||
|
||||
it('reads the presented certificate for the admin to compare and pin', async () => {
|
||||
const c = await fetchServerCertificate(url, 5000);
|
||||
expect(c.fingerprint256).toBe([...pinnedFingerprints(CERT_A)][0]);
|
||||
expect(c.selfSigned).toBe(true);
|
||||
expect(c.trustedBySystem).toBe(false);
|
||||
expect(c.subjectAltName).toContain('accounting-test-a.invalid');
|
||||
expect(pinnedFingerprints(c.pem).has(c.fingerprint256)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to fetch a certificate from a plain-http address', async () => {
|
||||
await expect(fetchServerCertificate('http://127.0.0.1:1/', 1000)).rejects.toThrow('https');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { X509Certificate } from 'crypto';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
|
||||
/**
|
||||
* The one HTTP client both accounting drivers use.
|
||||
*
|
||||
* Built on node:http(s) rather than global fetch because a self-hosted
|
||||
* Tahesab/Zarrin often serves a self-signed certificate, and fetch cannot be
|
||||
* given a CA without an extra dependency. The reference "solved" that with
|
||||
* `Http::withoutVerifying()` (SEC-17); here verification is ALWAYS on and the
|
||||
* admin can paste the installation's certificate instead.
|
||||
*
|
||||
* A pasted certificate is trusted two ways: as a CA (the normal chain and
|
||||
* hostname checks), and as a pin — when the server presents exactly that
|
||||
* certificate it is accepted whatever name it was issued for. Tahesab's own
|
||||
* certificate is self-signed for `localhost`, so a remote install reached by
|
||||
* IP can only be verified by the pin.
|
||||
*
|
||||
* Errors say whether the request may have reached the server. That decides
|
||||
* what the outbox may do next: a refused connection is safe to retry, a
|
||||
* timeout after the request went out is not (Zarrin has no idempotency key).
|
||||
*/
|
||||
|
||||
export interface HttpRequest {
|
||||
method: 'GET' | 'POST';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs: number;
|
||||
/** PEM CA certificate(s) to trust in addition to the system store. */
|
||||
ca?: string | null;
|
||||
}
|
||||
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class AccountingHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** True when the request may have been received and acted on. */
|
||||
readonly ambiguous: boolean,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountingHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const TLS_CERT_ERRORS = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/;
|
||||
|
||||
/** The PEM blocks in a pasted bundle, parsed; unparsable blocks are skipped. */
|
||||
export function parsePemCertificates(pem: string | null | undefined): X509Certificate[] {
|
||||
const blocks = (pem ?? '').match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
|
||||
const out: X509Certificate[] = [];
|
||||
for (const block of blocks) {
|
||||
try {
|
||||
out.push(new X509Certificate(block));
|
||||
} catch {
|
||||
// not a certificate — the CA list below will reject it too
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprints, as Node prints them (`AB:CD:…`), of the pasted certificates. */
|
||||
export function pinnedFingerprints(pem: string | null | undefined): Set<string> {
|
||||
return new Set(parsePemCertificates(pem).map((c) => c.fingerprint256));
|
||||
}
|
||||
|
||||
function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string {
|
||||
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
||||
return pinned
|
||||
? `گواهی سرور برای این آدرس صادر نشده و با گواهی ثبتشده در تنظیمات هم یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
|
||||
: `گواهی TLS سرور برای این آدرس صادر نشده (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
|
||||
}
|
||||
return pinned
|
||||
? `گواهی فعلی سرور با گواهی ثبتشده در تنظیمات یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
|
||||
: `گواهی TLS سرور قابلاعتماد نیست (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
|
||||
}
|
||||
|
||||
export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url);
|
||||
} catch {
|
||||
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
|
||||
}
|
||||
const isHttps = url.protocol === 'https:';
|
||||
if (!isHttps && url.protocol !== 'http:') {
|
||||
return Promise.reject(new AccountingHttpError('آدرس باید با http یا https شروع شود', false, 'BAD_URL'));
|
||||
}
|
||||
|
||||
const headers: Record<string, string | number> = { ...(req.headers ?? {}) };
|
||||
if (req.body !== undefined) headers['Content-Length'] = Buffer.byteLength(req.body);
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
method: req.method,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers,
|
||||
};
|
||||
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
|
||||
if (isHttps) {
|
||||
options.rejectUnauthorized = true;
|
||||
if (req.ca && req.ca.trim()) {
|
||||
// Extra CA on top of the system store, never instead of verification.
|
||||
options.ca = [...tls.rootCertificates, req.ca];
|
||||
// Runs only after the chain verified. The exact pasted certificate is
|
||||
// accepted under any name; anything else gets the normal name check.
|
||||
// (Pooled sockets are keyed by `ca`, so a reused one had the same pins.)
|
||||
options.checkServerIdentity = (host, cert) =>
|
||||
pins.has(cert.fingerprint256) ? undefined : tls.checkServerIdentity(host, cert);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<HttpResponse>((resolve, reject) => {
|
||||
let connected = false;
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const client = isHttps ? https : http;
|
||||
const request = client.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
request.destroy();
|
||||
finish(() => reject(new AccountingHttpError('پاسخ سرور حسابداری بیش از حد بزرگ است', true, 'TOO_LARGE')));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () =>
|
||||
finish(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })),
|
||||
);
|
||||
res.on('error', (err) => finish(() => reject(new AccountingHttpError(err.message, true, 'RESPONSE'))));
|
||||
});
|
||||
|
||||
request.on('socket', (socket) => {
|
||||
// A kept-alive socket is already connected; listening on it again would
|
||||
// pile up a listener per request.
|
||||
if (!(socket as { connecting?: boolean }).connecting) {
|
||||
connected = true;
|
||||
return;
|
||||
}
|
||||
socket.once(isHttps ? 'secureConnect' : 'connect', () => {
|
||||
connected = true;
|
||||
});
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
request.destroy();
|
||||
finish(() =>
|
||||
reject(
|
||||
new AccountingHttpError(
|
||||
connected ? 'پاسخ سرور حسابداری در زمان مقرر نرسید' : 'اتصال به سرور حسابداری برقرار نشد (timeout)',
|
||||
connected,
|
||||
'TIMEOUT',
|
||||
),
|
||||
),
|
||||
);
|
||||
}, req.timeoutMs);
|
||||
|
||||
request.on('error', (err: NodeJS.ErrnoException) => {
|
||||
// Node's own text for these suggests flags that do not apply here.
|
||||
const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message;
|
||||
finish(() => reject(new AccountingHttpError(text, connected, err.code)));
|
||||
});
|
||||
|
||||
if (req.body !== undefined) request.write(req.body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export interface ServerCertificate {
|
||||
pem: string;
|
||||
subject: string;
|
||||
issuer: string;
|
||||
subjectAltName: string | null;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
expired: boolean;
|
||||
fingerprint256: string;
|
||||
selfSigned: boolean;
|
||||
/** Valid chain to a system CA and issued for this host: no pin needed. */
|
||||
trustedBySystem: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the certificate an https server presents, WITHOUT trusting it, so the
|
||||
* admin can compare its fingerprint with the one on the accounting server and
|
||||
* pin it. Nothing but the TLS handshake is sent.
|
||||
*/
|
||||
export function fetchServerCertificate(baseUrl: string, timeoutMs: number): Promise<ServerCertificate> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
return Promise.reject(new AccountingHttpError('این آدرس https نیست؛ گواهی ندارد', false, 'NOT_HTTPS'));
|
||||
}
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
return new Promise<ServerCertificate>((resolve, reject) => {
|
||||
const socket = tls.connect({
|
||||
host,
|
||||
port: Number(url.port || 443),
|
||||
servername: net.isIP(host) ? undefined : host,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new AccountingHttpError('اتصال به سرور حسابداری برقرار نشد (timeout)', false, 'TIMEOUT'));
|
||||
}, timeoutMs);
|
||||
socket.once('secureConnect', () => {
|
||||
clearTimeout(timer);
|
||||
const peer = socket.getPeerCertificate();
|
||||
const chainOk = socket.authorized;
|
||||
socket.end();
|
||||
if (!peer?.raw) {
|
||||
reject(new AccountingHttpError('سرور گواهی ارسال نکرد', false, 'NO_CERT'));
|
||||
return;
|
||||
}
|
||||
const cert = new X509Certificate(peer.raw);
|
||||
const nameOk = net.isIP(host) ? !!cert.checkIP(host) : !!cert.checkHost(host);
|
||||
const b64 = peer.raw.toString('base64').match(/.{1,64}/g)!.join('\n');
|
||||
resolve({
|
||||
pem: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`,
|
||||
subject: cert.subject,
|
||||
issuer: cert.issuer,
|
||||
subjectAltName: cert.subjectAltName ?? null,
|
||||
validFrom: cert.validFrom,
|
||||
validTo: cert.validTo,
|
||||
expired: new Date(cert.validTo).getTime() < Date.now(),
|
||||
fingerprint256: cert.fingerprint256,
|
||||
// checkIssued() also wants keyCertSign, which Tahesab's certificate lacks.
|
||||
selfSigned: cert.subject === cert.issuer && cert.verify(cert.publicKey),
|
||||
trustedBySystem: chainOk && nameOk,
|
||||
});
|
||||
});
|
||||
socket.once('error', (err: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timer);
|
||||
reject(new AccountingHttpError(err.message, false, err.code));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON body that may carry junk around it. Tahesab replies have been
|
||||
* seen with a BOM, raw control characters and unescaped backslashes inside
|
||||
* strings (henza_server TahesabController::test had to scrub them the same
|
||||
* way before json_decode would accept the body).
|
||||
*/
|
||||
export function parseLooseJson(body: string): unknown {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
// fall through to the scrubbed attempt
|
||||
}
|
||||
let raw = body.replace(/^/, '');
|
||||
raw = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
raw = raw.replace(/\\(?!["\\/bfnrtu])/g, '-');
|
||||
const start = raw.search(/[{[]/);
|
||||
if (start > 0) raw = raw.slice(start);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
import { AccountingAdminService } from './accounting-admin.service';
|
||||
import { AccountingSyncService } from './accounting-sync.service';
|
||||
|
||||
/**
|
||||
* D27, plan §12 — sync where people work, on top of the «اتصال حسابداری»
|
||||
* screen: a button on the wholesale-customer list and on the product list,
|
||||
* and the customer's own balance read live each time the app's profile opens.
|
||||
* Each is behind the permission of the screen it sits on, and works whenever a
|
||||
* provider is connected, whatever the feature switches say (owner's ruling).
|
||||
*/
|
||||
@Controller()
|
||||
export class AccountingQuickController {
|
||||
constructor(
|
||||
private readonly admin: AccountingAdminService,
|
||||
private readonly sync: AccountingSyncService,
|
||||
) {}
|
||||
|
||||
/** Whether to show the sync buttons. Any signed-in admin; reveals no settings. */
|
||||
@Get('admin/accounting-status')
|
||||
status() {
|
||||
return this.admin.connectionStatus();
|
||||
}
|
||||
|
||||
@Post('admin/customers-omde/accounting-sync')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
syncCustomers() {
|
||||
return this.admin.syncNow(['customers', 'balances']);
|
||||
}
|
||||
|
||||
@Post('admin/products/accounting-sync')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
syncProducts() {
|
||||
return this.admin.syncNow(['products']);
|
||||
}
|
||||
|
||||
/** The app's profile: this customer's balance, read from the software now. */
|
||||
@CustomerAuth()
|
||||
@Get('customer/me/accounting-balance')
|
||||
myBalance(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.sync.customerBalanceLive(me.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AccountingAdminController } from './accounting-admin.controller';
|
||||
import { AccountingAdminService } from './accounting-admin.service';
|
||||
import { AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingHooksService } from './accounting-hooks.service';
|
||||
import { AccountingQuickController } from './accounting-quick.controller';
|
||||
import { AccountingSyncService } from './accounting-sync.service';
|
||||
import { AccountingWorkerService } from './accounting-worker.service';
|
||||
|
||||
/**
|
||||
* Accounting software integration (D27). Global so orders, customer admin,
|
||||
* product admin, the catalog and the report can call the hooks without each
|
||||
* re-importing it — the same reason SmsModule is global.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AccountingAdminController, AccountingQuickController],
|
||||
providers: [
|
||||
AccountingConfigService,
|
||||
AccountingHooksService,
|
||||
AccountingSyncService,
|
||||
AccountingWorkerService,
|
||||
AccountingAdminService,
|
||||
],
|
||||
exports: [AccountingConfigService, AccountingHooksService, AccountingSyncService],
|
||||
})
|
||||
export class AccountingSyncModule {}
|
||||
@@ -0,0 +1,949 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AccountingOutbox, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingConfig } from './accounting-config';
|
||||
import { JalaliDate, parseJalali, toJalali } from './jalali';
|
||||
import {
|
||||
AccountingProviderError,
|
||||
CustomerPayload,
|
||||
DocumentLine,
|
||||
ExternalCustomer,
|
||||
ExternalGroup,
|
||||
ExternalKarSakhte,
|
||||
ProviderName,
|
||||
StatementRow,
|
||||
normalizeMobile,
|
||||
splitName,
|
||||
} from './providers';
|
||||
|
||||
/** A job that must stop and wait for a human, not be retried. */
|
||||
export class NeedsReviewError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NeedsReviewError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CUSTOMER_PAGES = 1000;
|
||||
// Real-time (owner, 2026-10-05): the change poll keeps stored figures a few
|
||||
// seconds old; these windows only absorb a burst of reads of the same figure.
|
||||
const STATEMENT_CACHE_MS = 10_000;
|
||||
const BALANCE_FRESH_MS = 5_000;
|
||||
const BALANCE_ON_DEMAND_TIMEOUT_MS = 3_000;
|
||||
const STOCK_ON_DEMAND_TIMEOUT_MS = 2_500;
|
||||
|
||||
/** Lowercase alphanumeric, ≥ 20 chars: Tahesab lowercases Factor_Code and dedupes on it. */
|
||||
export function factorCodeFor(orderId: string) {
|
||||
return `gbord${orderId}`.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function groupMatches(selected: ExternalGroup[], c: { groupId: string | null; groupName: string | null }) {
|
||||
if (selected.length === 0) return true;
|
||||
return selected.some(
|
||||
(g) => (g.id !== '' && c.groupId !== null && g.id === c.groupId) || (!!g.name && !!c.groupName && g.name === c.groupName),
|
||||
);
|
||||
}
|
||||
|
||||
/** A real Iranian national code (10 digits, checksum), excluding the all-same placeholders. */
|
||||
export function validNationalCode(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
const d = v.replace(/\D/g, '');
|
||||
if (!/^\d{10}$/.test(d) || /^(\d)\1{9}$/.test(d)) return null;
|
||||
const check = Number(d[9]);
|
||||
const sum = d
|
||||
.slice(0, 9)
|
||||
.split('')
|
||||
.reduce((s, ch, i) => s + Number(ch) * (10 - i), 0);
|
||||
const r = sum % 11;
|
||||
return (r < 2 ? check === r : check === 11 - r) ? d : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the accounting integration does (D27, plan
|
||||
* docs/ACCOUNTING-INTEGRATION-PLAN.md §4): pulls on a schedule, outbox jobs,
|
||||
* and the reads the customer app makes. Each public pull takes the active
|
||||
* provider so the worker resolves configuration once per run.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountingSyncService {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private readonly statementCache = new Map<string, { at: number; rows: StatementRow[] }>();
|
||||
private itemCache: { provider: string; at: number; items: ExternalKarSakhte[] } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AccountingConfigService,
|
||||
) {}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F1 — customers
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Reads every account (in the selected groups) and links or imports it.
|
||||
* Two passes: all accounts first, so two accounts sharing one mobile are
|
||||
* caught before either is linked.
|
||||
*/
|
||||
async pullCustomers(a: ActiveProvider) {
|
||||
// No groups must not mean "all": cash boxes, staff and investors are
|
||||
// accounts too, and would become app customers.
|
||||
if (a.config.mapping.importCustomerGroups.length === 0) {
|
||||
throw new Error('هیچ گروه مشتری برای ورود انتخاب نشده است (تب «نگاشت»)');
|
||||
}
|
||||
const provider = a.provider.name;
|
||||
const all: ExternalCustomer[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < MAX_CUSTOMER_PAGES; page += 1) {
|
||||
const res = await a.provider.listCustomers(a.settings, cursor);
|
||||
all.push(...res.items);
|
||||
if (!res.next) break;
|
||||
cursor = res.next;
|
||||
}
|
||||
|
||||
const inScope = all.filter((c) => groupMatches(a.config.mapping.importCustomerGroups, c));
|
||||
const byMobile = new Map<string, ExternalCustomer[]>();
|
||||
for (const c of inScope) {
|
||||
if (!c.mobile) continue;
|
||||
byMobile.set(c.mobile, [...(byMobile.get(c.mobile) ?? []), c]);
|
||||
}
|
||||
|
||||
const existingLinks = await this.prisma.accountingLink.findMany({
|
||||
where: { provider, entityType: 'CUSTOMER' },
|
||||
select: { id: true, entityId: true, externalCode: true },
|
||||
});
|
||||
const linkByCode = new Map(existingLinks.map((l) => [l.externalCode, l]));
|
||||
const linkedCustomerIds = new Set(existingLinks.map((l) => l.entityId));
|
||||
|
||||
const summary = { scanned: all.length, inScope: inScope.length, refreshed: 0, linked: 0, imported: 0, issues: 0 };
|
||||
|
||||
for (const ext of inScope) {
|
||||
const snapshot = ext as unknown as Prisma.InputJsonValue;
|
||||
const link = linkByCode.get(ext.code);
|
||||
if (link) {
|
||||
await this.prisma.accountingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { externalName: ext.name, snapshot, syncedAt: new Date() },
|
||||
});
|
||||
summary.refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ext.mobile) {
|
||||
await this.issue(provider, 'CUSTOMER_NO_MOBILE', ext.code, `حساب «${ext.name}» (کد ${ext.code}) موبایل معتبر ندارد`, ext);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
const siblings = byMobile.get(ext.mobile) ?? [];
|
||||
if (siblings.length > 1) {
|
||||
await this.issue(
|
||||
provider,
|
||||
'CUSTOMER_DUPLICATE_MOBILE',
|
||||
ext.code,
|
||||
`موبایل ${ext.mobile} در ${siblings.length} حساب حسابداری تکرار شده (کدها: ${siblings.map((s) => s.code).join('، ')})`,
|
||||
siblings,
|
||||
);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const appCustomer = await this.prisma.customer.findUnique({ where: { mobile: ext.mobile }, select: { id: true } });
|
||||
if (appCustomer) {
|
||||
if (linkedCustomerIds.has(appCustomer.id)) {
|
||||
await this.issue(
|
||||
provider,
|
||||
'CUSTOMER_LINK_CONFLICT',
|
||||
ext.code,
|
||||
`مشتری با موبایل ${ext.mobile} قبلاً به حساب دیگری لینک شده؛ حساب ${ext.code} لینک نشد`,
|
||||
ext,
|
||||
appCustomer.id,
|
||||
);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'CUSTOMER', entityId: appCustomer.id, externalCode: ext.code, externalName: ext.name, origin: 'LINKED', snapshot },
|
||||
});
|
||||
linkedCustomerIds.add(appCustomer.id);
|
||||
summary.linked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.importCustomer(provider, ext);
|
||||
linkedCustomerIds.add(created);
|
||||
summary.imported += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* An account that exists only in the accounting software becomes an app
|
||||
* customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The
|
||||
* national code is the account's own when it is a real one and still free,
|
||||
* else the mobile — the fallback admin-customers and customer-auth already use.
|
||||
* Credit limit 0 and UNVERIFIED: everything beyond identity is the app's.
|
||||
*/
|
||||
private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise<string> {
|
||||
const mobile = ext.mobile as string;
|
||||
const candidates = [validNationalCode(ext.nationalCode), mobile, `${mobile}-${provider}-${ext.code}`].filter(
|
||||
(v): v is string => !!v,
|
||||
);
|
||||
let nationalCode = candidates[candidates.length - 1];
|
||||
for (const c of candidates) {
|
||||
const taken = await this.prisma.customer.findUnique({ where: { nationalCode: c }, select: { id: true } });
|
||||
if (!taken) {
|
||||
nationalCode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const { firstName, lastName } = splitName(ext.name || mobile);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const customer = await tx.customer.create({
|
||||
data: {
|
||||
// The DB trigger rebuilds fullName from the parts (schema.prisma).
|
||||
fullName: [firstName, lastName].filter(Boolean).join(' ') || mobile,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
nationalCode,
|
||||
passwordHash: '',
|
||||
phone: ext.phone,
|
||||
address: ext.address,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.accountingLink.create({
|
||||
data: {
|
||||
provider,
|
||||
entityType: 'CUSTOMER',
|
||||
entityId: customer.id,
|
||||
externalCode: ext.code,
|
||||
externalName: ext.name,
|
||||
origin: 'IMPORTED',
|
||||
snapshot: ext as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return customer.id;
|
||||
});
|
||||
}
|
||||
|
||||
private customerPayload(
|
||||
c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string },
|
||||
group: ExternalGroup | null,
|
||||
): CustomerPayload {
|
||||
const parts = c.firstName || c.lastName ? { firstName: c.firstName ?? '', lastName: c.lastName ?? '' } : splitName(c.fullName);
|
||||
return {
|
||||
firstName: parts.firstName,
|
||||
lastName: parts.lastName,
|
||||
fullName: c.fullName || [parts.firstName, parts.lastName].filter(Boolean).join(' '),
|
||||
mobile: normalizeMobile(c.mobile) ?? c.mobile,
|
||||
phone: c.phone,
|
||||
address: c.address,
|
||||
nationalCode: validNationalCode(c.nationalCode),
|
||||
group: { id: group?.id || null, name: group?.name || null },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the customer has an account in the accounting software and
|
||||
* returns its code: existing link → update it (when `update`); else search
|
||||
* by mobile → link; else create → link. Search-before-create is what makes a
|
||||
* retried push safe on both providers.
|
||||
*/
|
||||
async ensureCustomerAccount(a: ActiveProvider, customerId: string, opts: { update: boolean }) {
|
||||
const provider = a.provider.name;
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NeedsReviewError('مشتری در اپ پیدا نشد');
|
||||
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
});
|
||||
|
||||
if (link) {
|
||||
if (opts.update) {
|
||||
// Keep the account in its current group: Zarrin moves it otherwise.
|
||||
const snap = (link.snapshot ?? {}) as Partial<ExternalCustomer>;
|
||||
const group = snap.groupName || snap.groupId ? { id: snap.groupId ?? '', name: snap.groupName ?? '' } : a.config.mapping.newCustomerGroup;
|
||||
const payload = this.customerPayload(customer, group);
|
||||
const res = await a.provider.updateCustomer(a.settings, link.externalCode, payload);
|
||||
await this.prisma.accountingLink.update({ where: { id: link.id }, data: { syncedAt: new Date() } });
|
||||
return { code: link.externalCode, request: { action: 'update', code: link.externalCode, payload }, response: res.response };
|
||||
}
|
||||
return { code: link.externalCode, request: { action: 'none' }, response: null };
|
||||
}
|
||||
|
||||
const mobile = normalizeMobile(customer.mobile);
|
||||
if (!mobile) throw new NeedsReviewError(`موبایل مشتری (${customer.mobile}) معتبر نیست`);
|
||||
|
||||
const matches = await a.provider.findCustomersByMobile(a.settings, mobile);
|
||||
const free = [];
|
||||
for (const m of matches) {
|
||||
const taken = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_externalCode: { provider, entityType: 'CUSTOMER', externalCode: m.code } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!taken) free.push(m);
|
||||
}
|
||||
if (free.length > 1) {
|
||||
throw new NeedsReviewError(
|
||||
`موبایل ${mobile} در چند حساب حسابداری هست (کدها: ${free.map((m) => m.code).join('، ')}). از «لینک دستی» یکی را انتخاب کنید.`,
|
||||
);
|
||||
}
|
||||
if (free.length === 1) {
|
||||
const m = free[0];
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'CUSTOMER', entityId: customerId, externalCode: m.code, externalName: m.name, origin: 'LINKED', snapshot: m as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
return { code: m.code, request: { action: 'link', mobile }, response: m };
|
||||
}
|
||||
|
||||
if (!a.config.mapping.newCustomerGroup) {
|
||||
throw new NeedsReviewError('گروه مشتری جدید در تب «نگاشت» تعیین نشده؛ بعد از تعیین، «تلاش دوباره» را بزنید');
|
||||
}
|
||||
const payload = this.customerPayload(customer, a.config.mapping.newCustomerGroup);
|
||||
const res = await a.provider.createCustomer(a.settings, payload);
|
||||
await this.prisma.accountingLink.create({
|
||||
data: {
|
||||
provider,
|
||||
entityType: 'CUSTOMER',
|
||||
entityId: customerId,
|
||||
externalCode: res.code,
|
||||
externalName: payload.fullName,
|
||||
origin: 'PUSHED',
|
||||
snapshot: { ...payload, groupId: payload.group.id, groupName: payload.group.name } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return { code: res.code, request: { action: 'create', payload }, response: res.response };
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F2 — balances
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
async pullBalances(a: ActiveProvider) {
|
||||
const links = await this.prisma.accountingLink.findMany({
|
||||
where: { provider: a.provider.name, entityType: 'CUSTOMER' },
|
||||
select: { entityId: true, externalCode: true },
|
||||
});
|
||||
if (links.length === 0) return { linked: 0, updated: 0 };
|
||||
const balances = await a.provider.getBalances(
|
||||
a.settings,
|
||||
links.map((l) => l.externalCode),
|
||||
);
|
||||
const byCode = new Map(balances.map((b) => [b.code, b]));
|
||||
const now = new Date();
|
||||
let updated = 0;
|
||||
for (const l of links) {
|
||||
const b = byCode.get(l.externalCode);
|
||||
if (!b) continue;
|
||||
await this.prisma.customer.update({
|
||||
where: { id: l.entityId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
return { linked: links.length, updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* The balance the customer sees (D27): the accounting software's, refreshed
|
||||
* on demand when older than a minute, with a short timeout so the app never
|
||||
* waits on a slow installation — on any failure the last stored figure is
|
||||
* served. Null when the integration does not apply to this customer.
|
||||
*/
|
||||
async customerBalance(customerId: string) {
|
||||
const a = await this.config.activeFor('balances');
|
||||
if (!a) return null;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
});
|
||||
if (!link) return null;
|
||||
|
||||
let c = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS;
|
||||
if (stale) {
|
||||
try {
|
||||
const [b] = await a.provider.getBalances(
|
||||
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
||||
[link.externalCode],
|
||||
);
|
||||
if (b) {
|
||||
c = await this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() },
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!c?.accountingBalanceAt) return null;
|
||||
return {
|
||||
gold: c.accountingGoldBalance ?? new Prisma.Decimal(0),
|
||||
rial: c.accountingRialBalance ?? new Prisma.Decimal(0),
|
||||
at: c.accountingBalanceAt,
|
||||
provider: a.provider.label,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Real-time: change poll + live reads
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* One poll of the software's change feed. Only the accounts and کارساخته
|
||||
* that changed are re-read, so this is cheap enough to run every few
|
||||
* seconds. Returns the new cursor and what the slow jobs should still do.
|
||||
*/
|
||||
async pollChanges(a: ActiveProvider, cursor: string | null) {
|
||||
const batch = await a.provider.getChanges(a.settings, cursor);
|
||||
const out = { cursor: batch.cursor, balances: 0, stocks: 0, fullBalances: false, fullProducts: false, newAccounts: false };
|
||||
if (cursor === null) return out;
|
||||
const provider = a.provider.name;
|
||||
|
||||
if (a.config.features.balances) {
|
||||
if (batch.overflow) out.fullBalances = true;
|
||||
else if (batch.accountCodes.length > 0) {
|
||||
const links = await this.prisma.accountingLink.findMany({
|
||||
where: { provider, entityType: 'CUSTOMER', externalCode: { in: batch.accountCodes } },
|
||||
select: { entityId: true, externalCode: true },
|
||||
});
|
||||
out.newAccounts = links.length < batch.accountCodes.length;
|
||||
if (links.length > 0) out.balances = await this.storeBalances(a, links);
|
||||
}
|
||||
}
|
||||
|
||||
if (a.config.features.products) {
|
||||
if (batch.overflow) out.fullProducts = true;
|
||||
else if (batch.itemNames.length > 0) {
|
||||
const links = await this.prisma.accountingLink.findMany({
|
||||
where: { provider, entityType: 'PRODUCT', externalCode: { in: batch.itemNames } },
|
||||
select: { entityId: true, externalCode: true },
|
||||
});
|
||||
// A name with no product yet is a new کارساخته: the product pull imports it.
|
||||
if (links.length < batch.itemNames.length) out.fullProducts = true;
|
||||
for (const l of links) {
|
||||
if (await this.storeStock(a, l.entityId, l.externalCode, l.externalCode)) out.stocks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads the balances of these linked customers and stores them. */
|
||||
private async storeBalances(a: ActiveProvider, links: { entityId: string; externalCode: string }[]) {
|
||||
const balances = await a.provider.getBalances(a.settings, links.map((l) => l.externalCode));
|
||||
const byCode = new Map(balances.map((b) => [b.code, b]));
|
||||
const now = new Date();
|
||||
let n = 0;
|
||||
for (const l of links) {
|
||||
const b = byCode.get(l.externalCode);
|
||||
if (!b) continue;
|
||||
await this.prisma.customer.update({
|
||||
where: { id: l.entityId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
|
||||
});
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private async storeStock(a: ActiveProvider, productId: string, code: string, name: string, timeoutMs?: number) {
|
||||
const settings = timeoutMs ? { ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, timeoutMs) } : a.settings;
|
||||
const stock = await a.provider.getKarSakhteStock(settings, { code, name });
|
||||
const known = stock.weight !== null || stock.count !== null;
|
||||
await this.prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
accountingStockWeight: stock.weight,
|
||||
accountingStockCount: stock.count,
|
||||
accountingStockAt: known ? new Date() : null,
|
||||
},
|
||||
});
|
||||
return known;
|
||||
}
|
||||
|
||||
/**
|
||||
* At order time: the product's stock straight from the accounting software.
|
||||
* If it cannot answer in time the order goes ahead on the last stored figure
|
||||
* (owner's ruling) and the next change poll corrects it. Returns the product
|
||||
* fields stockBlocked() reads, fresh when it could be.
|
||||
*/
|
||||
async liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(
|
||||
product: T,
|
||||
): Promise<T> {
|
||||
const a = await this.config.activeFor('products');
|
||||
if (!a) return product;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'PRODUCT', entityId: product.id } },
|
||||
select: { externalCode: true, externalName: true },
|
||||
});
|
||||
if (!link) return product;
|
||||
try {
|
||||
await this.storeStock(a, product.id, link.externalCode, link.externalName ?? link.externalCode, STOCK_ON_DEMAND_TIMEOUT_MS);
|
||||
const fresh = await this.prisma.product.findUnique({
|
||||
where: { id: product.id },
|
||||
select: { accountingStockAt: true, accountingStockWeight: true, accountingStockCount: true },
|
||||
});
|
||||
return fresh ? { ...product, ...fresh } : product;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Live stock of product ${product.id} unavailable, using the stored figure: ${(err as Error).message}`);
|
||||
return product;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's profile (plan §12): this one customer's balance, read from the
|
||||
* accounting software on every call — no freshness window, no feature
|
||||
* switch, only a connected provider. On no answer the stored figure is
|
||||
* served and labelled as such.
|
||||
*/
|
||||
async customerBalanceLive(customerId: string) {
|
||||
const none = { source: 'none' as const, gold: null, rial: null, at: null, provider: null };
|
||||
const a = await this.config.active();
|
||||
if (!a) return none;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
select: { externalCode: true },
|
||||
});
|
||||
if (!link) return none;
|
||||
try {
|
||||
const [b] = await a.provider.getBalances(
|
||||
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
||||
[link.externalCode],
|
||||
);
|
||||
if (b) {
|
||||
const at = new Date();
|
||||
await this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: at },
|
||||
});
|
||||
return { source: 'accounting' as const, gold: b.goldGrams.toString(), rial: b.rial.toString(), at, provider: a.provider.label };
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Live balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
|
||||
}
|
||||
const c = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
if (!c?.accountingBalanceAt) return none;
|
||||
return {
|
||||
source: 'stored' as const,
|
||||
gold: (c.accountingGoldBalance ?? 0).toString(),
|
||||
rial: (c.accountingRialBalance ?? 0).toString(),
|
||||
at: c.accountingBalanceAt,
|
||||
provider: a.provider.label,
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F6 — statement («گزارش»)
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The customer's statement from the accounting software, oldest first, or
|
||||
* null when the integration does not apply (the report then falls back to
|
||||
* the internal ledger). Cached for a minute per customer so paging through
|
||||
* the report costs one call.
|
||||
*/
|
||||
async customerStatement(customerId: string, range?: { from?: string; to?: string }) {
|
||||
const a = await this.config.activeFor('report');
|
||||
if (!a) return null;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
||||
});
|
||||
if (!link) return null;
|
||||
|
||||
const to: JalaliDate = parseJalali(range?.to) ?? toJalali(new Date());
|
||||
const from: JalaliDate =
|
||||
parseJalali(range?.from) ?? toJalali(new Date(Date.now() - a.config.report.windowDays * 24 * 3600 * 1000));
|
||||
const key = `${a.provider.name}:${link.externalCode}:${from.jy}-${from.jm}-${from.jd}:${to.jy}-${to.jm}-${to.jd}`;
|
||||
const hit = this.statementCache.get(key);
|
||||
if (hit && Date.now() - hit.at < STATEMENT_CACHE_MS) return { rows: hit.rows, code: link.externalCode, provider: a.provider.label };
|
||||
|
||||
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from, to });
|
||||
// SEC-16: never hand back a line that belongs to another account, whatever
|
||||
// the software returned.
|
||||
const own = rows.filter((r) => !r.accountCode || r.accountCode === link.externalCode);
|
||||
this.statementCache.set(key, { at: Date.now(), rows: own });
|
||||
if (this.statementCache.size > 500) this.statementCache.delete(this.statementCache.keys().next().value as string);
|
||||
return { rows: own, code: link.externalCode, provider: a.provider.label };
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F4 — کارساخته ↔ products
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Accounting items → app products. A new item becomes a product with no
|
||||
* image and no variant, so it stays out of the app (F5) until an admin
|
||||
* photographs it and adds its weights. Title, code and stock follow the
|
||||
* accounting software; everything else is the app's (D27).
|
||||
*/
|
||||
async pullProducts(a: ActiveProvider) {
|
||||
const provider = a.provider.name;
|
||||
const items = await a.provider.listKarSakhte(a.settings);
|
||||
const links = await this.prisma.accountingLink.findMany({ where: { provider, entityType: 'PRODUCT' } });
|
||||
const linkByCode = new Map(links.map((l) => [l.externalCode, l]));
|
||||
const linkedProductIds = new Set(links.map((l) => l.entityId));
|
||||
const summary = { items: items.length, updated: 0, linked: 0, imported: 0, stockErrors: 0, issues: 0 };
|
||||
const now = new Date();
|
||||
const touched = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
let stock = { weight: item.stockWeight, count: item.stockCount };
|
||||
if (stock.weight === null && stock.count === null) {
|
||||
try {
|
||||
stock = await a.provider.getKarSakhteStock(a.settings, item);
|
||||
} catch (err) {
|
||||
summary.stockErrors += 1;
|
||||
this.logger.warn(`Stock of «${item.name}» unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
const stockData = {
|
||||
accountingStockWeight: stock.weight,
|
||||
accountingStockCount: stock.count,
|
||||
accountingStockAt: stock.weight !== null || stock.count !== null ? now : null,
|
||||
};
|
||||
const snapshot = item as unknown as Prisma.InputJsonValue;
|
||||
|
||||
const link = linkByCode.get(item.code);
|
||||
if (link) {
|
||||
await this.applyItemToProduct(provider, link.entityId, item, stockData);
|
||||
await this.prisma.accountingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { externalName: item.name, snapshot, syncedAt: now },
|
||||
});
|
||||
touched.add(link.entityId);
|
||||
summary.updated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A product an admin created by the same name (in Tahesab an item only
|
||||
// appears after its first document, which is how it gets linked).
|
||||
const sameName = await this.prisma.product.findMany({ where: { title: item.name }, select: { id: true } });
|
||||
const candidates = sameName.filter((p) => !linkedProductIds.has(p.id));
|
||||
if (candidates.length > 1) {
|
||||
await this.issue(provider, 'PRODUCT_DUPLICATE_TITLE', item.code, `چند محصول با عنوان «${item.name}» هست؛ کارساخته لینک نشد`, item);
|
||||
summary.issues += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
const productId = candidates[0].id;
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot },
|
||||
});
|
||||
await this.applyItemToProduct(provider, productId, item, stockData);
|
||||
linkedProductIds.add(productId);
|
||||
touched.add(productId);
|
||||
summary.linked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const productId = await this.prisma.$transaction(async (tx) => {
|
||||
const product = await tx.product.create({
|
||||
data: { title: item.name, ...stockData },
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: product.id, externalCode: item.code, externalName: item.name, origin: 'IMPORTED', snapshot },
|
||||
});
|
||||
return product.id;
|
||||
});
|
||||
await this.setProductCode(provider, productId, item);
|
||||
linkedProductIds.add(productId);
|
||||
touched.add(productId);
|
||||
summary.imported += 1;
|
||||
}
|
||||
|
||||
// Stock figures only mean something for products linked under the active
|
||||
// provider; anything else (unlinked, or from a provider switched away
|
||||
// from) must not keep blocking orders.
|
||||
await this.prisma.product.updateMany({
|
||||
where: { accountingStockAt: { not: null }, id: { notIn: [...touched] } },
|
||||
data: { accountingStockWeight: null, accountingStockCount: null, accountingStockAt: null },
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async applyItemToProduct(
|
||||
provider: ProviderName,
|
||||
productId: string,
|
||||
item: ExternalKarSakhte,
|
||||
stockData: Prisma.ProductUpdateInput,
|
||||
) {
|
||||
await this.prisma.product.update({ where: { id: productId }, data: { title: item.name, ...stockData } });
|
||||
await this.setProductCode(provider, productId, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zarrin items have a numeric id, which becomes the product's code. Tahesab
|
||||
* items have none (the name is the key), so the code stays the app's.
|
||||
*/
|
||||
private async setProductCode(provider: ProviderName, productId: string, item: ExternalKarSakhte) {
|
||||
if (provider !== 'zarrin') return;
|
||||
const owner = await this.prisma.product.findUnique({ where: { productCode: item.code }, select: { id: true } });
|
||||
if (owner && owner.id !== productId) {
|
||||
await this.issue(provider, 'PRODUCT_CODE_TAKEN', item.code, `کد ${item.code} روی محصول دیگری است؛ کد محصول «${item.name}» عوض نشد`, item, productId);
|
||||
return;
|
||||
}
|
||||
if (!owner) await this.prisma.product.update({ where: { id: productId }, data: { productCode: item.code } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a product has its کارساخته: existing link → done; an item by
|
||||
* the same name → link; else create it (Zarrin). Tahesab cannot create items
|
||||
* through its API — the item comes into being with the first document under
|
||||
* the product's title, and the next pull links it by name.
|
||||
*/
|
||||
async ensureProductItem(a: ActiveProvider, productId: string): Promise<{ code: string; name: string; created: boolean } | null> {
|
||||
const provider = a.provider.name;
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider, entityType: 'PRODUCT', entityId: productId } },
|
||||
});
|
||||
const product = await this.prisma.product.findUnique({ where: { id: productId }, select: { id: true, title: true, categoryId: true } });
|
||||
if (!product) throw new NeedsReviewError('محصول در اپ پیدا نشد');
|
||||
if (link) return { code: link.externalCode, name: link.externalName ?? product.title, created: false };
|
||||
|
||||
if (!a.provider.capabilities.createKarSakhte) return null;
|
||||
|
||||
const existing = (await this.itemsCached(a)).filter((i) => i.name === product.title);
|
||||
for (const item of existing) {
|
||||
const taken = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_externalCode: { provider, entityType: 'PRODUCT', externalCode: item.code } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!taken) {
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot: item as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
await this.setProductCode(provider, productId, item);
|
||||
return { code: item.code, name: item.name, created: false };
|
||||
}
|
||||
}
|
||||
|
||||
const m = a.config.mapping;
|
||||
const group = (product.categoryId && m.categoryGroups.find((x) => x.categoryId === product.categoryId)?.group) || m.newKarSakhteGroup;
|
||||
if (!group?.id) throw new NeedsReviewError('گروه ساخته برای این دستهبندی/پیشفرض در تنظیمات حسابداری تعیین نشده');
|
||||
if (!m.newKarSakhteType?.id) throw new NeedsReviewError('نوع کارساختهی پیشفرض در تنظیمات حسابداری تعیین نشده');
|
||||
|
||||
const res = await a.provider.createKarSakhte(a.settings, { name: product.title, groupId: group.id, typeId: m.newKarSakhteType.id });
|
||||
const item: ExternalKarSakhte = {
|
||||
code: res.code,
|
||||
name: product.title,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
typeId: m.newKarSakhteType.id,
|
||||
typeName: m.newKarSakhteType.name,
|
||||
saleWagePercent: null,
|
||||
stockWeight: null,
|
||||
stockCount: null,
|
||||
};
|
||||
await this.prisma.accountingLink.create({
|
||||
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: res.code, externalName: product.title, origin: 'PUSHED', snapshot: item as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
await this.setProductCode(provider, productId, item);
|
||||
return { code: res.code, name: product.title, created: true };
|
||||
}
|
||||
|
||||
/** Zarrin's listKarSakhte takes seconds; an order with several lines needs it once. */
|
||||
private async itemsCached(a: ActiveProvider) {
|
||||
const c = this.itemCache;
|
||||
if (c && c.provider === a.provider.name && Date.now() - c.at < STATEMENT_CACHE_MS) return c.items;
|
||||
const items = await a.provider.listKarSakhte(a.settings);
|
||||
this.itemCache = { provider: a.provider.name, at: Date.now(), items };
|
||||
return items;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// F3 — order documents
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
async buildOrderDocument(a: ActiveProvider, orderId: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { items: { include: { productVariant: { include: { product: true } } } } },
|
||||
});
|
||||
if (!order) throw new NeedsReviewError('سفارش در اپ پیدا نشد');
|
||||
if (order.items.length === 0) throw new NeedsReviewError('سفارش قلمی ندارد');
|
||||
|
||||
const customer = await this.ensureCustomerAccount(a, order.customerId, { update: false });
|
||||
|
||||
const lines: DocumentLine[] = [];
|
||||
let total = new Prisma.Decimal(0);
|
||||
for (const item of order.items) {
|
||||
const product = item.productVariant.product;
|
||||
let ref: { code: string; name: string };
|
||||
const ensured = await this.ensureProductItem(a, product.id);
|
||||
if (ensured) ref = ensured;
|
||||
else {
|
||||
// Tahesab: the item is identified by name and created by this document.
|
||||
ref = { code: product.title, name: product.title };
|
||||
}
|
||||
const qty = item.quantity;
|
||||
const wage = new Prisma.Decimal(item.wagePercentSnapshot).sub(item.discountPercentSnapshot);
|
||||
const weight = new Prisma.Decimal(item.rashWeightGramsSnapshot).mul(qty);
|
||||
total = total.add(new Prisma.Decimal(item.computedWeightGrams).mul(qty));
|
||||
lines.push({
|
||||
itemCode: ref.code,
|
||||
itemName: ref.name,
|
||||
weightGrams: weight,
|
||||
count: qty,
|
||||
wagePercent: wage,
|
||||
flatRial: new Prisma.Decimal(item.flatRialCharge).mul(qty),
|
||||
karat: a.config.mapping.karat,
|
||||
description: `${product.title} — ${item.productVariant.title} — سفارش ${order.orderNumber} — اجرت ${wage.toString()}٪`,
|
||||
});
|
||||
}
|
||||
|
||||
// Acceptance rule of F3: the gold the document moves must equal the
|
||||
// order's own total, or the two books disagree from the first day.
|
||||
const docGold = lines.reduce(
|
||||
(s, l) => s.add(l.weightGrams.mul(new Prisma.Decimal(1).add(l.wagePercent.div(100)))),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
const mismatch = docGold.sub(order.totalGoldWeightGrams).abs();
|
||||
if (mismatch.gt('0.01')) {
|
||||
throw new NeedsReviewError(
|
||||
`جمع وزن سند (${docGold.toFixed(3)}) با وزن سفارش (${new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3)}) نمیخواند`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
input: {
|
||||
factorCode: factorCodeFor(order.id),
|
||||
customerCode: customer.code,
|
||||
date: order.deliveredAt ?? new Date(),
|
||||
description: `سفارش ${order.orderNumber} از اپ`,
|
||||
temporary: true, // D27: the accountant finalises
|
||||
lines,
|
||||
},
|
||||
check: { orderTotal: new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3), documentGold: docGold.toFixed(3), linesTotal: total.toFixed(3) },
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// outbox jobs
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Runs one outbox job. Throws to signal failure; the worker decides retry vs review. */
|
||||
async runJob(a: ActiveProvider, job: AccountingOutbox): Promise<{ externalRef?: string | null; request?: unknown; response?: unknown }> {
|
||||
switch (job.kind) {
|
||||
case 'CUSTOMER_PUSH': {
|
||||
const res = await this.ensureCustomerAccount(a, job.entityId, { update: true });
|
||||
return { externalRef: res.code, request: res.request, response: res.response };
|
||||
}
|
||||
|
||||
case 'PRODUCT_PUSH': {
|
||||
const res = await this.ensureProductItem(a, job.entityId);
|
||||
return {
|
||||
externalRef: res?.code ?? null,
|
||||
request: { productId: job.entityId },
|
||||
response: res ?? { note: `${a.provider.label} ساخت کارساخته ندارد؛ با اولین سند ساخته میشود` },
|
||||
};
|
||||
}
|
||||
|
||||
case 'ORDER_DOCUMENT': {
|
||||
const doc = await this.buildOrderDocument(a, job.entityId);
|
||||
const request = { ...doc.input, lines: doc.input.lines.map((l) => ({ ...l, weightGrams: l.weightGrams.toFixed(3), wagePercent: l.wagePercent.toString(), flatRial: l.flatRial.toFixed(0) })), check: doc.check };
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { lastRequest: request as unknown as Prisma.InputJsonValue } });
|
||||
const result = await a.provider.postOrderDocument(
|
||||
a.settings,
|
||||
doc.input,
|
||||
(job.progress ?? {}) as Record<string, unknown>,
|
||||
async (p) => {
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { progress: p as unknown as Prisma.InputJsonValue } });
|
||||
},
|
||||
);
|
||||
return { externalRef: result.ref, request, response: result.response };
|
||||
}
|
||||
|
||||
case 'ORDER_DOCUMENT_DELETE': {
|
||||
const sent = await this.prisma.accountingOutbox.findFirst({
|
||||
where: { provider: job.provider, kind: 'ORDER_DOCUMENT', entityId: job.entityId, status: { in: ['SENT', 'SENDING', 'NEEDS_REVIEW', 'FAILED'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!sent) return { response: { note: 'سندی برای حذف ثبت نشده بود' } };
|
||||
// The order row may be gone (deleted from the admin panel); the sent
|
||||
// document's own request says which account it went to.
|
||||
const order = await this.prisma.order.findUnique({ where: { id: job.entityId }, select: { customerId: true, deliveredAt: true } });
|
||||
let customerCode = (sent.lastRequest as { customerCode?: string } | null)?.customerCode ?? null;
|
||||
if (!customerCode && order) {
|
||||
const link = await this.prisma.accountingLink.findUnique({
|
||||
where: { provider_entityType_entityId: { provider: job.provider, entityType: 'CUSTOMER', entityId: order.customerId } },
|
||||
});
|
||||
customerCode = link?.externalCode ?? null;
|
||||
}
|
||||
if (!customerCode) throw new NeedsReviewError('حساب مشتری در حسابداری معلوم نیست؛ سند را دستی حذف کنید');
|
||||
const link = { externalCode: customerCode };
|
||||
const factorCode = factorCodeFor(job.entityId);
|
||||
|
||||
// Only a document still temporary may be deleted automatically (plan
|
||||
// F3). Tahesab reports that per row; Zarrin cannot, so a person checks.
|
||||
if (job.provider === 'tahesab') {
|
||||
// The document is dated when it was built (or at delivery, if earlier).
|
||||
const posted = order?.deliveredAt && order.deliveredAt < sent.createdAt ? order.deliveredAt : sent.createdAt;
|
||||
const date = toJalali(posted);
|
||||
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from: date, to: toJalali(new Date()) });
|
||||
const docRows = rows.filter((r) => r.documentRef === factorCode);
|
||||
if (docRows.some((r) => !r.temporary)) {
|
||||
throw new NeedsReviewError('سند در حسابداری نهایی شده؛ حذف خودکار انجام نشد. حسابدار باید دستی اصلاح کند.');
|
||||
}
|
||||
} else {
|
||||
throw new NeedsReviewError(
|
||||
`${a.provider.label} وضعیت موقت/نهایی سند را گزارش نمیدهد؛ سند ${sent.externalRef ?? ''} را حسابدار بررسی و حذف کند.`,
|
||||
);
|
||||
}
|
||||
const res = await a.provider.deleteDocument(a.settings, { factorCode, ref: sent.externalRef, customerCode: link.externalCode });
|
||||
await this.prisma.accountingOutbox.update({
|
||||
where: { id: sent.id },
|
||||
data: { status: 'RESOLVED', lastError: 'سند بهخاطر لغو سفارش حذف شد' },
|
||||
});
|
||||
return { externalRef: sent.externalRef, request: { factorCode }, response: res.response };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a job whose call may or may not have landed safe to send again?
|
||||
* Customer/product pushes search before they create; Tahesab rejects a
|
||||
* repeated document row; a Zarrin document has no such guard.
|
||||
*/
|
||||
isRetrySafe(provider: ProviderName, kind: AccountingOutbox['kind']) {
|
||||
if (kind === 'ORDER_DOCUMENT') return provider === 'tahesab';
|
||||
return true;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
async issue(provider: ProviderName, kind: string, externalCode: string, message: string, data?: unknown, entityId?: string) {
|
||||
const json =
|
||||
data === undefined || data === null ? Prisma.JsonNull : (JSON.parse(JSON.stringify(data)) as Prisma.InputJsonValue);
|
||||
await this.prisma.accountingIssue.upsert({
|
||||
where: { provider_kind_externalCode: { provider, kind, externalCode } },
|
||||
update: { message, data: json, entityId: entityId ?? null, resolvedAt: null },
|
||||
create: { provider, kind, externalCode, message, data: json, entityId: entityId ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
describeError(err: unknown) {
|
||||
if (err instanceof AccountingProviderError) return err.message;
|
||||
if (err instanceof NeedsReviewError) return err.message;
|
||||
return (err as Error)?.message ?? String(err);
|
||||
}
|
||||
|
||||
/** For callers that only need to know the configuration. */
|
||||
activeConfig(): Promise<AccountingConfig> {
|
||||
return this.config.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { formatJalali, jalaliToIsoDate, parseJalali, toIsoDateTehran, toJalali } from './jalali';
|
||||
import { openSecret, sealSecret } from './secret-box';
|
||||
import { dec, normalizeMobile, splitName } from './providers/accounting-provider';
|
||||
import { factorCodeFor, validNationalCode } from './accounting-sync.service';
|
||||
import { parseLooseJson } from './accounting-http';
|
||||
import { isAccountingSettingKey, withDefaults } from './accounting-config';
|
||||
|
||||
describe('jalali', () => {
|
||||
it('converts a Tehran calendar day', () => {
|
||||
// The Tahesab test server reported 1405/07/13 on 2026-10-05.
|
||||
expect(formatJalali(toJalali(new Date('2026-10-05T08:00:00Z')), '/')).toBe('1405/07/13');
|
||||
// Nowruz 1404: 2025-03-21 00:30 in Tehran is still 20 March in UTC.
|
||||
expect(formatJalali(toJalali(new Date('2025-03-20T21:00:00Z')))).toBe('1404-01-01');
|
||||
});
|
||||
|
||||
it('goes back to Gregorian', () => {
|
||||
expect(jalaliToIsoDate({ jy: 1405, jm: 7, jd: 13 })).toBe('2026-10-05');
|
||||
expect(jalaliToIsoDate({ jy: 1403, jm: 12, jd: 30 })).toBe('2025-03-20');
|
||||
});
|
||||
|
||||
it('parses the forms the apps and Tahesab use, and rejects impossible days', () => {
|
||||
expect(parseJalali('1405/7/13')).toEqual({ jy: 1405, jm: 7, jd: 13 });
|
||||
expect(parseJalali('۱۴۰۵-۰۷-۱۳')).toEqual({ jy: 1405, jm: 7, jd: 13 });
|
||||
expect(parseJalali('1403/12/30')).not.toBeNull(); // leap year
|
||||
expect(parseJalali('1404/12/30')).toBeNull(); // common year
|
||||
expect(parseJalali('1405/07/31')).toBeNull();
|
||||
expect(parseJalali('2026-10-05x')).toBeNull();
|
||||
});
|
||||
|
||||
it('formats a Tehran ISO day', () => {
|
||||
expect(toIsoDateTehran(new Date('2026-10-04T21:00:00Z'))).toBe('2026-10-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret-box', () => {
|
||||
const saved = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
it('round-trips and never stores the plain token', () => {
|
||||
process.env.ACCOUNTING_SECRET_KEY = 'k1';
|
||||
const sealed = sealSecret('M3Y3D2Y7');
|
||||
expect(sealed.startsWith('enc:v1:')).toBe(true);
|
||||
expect(sealed).not.toContain('M3Y3D2Y7');
|
||||
expect(openSecret(sealed)).toBe('M3Y3D2Y7');
|
||||
});
|
||||
|
||||
it('cannot be opened with another key', () => {
|
||||
process.env.ACCOUNTING_SECRET_KEY = 'k1';
|
||||
const sealed = sealSecret('secret');
|
||||
process.env.ACCOUNTING_SECRET_KEY = 'k2';
|
||||
expect(openSecret(sealed)).toBeNull();
|
||||
expect(openSecret('plain-text')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to JWT_SECRET when the dedicated key is blank', () => {
|
||||
process.env.ACCOUNTING_SECRET_KEY = ' ';
|
||||
process.env.JWT_SECRET = 'jwt';
|
||||
expect(openSecret(sealSecret('x'))).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('normalises every mobile form the two programs store', () => {
|
||||
expect(normalizeMobile('9121234567')).toBe('09121234567');
|
||||
expect(normalizeMobile('09121234567')).toBe('09121234567');
|
||||
expect(normalizeMobile('+98 912 123 4567')).toBe('09121234567');
|
||||
expect(normalizeMobile('00989121234567')).toBe('09121234567');
|
||||
expect(normalizeMobile('۰۹۱۲۱۲۳۴۵۶۷')).toBe('09121234567');
|
||||
expect(normalizeMobile('02155662233')).toBeNull();
|
||||
expect(normalizeMobile('')).toBeNull();
|
||||
expect(normalizeMobile(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the numbers Tahesab sends as strings, and its literal "null"', () => {
|
||||
expect(dec('-177.431')?.toString()).toBe('-177.431');
|
||||
expect(dec('1,234')?.toString()).toBe('1234');
|
||||
expect(dec('null')).toBeNull();
|
||||
expect(dec(null)).toBeNull();
|
||||
expect(dec('abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts only real national codes', () => {
|
||||
expect(validNationalCode('0499370899')).toBe('0499370899');
|
||||
expect(validNationalCode('0000000000')).toBeNull(); // seen on the Tahesab test server
|
||||
expect(validNationalCode('1234567890')).toBeNull();
|
||||
expect(validNationalCode(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a Tahesab-safe Factor_Code from an order id', () => {
|
||||
const code = factorCodeFor('cm1abcdefghijklmnopqrstuv');
|
||||
expect(code).toBe(code.toLowerCase());
|
||||
expect(code).toMatch(/^[a-z0-9]+$/);
|
||||
expect(code.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('splits a name the way the DB trigger does', () => {
|
||||
expect(splitName('علی رضا محمدی')).toEqual({ firstName: 'علی', lastName: 'رضا محمدی' });
|
||||
expect(splitName('masoud')).toEqual({ firstName: 'masoud', lastName: '' });
|
||||
});
|
||||
|
||||
it('scrubs the junk Tahesab replies have carried', () => {
|
||||
expect(parseLooseJson('{"a":"x\\y"}')).toEqual({ a: 'x-y' });
|
||||
expect(parseLooseJson('garbage [1,2]')).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('fences off every accounting Setting key from the const routes', () => {
|
||||
expect(isAccountingSettingKey('accounting.config')).toBe(true);
|
||||
expect(isAccountingSettingKey('Accounting.lease')).toBe(true);
|
||||
expect(isAccountingSettingKey('wallet.config')).toBe(false);
|
||||
});
|
||||
|
||||
it('fills a partial stored config with defaults', () => {
|
||||
const c = withDefaults({ provider: 'tahesab', features: { balances: true } });
|
||||
expect(c.provider).toBe('tahesab');
|
||||
expect(c.features).toEqual({ customers: false, balances: true, report: false, products: false, documents: false });
|
||||
expect(c.tahesab.dbName).toBe('TahesabDB');
|
||||
expect(c.mapping.karat).toBe(750);
|
||||
expect(withDefaults({ provider: 'other' }).provider).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps Decimal maths exact for the wage-as-karat rule', () => {
|
||||
const w = new Prisma.Decimal(18.5);
|
||||
expect(new Prisma.Decimal(750).mul(new Prisma.Decimal(1).add(w.div(100))).toString()).toBe('888.75');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import { AccountingOutbox, Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { envOr } from '../../common/env';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ActiveProvider, AccountingConfigService, JobName } from './accounting-config.service';
|
||||
import { AccountingSyncService, NeedsReviewError } from './accounting-sync.service';
|
||||
import { AccountingNotSupportedError, AccountingProviderError } from './providers';
|
||||
|
||||
/** Scheduled pulls (customers, full balances, full products): the safety net. */
|
||||
const TICK_MS = 30_000;
|
||||
/** Outbox and change poll, the real-time path: checked this often. */
|
||||
const FAST_TICK_MS = 2_000;
|
||||
/** How often the change feed is polled. */
|
||||
const CHANGES_POLL_MS = 5_000;
|
||||
/** Long enough for a full customer import; renewed before every step. */
|
||||
const LEASE_MS = 10 * 60_000;
|
||||
const LEASE_KEY = 'accounting.lease';
|
||||
const OUTBOX_BATCH = 25;
|
||||
const MAX_ATTEMPTS = 8;
|
||||
|
||||
type PullJob = Exclude<JobName, 'outbox'>;
|
||||
|
||||
export const PULL_FEATURE: Record<PullJob, 'customers' | 'balances' | 'products'> = {
|
||||
customers: 'customers',
|
||||
balances: 'balances',
|
||||
products: 'products',
|
||||
};
|
||||
|
||||
function toJson(v: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
|
||||
if (v === undefined || v === null) return Prisma.JsonNull;
|
||||
return JSON.parse(JSON.stringify(v)) as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the accounting integration in the background (D27, plan §3.4). Two
|
||||
* loops: a fast one (every couple of seconds) for the outbox and the change
|
||||
* feed, the real-time path; and the 30-second one for the scheduled pulls that
|
||||
* reconcile what the feed cannot see (new customers, edits and deletions,
|
||||
* overflow).
|
||||
*
|
||||
* In-process, no queue dependency. A lease row in the Setting table
|
||||
* (`accounting.lease`) keeps a second API instance from running the same jobs
|
||||
* at the same time; the in-memory flag keeps one instance's ticks from
|
||||
* overlapping. Set ACCOUNTING_WORKER=off to keep an instance out entirely.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountingWorkerService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private readonly owner = randomUUID();
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
private fastRunning = false;
|
||||
private fastTimer: NodeJS.Timeout | null = null;
|
||||
private kickTimer: NodeJS.Timeout | null = null;
|
||||
private changes: { provider: string; cursor: string | null } = { provider: '', cursor: null };
|
||||
private lastChangePollAt = 0;
|
||||
private lastChangeWarnAt = 0;
|
||||
private forced = new Set<PullJob>();
|
||||
private lastForcedCustomersAt = 0;
|
||||
/** Shown on the admin screen. */
|
||||
readonly realtime: {
|
||||
lastPollAt?: string;
|
||||
lastOkAt?: string;
|
||||
lastError?: string | null;
|
||||
lastChangeAt?: string;
|
||||
lastSummary?: Record<string, unknown>;
|
||||
} = {};
|
||||
private recovered = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AccountingConfigService,
|
||||
private readonly sync: AccountingSyncService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') return;
|
||||
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
||||
this.timer.unref?.();
|
||||
this.fastTimer = setInterval(() => void this.fastTick(), FAST_TICK_MS);
|
||||
this.fastTimer.unref?.();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.fastTimer) clearInterval(this.fastTimer);
|
||||
if (this.kickTimer) clearTimeout(this.kickTimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Something was just queued. Hooks run inside the caller's transaction, so
|
||||
* wake the outbox a moment later, once it has committed, instead of waiting
|
||||
* for the next tick.
|
||||
*/
|
||||
kickSoon() {
|
||||
if (this.kickTimer || !this.fastTimer) return;
|
||||
this.kickTimer = setTimeout(() => {
|
||||
this.kickTimer = null;
|
||||
void this.fastTick(true);
|
||||
}, 700);
|
||||
this.kickTimer.unref?.();
|
||||
}
|
||||
|
||||
/** True when this instance holds (or just took) the lease. */
|
||||
private async acquireLease(): Promise<boolean> {
|
||||
const value = JSON.stringify({ owner: this.owner, until: new Date(Date.now() + LEASE_MS).toISOString() });
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "Setting" ("key", "value", "updatedAt") VALUES (${LEASE_KEY}, ${value}::jsonb, now())
|
||||
ON CONFLICT ("key") DO NOTHING`;
|
||||
const n = await this.prisma.$executeRaw`
|
||||
UPDATE "Setting" SET "value" = ${value}::jsonb, "updatedAt" = now()
|
||||
WHERE "key" = ${LEASE_KEY}
|
||||
AND ("value"->>'owner' = ${this.owner} OR ("value"->>'until')::timestamptz < now())`;
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
async tick(only?: JobName): Promise<boolean> {
|
||||
if (this.running) return false;
|
||||
this.running = true;
|
||||
try {
|
||||
if (!(await this.acquireLease())) return false;
|
||||
const a = await this.config.active();
|
||||
if (!a) return false;
|
||||
|
||||
if (!this.recovered.has(a.provider.name)) {
|
||||
await this.recoverInterrupted(a);
|
||||
this.recovered.add(a.provider.name);
|
||||
}
|
||||
|
||||
if (only === 'outbox') await this.processOutbox(a);
|
||||
for (const job of ['balances', 'customers', 'products'] as PullJob[]) {
|
||||
if (only && only !== job) continue;
|
||||
if (!a.config.features[PULL_FEATURE[job]]) continue;
|
||||
const wasForced = this.forced.delete(job);
|
||||
if (!only && !wasForced && !(await this.isDue(job, a))) continue;
|
||||
await this.acquireLease();
|
||||
await this.runPull(job, a);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.error(`Accounting tick failed: ${(err as Error).message}`);
|
||||
return false;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real-time path, every couple of seconds: send queued documents and
|
||||
* edits at once, and poll the software's change feed so a balance or a stock
|
||||
* figure changed there is changed here moments later. Never waits behind a
|
||||
* long scheduled pull (it has its own guard, apart from tick()).
|
||||
*/
|
||||
async fastTick(force = false): Promise<void> {
|
||||
if (this.fastRunning) return;
|
||||
this.fastRunning = true;
|
||||
try {
|
||||
if (!(await this.acquireLease())) return;
|
||||
const a = await this.config.active();
|
||||
if (!a) return;
|
||||
if (!this.recovered.has(a.provider.name)) {
|
||||
await this.recoverInterrupted(a);
|
||||
this.recovered.add(a.provider.name);
|
||||
}
|
||||
await this.processOutbox(a);
|
||||
|
||||
if (!force && Date.now() - this.lastChangePollAt < CHANGES_POLL_MS) return;
|
||||
if (!a.provider.capabilities.changeFeed || !(a.config.features.balances || a.config.features.products)) return;
|
||||
this.lastChangePollAt = Date.now();
|
||||
await this.pollChanges(a);
|
||||
} catch (err) {
|
||||
this.logger.error(`Accounting fast tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.fastRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollChanges(a: ActiveProvider) {
|
||||
if (this.changes.provider !== a.provider.name) this.changes = { provider: a.provider.name, cursor: null };
|
||||
this.realtime.lastPollAt = new Date().toISOString();
|
||||
try {
|
||||
const baseline = this.changes.cursor === null;
|
||||
const r = await this.sync.pollChanges(a, this.changes.cursor);
|
||||
this.changes.cursor = r.cursor;
|
||||
this.realtime.lastOkAt = new Date().toISOString();
|
||||
this.realtime.lastError = null;
|
||||
if (r.balances > 0 || r.stocks > 0) {
|
||||
this.realtime.lastChangeAt = this.realtime.lastOkAt;
|
||||
this.realtime.lastSummary = { balances: r.balances, stocks: r.stocks };
|
||||
}
|
||||
if (baseline) return;
|
||||
// What the feed cannot describe goes to the scheduled jobs right away.
|
||||
if (r.fullBalances) this.forced.add('balances');
|
||||
if (r.fullProducts) this.forced.add('products');
|
||||
if (r.newAccounts && a.config.features.customers && Date.now() - this.lastForcedCustomersAt > 60_000) {
|
||||
this.lastForcedCustomersAt = Date.now();
|
||||
this.forced.add('customers');
|
||||
}
|
||||
if (this.forced.size > 0) void this.tick();
|
||||
} catch (err) {
|
||||
const msg = this.sync.describeError(err);
|
||||
this.realtime.lastError = msg;
|
||||
if (Date.now() - this.lastChangeWarnAt > 60_000) {
|
||||
this.lastChangeWarnAt = Date.now();
|
||||
this.logger.warn(`Accounting change poll failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sync button (plan §12): runs these pulls now and waits for them. Needs
|
||||
* only a connected provider — the feature switches do not apply. Each job
|
||||
* reports on its own, so one failing does not hide the others.
|
||||
*/
|
||||
async runPullsNow(jobs: PullJob[]): Promise<Record<string, { ok: boolean; summary?: unknown; error?: string }> | null> {
|
||||
if (this.running) return null;
|
||||
this.running = true;
|
||||
try {
|
||||
const a = await this.config.active();
|
||||
if (!a) return null;
|
||||
const out: Record<string, { ok: boolean; summary?: unknown; error?: string }> = {};
|
||||
for (const job of jobs) {
|
||||
await this.acquireLease();
|
||||
const startedAt = new Date().toISOString();
|
||||
await this.config.recordJob(job, { lastRunAt: startedAt });
|
||||
try {
|
||||
const summary =
|
||||
job === 'customers'
|
||||
? await this.sync.pullCustomers(a)
|
||||
: job === 'balances'
|
||||
? await this.sync.pullBalances(a)
|
||||
: await this.sync.pullProducts(a);
|
||||
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
|
||||
out[job] = { ok: true, summary };
|
||||
} catch (err) {
|
||||
const error = this.sync.describeError(err);
|
||||
await this.config.recordJob(job, { lastError: error });
|
||||
out[job] = { ok: false, error };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** «سینک الان» from the admin screen: runs in the background, returns at once. */
|
||||
runNow(job: JobName): boolean {
|
||||
if (job === 'outbox') {
|
||||
if (this.fastRunning) return false;
|
||||
void this.fastTick(true);
|
||||
return true;
|
||||
}
|
||||
if (this.running) return false;
|
||||
void this.tick(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
get busy() {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async isDue(job: PullJob, a: ActiveProvider) {
|
||||
const minutes = {
|
||||
customers: a.config.intervals.customersMinutes,
|
||||
balances: a.config.intervals.balancesMinutes,
|
||||
products: a.config.intervals.productsMinutes,
|
||||
}[job];
|
||||
const state = await this.config.getState();
|
||||
const last = state.jobs[job]?.lastRunAt;
|
||||
return !last || Date.now() - new Date(last).getTime() >= minutes * 60_000;
|
||||
}
|
||||
|
||||
private async runPull(job: PullJob, a: ActiveProvider) {
|
||||
const startedAt = new Date().toISOString();
|
||||
await this.config.recordJob(job, { lastRunAt: startedAt });
|
||||
try {
|
||||
const summary =
|
||||
job === 'customers'
|
||||
? await this.sync.pullCustomers(a)
|
||||
: job === 'balances'
|
||||
? await this.sync.pullBalances(a)
|
||||
: await this.sync.pullProducts(a);
|
||||
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
|
||||
} catch (err) {
|
||||
const msg = this.sync.describeError(err);
|
||||
this.logger.warn(`Accounting ${job} pull failed: ${msg}`);
|
||||
await this.config.recordJob(job, { lastError: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A job left in SENDING means the process stopped mid-call: its outcome is
|
||||
* unknown. Safe kinds go back to the queue; a Zarrin document goes to a
|
||||
* person, since sending it again could post it twice.
|
||||
*/
|
||||
private async recoverInterrupted(a: ActiveProvider) {
|
||||
const stuck = await this.prisma.accountingOutbox.findMany({ where: { provider: a.provider.name, status: 'SENDING' } });
|
||||
for (const job of stuck) {
|
||||
const safe = this.sync.isRetrySafe(a.provider.name, job.kind);
|
||||
await this.prisma.accountingOutbox.update({
|
||||
where: { id: job.id },
|
||||
data: safe
|
||||
? { status: 'PENDING', nextAttemptAt: new Date() }
|
||||
: {
|
||||
status: 'NEEDS_REVIEW',
|
||||
lastError: 'سرور هنگام ارسال متوقف شد؛ معلوم نیست سند ثبت شده یا نه. پیش از «تلاش دوباره» در حسابداری بررسی کنید.',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async processOutbox(a: ActiveProvider) {
|
||||
const jobs = await this.prisma.accountingOutbox.findMany({
|
||||
where: { provider: a.provider.name, status: 'PENDING', nextAttemptAt: { lte: new Date() } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: OUTBOX_BATCH,
|
||||
});
|
||||
const startedAt = new Date().toISOString();
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const job of jobs) {
|
||||
await this.acquireLease();
|
||||
const ok = await this.runOne(a, job);
|
||||
if (ok) sent += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
if (jobs.length > 0) {
|
||||
await this.config.recordJob('outbox', { lastRunAt: startedAt, lastOkAt: new Date().toISOString(), summary: { picked: jobs.length, sent, failed } });
|
||||
}
|
||||
}
|
||||
|
||||
private async runOne(a: ActiveProvider, job: AccountingOutbox): Promise<boolean> {
|
||||
const attempts = job.attempts + 1;
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { status: 'SENDING', attempts } });
|
||||
const fresh = { ...job, attempts };
|
||||
try {
|
||||
const res = await this.sync.runJob(a, fresh);
|
||||
await this.prisma.accountingOutbox.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'SENT',
|
||||
sentAt: new Date(),
|
||||
externalRef: res.externalRef ?? null,
|
||||
lastRequest: toJson(res.request),
|
||||
lastResponse: toJson(res.response),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = this.sync.describeError(err);
|
||||
const response = err instanceof AccountingProviderError ? err.response : undefined;
|
||||
let data: Prisma.AccountingOutboxUpdateInput;
|
||||
if (err instanceof NeedsReviewError || err instanceof AccountingNotSupportedError) {
|
||||
data = { status: 'NEEDS_REVIEW', lastError: msg };
|
||||
} else if (err instanceof AccountingProviderError && err.ambiguous && !this.sync.isRetrySafe(a.provider.name, job.kind)) {
|
||||
data = {
|
||||
status: 'NEEDS_REVIEW',
|
||||
lastError: `${msg} — ممکن است سند ثبت شده باشد؛ پیش از «تلاش دوباره» در حسابداری بررسی کنید.`,
|
||||
};
|
||||
} else if (attempts >= MAX_ATTEMPTS) {
|
||||
data = { status: 'FAILED', lastError: msg };
|
||||
} else {
|
||||
const delayMin = Math.min(2 ** (attempts - 1), 60);
|
||||
data = { status: 'PENDING', lastError: msg, nextAttemptAt: new Date(Date.now() + delayMin * 60_000) };
|
||||
}
|
||||
if (response !== undefined) data.lastResponse = toJson(response);
|
||||
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data });
|
||||
this.logger.warn(`Accounting job ${job.kind} ${job.entityId} → ${String(data.status)}: ${msg}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Jalali (Shamsi) ⇄ Gregorian conversion.
|
||||
*
|
||||
* Tahesab wants a document date as separate Shamsi year/month/day integers and
|
||||
* its list filters as `1405-04-24`; the customer app sends report ranges as
|
||||
* `1405/04/24`; Zarrin wants Gregorian `2024-10-12` on most calls. One small
|
||||
* converter covers all three without a dependency.
|
||||
*
|
||||
* The arithmetic is the well-known jalaali-js algorithm (Borkowski's leap-year
|
||||
* break table), valid for Jalali years -61 … 3177.
|
||||
*/
|
||||
|
||||
export interface JalaliDate {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
}
|
||||
|
||||
const BREAKS = [
|
||||
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456,
|
||||
3178,
|
||||
];
|
||||
|
||||
function div(a: number, b: number) {
|
||||
return ~~(a / b);
|
||||
}
|
||||
|
||||
function mod(a: number, b: number) {
|
||||
return a - ~~(a / b) * b;
|
||||
}
|
||||
|
||||
function jalCal(jy: number) {
|
||||
const gy = jy + 621;
|
||||
let leapJ = -14;
|
||||
let jp = BREAKS[0];
|
||||
let jump = 0;
|
||||
if (jy < jp || jy >= BREAKS[BREAKS.length - 1]) throw new RangeError(`Invalid Jalali year ${jy}`);
|
||||
|
||||
for (let i = 1; i < BREAKS.length; i += 1) {
|
||||
const jm = BREAKS[i];
|
||||
jump = jm - jp;
|
||||
if (jy < jm) break;
|
||||
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
|
||||
jp = jm;
|
||||
}
|
||||
let n = jy - jp;
|
||||
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
|
||||
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
|
||||
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
|
||||
const march = 20 + leapJ - leapG;
|
||||
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
|
||||
let leap = mod(mod(n + 1, 33) - 1, 4);
|
||||
if (leap === -1) leap = 4;
|
||||
return { leap, gy, march };
|
||||
}
|
||||
|
||||
function g2d(gy: number, gm: number, gd: number) {
|
||||
let d =
|
||||
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * mod(gm + 9, 12) + 2, 5) + gd - 34840408;
|
||||
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
|
||||
return d;
|
||||
}
|
||||
|
||||
function d2g(jdn: number) {
|
||||
let j = 4 * jdn + 139361631;
|
||||
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
|
||||
const i = div(mod(j, 1461), 4) * 5 + 308;
|
||||
const gd = div(mod(i, 153), 5) + 1;
|
||||
const gm = mod(div(i, 153), 12) + 1;
|
||||
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
|
||||
return { gy, gm, gd };
|
||||
}
|
||||
|
||||
function j2d(jy: number, jm: number, jd: number) {
|
||||
const r = jalCal(jy);
|
||||
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
|
||||
}
|
||||
|
||||
function d2j(jdn: number): JalaliDate {
|
||||
const gy = d2g(jdn).gy;
|
||||
let jy = gy - 621;
|
||||
const r = jalCal(jy);
|
||||
const jdn1f = g2d(gy, 3, r.march);
|
||||
let k = jdn - jdn1f;
|
||||
if (k >= 0) {
|
||||
if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 };
|
||||
k -= 186;
|
||||
} else {
|
||||
jy -= 1;
|
||||
k += 179;
|
||||
if (r.leap === 1) k += 1;
|
||||
}
|
||||
return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 };
|
||||
}
|
||||
|
||||
/** Iran has had no DST since 2022, so a fixed +03:30 gives the local calendar day. */
|
||||
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
|
||||
|
||||
/** The Jalali calendar day of an instant, as seen in Tehran. */
|
||||
export function toJalali(date: Date): JalaliDate {
|
||||
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
|
||||
return d2j(g2d(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate()));
|
||||
}
|
||||
|
||||
/** Gregorian calendar day for a Jalali day: `{ gy, gm, gd }`. */
|
||||
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
|
||||
return d2g(j2d(jy, jm, jd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `1405/04/24`, `1405-4-24` or Persian digits. Returns null for anything
|
||||
* that is not a real Jalali date.
|
||||
*/
|
||||
export function parseJalali(input: string | null | undefined): JalaliDate | null {
|
||||
if (!input) return null;
|
||||
const ascii = String(input)
|
||||
.trim()
|
||||
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||
const m = ascii.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/);
|
||||
if (!m) return null;
|
||||
const jy = Number(m[1]);
|
||||
const jm = Number(m[2]);
|
||||
const jd = Number(m[3]);
|
||||
if (jm < 1 || jm > 12 || jd < 1 || jd > 31 || (jm > 6 && jd > 30)) return null;
|
||||
try {
|
||||
// Round-trip rejects 30 Esfand in a common year.
|
||||
const back = d2j(j2d(jy, jm, jd));
|
||||
return back.jy === jy && back.jm === jm && back.jd === jd ? { jy, jm, jd } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** `1405-04-24` (or with another separator). */
|
||||
export function formatJalali(d: JalaliDate, sep = '-') {
|
||||
return `${d.jy}${sep}${pad(d.jm)}${sep}${pad(d.jd)}`;
|
||||
}
|
||||
|
||||
/** `2026-07-15` for a Jalali day. */
|
||||
export function jalaliToIsoDate(d: JalaliDate) {
|
||||
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
|
||||
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`;
|
||||
}
|
||||
|
||||
/** `2026-07-15` for an instant, Tehran calendar day. */
|
||||
export function toIsoDateTehran(date: Date) {
|
||||
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
|
||||
return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { JalaliDate } from '../jalali';
|
||||
|
||||
/**
|
||||
* One accounting software (D27). AccountingSyncService picks a driver by the
|
||||
* admin's switch and owns everything around the call — links, outbox, retries,
|
||||
* logging — so a driver only translates between the app's shapes and its
|
||||
* software's API.
|
||||
*
|
||||
* Every figure a driver returns is ALREADY in the app's convention:
|
||||
* * gold in grams of 750, positive balance = the customer owes;
|
||||
* * rial in rial, positive balance = the customer owes.
|
||||
* Each software's own sign and unit quirks stay inside its driver.
|
||||
*/
|
||||
|
||||
export type ProviderName = 'tahesab' | 'zarrin';
|
||||
|
||||
/** Decrypted connection settings, handed to every call. */
|
||||
export interface ProviderSettings {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
caCert: string | null;
|
||||
timeoutMs: number;
|
||||
/** Tahesab only: the `DBname` header (`TahesabDB` for SQL Server, `DB` for Access). */
|
||||
dbName?: string;
|
||||
/** Zarrin only, until verified on a real installation (plan §9). */
|
||||
balanceOwesWhen?: 'negative' | 'positive';
|
||||
}
|
||||
|
||||
export interface ExternalGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExternalCustomer {
|
||||
code: string;
|
||||
name: string;
|
||||
/** Normalised to 09xxxxxxxxx, or null when the account has no usable mobile. */
|
||||
mobile: string | null;
|
||||
phone: string | null;
|
||||
nationalCode: string | null;
|
||||
address: string | null;
|
||||
groupId: string | null;
|
||||
groupName: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerPayload {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
nationalCode: string | null;
|
||||
group: { id: string | null; name: string | null };
|
||||
}
|
||||
|
||||
export interface ExternalBalance {
|
||||
code: string;
|
||||
goldGrams: Prisma.Decimal;
|
||||
rial: Prisma.Decimal;
|
||||
}
|
||||
|
||||
export interface StatementQuery {
|
||||
code: string;
|
||||
from: JalaliDate;
|
||||
to: JalaliDate;
|
||||
}
|
||||
|
||||
/** One line of a customer's statement, normalised across providers. */
|
||||
export interface StatementRow {
|
||||
/** Stable within the provider (Tahesab ID + Radif, Zarrin idRadif). */
|
||||
key: string;
|
||||
/** The document the line belongs to — what a detail lookup is keyed on. */
|
||||
documentRef: string;
|
||||
/** The account the line belongs to, for the SEC-16 ownership check. */
|
||||
accountCode: string;
|
||||
documentNo: string;
|
||||
/** Jalali `1405/07/12`. */
|
||||
date: string;
|
||||
time: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
itemName: string | null;
|
||||
weight: Prisma.Decimal | null;
|
||||
karat: number | null;
|
||||
count: Prisma.Decimal | null;
|
||||
unitPrice: Prisma.Decimal | null;
|
||||
wagePercent: Prisma.Decimal | null;
|
||||
/** 750-equivalent gold received from the customer (reduces what they owe). */
|
||||
goldIn: Prisma.Decimal;
|
||||
goldOut: Prisma.Decimal;
|
||||
rialIn: Prisma.Decimal;
|
||||
rialOut: Prisma.Decimal;
|
||||
/** Running balance after this line, when the software reports one. */
|
||||
goldBalance: Prisma.Decimal | null;
|
||||
rialBalance: Prisma.Decimal | null;
|
||||
metal: string | null;
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalKarSakhte {
|
||||
/** Zarrin idKar; for Tahesab, which has no item ids, the item name itself. */
|
||||
code: string;
|
||||
name: string;
|
||||
groupId: string | null;
|
||||
groupName: string | null;
|
||||
typeId: string | null;
|
||||
typeName: string | null;
|
||||
saleWagePercent: Prisma.Decimal | null;
|
||||
stockWeight: Prisma.Decimal | null;
|
||||
stockCount: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface KarSakhteStock {
|
||||
weight: Prisma.Decimal | null;
|
||||
count: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface MovementRow {
|
||||
itemCode: string | null;
|
||||
itemName: string;
|
||||
date: string;
|
||||
description: string | null;
|
||||
weight: Prisma.Decimal | null;
|
||||
amount: Prisma.Decimal | null;
|
||||
count: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface DocumentLine {
|
||||
/** The linked کارساخته: Zarrin idKar / Tahesab Name_Kar. */
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
/** Physical (raw) weight of the line, all pieces. */
|
||||
weightGrams: Prisma.Decimal;
|
||||
count: number;
|
||||
/** Effective making charge: variant wage minus discount, in percent. */
|
||||
wagePercent: Prisma.Decimal;
|
||||
/** Flat rial charge of the line (stones), all pieces. */
|
||||
flatRial: Prisma.Decimal;
|
||||
karat: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface OrderDocumentInput {
|
||||
/** Deterministic per order; Tahesab dedupes on it (≥ 20 chars, lowercase). */
|
||||
factorCode: string;
|
||||
customerCode: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
temporary: boolean;
|
||||
lines: DocumentLine[];
|
||||
}
|
||||
|
||||
/** Resumable state of a multi-call document (Zarrin posts one call per line). */
|
||||
export interface DocumentProgress {
|
||||
factorId?: string;
|
||||
doneLines?: number;
|
||||
}
|
||||
|
||||
export interface DocumentPostResult {
|
||||
ref: string;
|
||||
progress: DocumentProgress;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
/** What changed in the accounting software since the last poll (real-time sync). */
|
||||
export interface ChangeBatch {
|
||||
/** Opaque; hand it back on the next poll. */
|
||||
cursor: string;
|
||||
/** Accounts that got a new or edited document. */
|
||||
accountCodes: string[];
|
||||
/** کارساخته names that got a new or edited document. */
|
||||
itemNames: string[];
|
||||
/** More changed than one poll can see: the caller must do a full refresh. */
|
||||
overflow: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
/** Can a کارساخته be created through the API? (Zarrin yes, Tahesab no.) */
|
||||
createKarSakhte: boolean;
|
||||
/** Can a posted document be looked up by our own key before a retry? */
|
||||
documentInquiry: boolean;
|
||||
/** Does the movement report cover every account, or only recent documents? */
|
||||
movementScope: 'all' | 'recent-documents';
|
||||
/** Can recent changes be polled cheaply (see getChanges)? */
|
||||
changeFeed: boolean;
|
||||
}
|
||||
|
||||
export interface AccountingProvider {
|
||||
readonly name: ProviderName;
|
||||
readonly label: string;
|
||||
readonly capabilities: ProviderCapabilities;
|
||||
|
||||
health(s: ProviderSettings): Promise<Record<string, unknown>>;
|
||||
|
||||
listCustomerGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
/** One page of accounts; `next` is null after the last page. */
|
||||
listCustomers(s: ProviderSettings, cursor: string | null): Promise<{ items: ExternalCustomer[]; next: string | null }>;
|
||||
findCustomersByMobile(s: ProviderSettings, mobile: string): Promise<ExternalCustomer[]>;
|
||||
createCustomer(s: ProviderSettings, p: CustomerPayload): Promise<{ code: string; response: unknown }>;
|
||||
updateCustomer(s: ProviderSettings, code: string, p: CustomerPayload): Promise<{ response: unknown }>;
|
||||
|
||||
getBalances(s: ProviderSettings, codes: string[]): Promise<ExternalBalance[]>;
|
||||
/**
|
||||
* Documents added or edited since `cursor` (null = first poll: only returns
|
||||
* the starting cursor). Cheap enough to call every few seconds.
|
||||
*/
|
||||
getChanges(s: ProviderSettings, cursor: string | null): Promise<ChangeBatch>;
|
||||
getStatement(s: ProviderSettings, q: StatementQuery): Promise<StatementRow[]>;
|
||||
|
||||
listKarSakhteGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
listKarSakhteTypes(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
listKarSakhte(s: ProviderSettings): Promise<ExternalKarSakhte[]>;
|
||||
getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise<KarSakhteStock>;
|
||||
getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise<MovementRow[]>;
|
||||
createKarSakhte(
|
||||
s: ProviderSettings,
|
||||
item: { name: string; groupId: string | null; typeId: string | null },
|
||||
): Promise<{ code: string; response: unknown }>;
|
||||
|
||||
postOrderDocument(
|
||||
s: ProviderSettings,
|
||||
doc: OrderDocumentInput,
|
||||
progress: DocumentProgress,
|
||||
onProgress: (p: DocumentProgress) => Promise<void>,
|
||||
): Promise<DocumentPostResult>;
|
||||
/** True when a document with this key exists. */
|
||||
documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise<boolean>;
|
||||
deleteDocument(
|
||||
s: ProviderSettings,
|
||||
doc: { factorCode: string; ref: string | null; customerCode: string },
|
||||
): Promise<{ response: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A call the software answered with an error, or could not be made.
|
||||
* `ambiguous` = the software may have acted on it anyway (timeout after send).
|
||||
*/
|
||||
export class AccountingProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly ambiguous = false,
|
||||
readonly response?: unknown,
|
||||
readonly notFound = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountingProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Not offered by this software's API. */
|
||||
export class AccountingNotSupportedError extends AccountingProviderError {
|
||||
constructor(what: string) {
|
||||
super(`${what} در API این نرمافزار وجود ندارد`);
|
||||
this.name = 'AccountingNotSupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
export function asciiDigits(v: string) {
|
||||
return v
|
||||
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)));
|
||||
}
|
||||
|
||||
/**
|
||||
* `9121234567`, `09121234567`, `+989121234567`, `00989121234567`, Persian
|
||||
* digits → `09121234567`. Anything that is not an Iranian mobile → null.
|
||||
*/
|
||||
export function normalizeMobile(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
let d = asciiDigits(String(v)).replace(/\D/g, '');
|
||||
if (d.startsWith('0098')) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length === 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** A Prisma.Decimal from a value the software sent, or null for "null"/blank/NaN. */
|
||||
export function dec(v: unknown): Prisma.Decimal | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = asciiDigits(String(v)).replace(/,/g, '').trim();
|
||||
if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'nan') return null;
|
||||
try {
|
||||
const d = new Prisma.Decimal(s);
|
||||
return d.isFinite() ? d : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const ZERO = new Prisma.Decimal(0);
|
||||
|
||||
export function str(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = String(v).trim();
|
||||
return s === '' || s.toLowerCase() === 'null' ? null : s;
|
||||
}
|
||||
|
||||
/** Split a full name into first/last the way the app's DB trigger does. */
|
||||
export function splitName(full: string): { firstName: string; lastName: string } {
|
||||
const parts = full.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' };
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AccountingProvider, ProviderName } from './accounting-provider';
|
||||
import { tahesabProvider } from './tahesab.provider';
|
||||
import { zarrinProvider } from './zarrin.provider';
|
||||
|
||||
/**
|
||||
* Every accounting software, keyed by the admin switch's value. A new one is a
|
||||
* driver file plus one line here — the same shape as the SMS drivers (D26).
|
||||
*/
|
||||
export const ACCOUNTING_PROVIDERS: Readonly<Record<ProviderName, AccountingProvider>> = {
|
||||
tahesab: tahesabProvider,
|
||||
zarrin: zarrinProvider,
|
||||
};
|
||||
|
||||
export function isProviderName(v: unknown): v is ProviderName {
|
||||
return typeof v === 'string' && Object.prototype.hasOwnProperty.call(ACCOUNTING_PROVIDERS, v);
|
||||
}
|
||||
|
||||
export * from './accounting-provider';
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { httpRequest } from '../accounting-http';
|
||||
import { AccountingProviderError, OrderDocumentInput, ProviderSettings } from './accounting-provider';
|
||||
import { karatWithWage, tahesabProvider as t } from './tahesab.provider';
|
||||
|
||||
jest.mock('../accounting-http', () => ({
|
||||
...jest.requireActual('../accounting-http'),
|
||||
httpRequest: jest.fn(),
|
||||
}));
|
||||
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
|
||||
|
||||
const s: ProviderSettings = { baseUrl: 'https://tahesab.local:8081/', token: 'TOK', caCert: null, timeoutMs: 5000, dbName: 'TahesabDB' };
|
||||
|
||||
const reply = (status: number, body: unknown) =>
|
||||
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
||||
|
||||
/** The body of the n-th request, parsed. */
|
||||
const sent = (n = 0) => JSON.parse(http.mock.calls[n][0].body as string);
|
||||
|
||||
beforeEach(() => http.mockReset());
|
||||
|
||||
describe('wire format', () => {
|
||||
it('posts {"Method": [args]} to the root with the bearer token and DBname', async () => {
|
||||
http.mockReturnValueOnce(reply(200, [{ Name: 'بنکدار', GID: 2 }]));
|
||||
const groups = await t.listCustomerGroups(s);
|
||||
const req = http.mock.calls[0][0];
|
||||
expect(req.method).toBe('POST');
|
||||
expect(req.url).toBe('https://tahesab.local:8081/');
|
||||
expect(req.headers).toMatchObject({ Authorization: 'Bearer TOK', DBname: 'TahesabDB' });
|
||||
expect(sent()).toEqual({ DoListGorooh: [] });
|
||||
// GID 0 (accounts in no group) is offered too.
|
||||
expect(groups).toEqual([
|
||||
{ id: '2', name: 'بنکدار' },
|
||||
{ id: '0', name: 'بدون گروه (GID 0)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('turns {"ERROR": …} into a provider error, flagging "not found"', async () => {
|
||||
http.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.', Command: 'POST', SSL: '1' }));
|
||||
await expect(t.updateCustomer(s, '5', { firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '09120000000', phone: null, address: null, nationalCode: null, group: { id: null, name: null } })).rejects.toMatchObject({
|
||||
notFound: true,
|
||||
response: { ERROR: 'هیچ رکوردی یافت نشد.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a timeout after the request went out as ambiguous', async () => {
|
||||
const { AccountingHttpError } = jest.requireActual('../accounting-http');
|
||||
http.mockRejectedValueOnce(new AccountingHttpError('timeout', true, 'TIMEOUT'));
|
||||
await expect(t.listCustomerGroups(s)).rejects.toMatchObject({ ambiguous: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('customers', () => {
|
||||
it('pages through code windows and stops after empty ones', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply(200, {
|
||||
'1': { Code: 1, Name: 'masoud', GID: '2', GoroupName: 'بنکدار', Mobile: '9121234567', CodeMelli: '', Address: 'آدرس' },
|
||||
}),
|
||||
);
|
||||
const page1 = await t.listCustomers(s, null);
|
||||
expect(sent()).toEqual({ Dolistmoshtari: [1, 500] });
|
||||
expect(page1.items[0]).toMatchObject({ code: '1', mobile: '09121234567', groupId: '2', nationalCode: null });
|
||||
expect(page1.next).toBe('501:0');
|
||||
|
||||
http.mockReturnValue(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
|
||||
let cursor: string | null = page1.next;
|
||||
let calls = 0;
|
||||
while (cursor) {
|
||||
cursor = (await t.listCustomers(s, cursor)).next;
|
||||
calls += 1;
|
||||
}
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
|
||||
it('looks a mobile up in both stored forms and keeps exact matches only', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(reply(200, { '1': { Code: 1, Name: 'a', Mobile: '9121234567' }, '790': { Code: 790, Name: 'b', Mobile: '9121234567' } }))
|
||||
.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
|
||||
const found = await t.findCustomersByMobile(s, '09121234567');
|
||||
expect(sent(0)).toEqual({ DoListMoshtari: [9121234567] });
|
||||
expect(sent(1)).toEqual({ DoListMoshtari: ['09121234567'] });
|
||||
expect(found.map((c) => c.code)).toEqual(['1', '790']);
|
||||
});
|
||||
|
||||
it('creates with an auto code and returns it', async () => {
|
||||
http.mockReturnValueOnce(reply(200, { OK: 35 }));
|
||||
const res = await t.createCustomer(s, {
|
||||
firstName: 'علی',
|
||||
lastName: 'محمدی',
|
||||
fullName: 'علی محمدی',
|
||||
mobile: '09121112233',
|
||||
phone: null,
|
||||
address: null,
|
||||
nationalCode: null,
|
||||
group: { id: '13', name: 'مشتري' },
|
||||
});
|
||||
expect(res.code).toBe('35');
|
||||
const args = sent().DoNewMoshtari;
|
||||
expect(args[0]).toBe('علی محمدی');
|
||||
expect(args[1]).toBe('مشتري');
|
||||
expect(args[2]).toBe('09121112233');
|
||||
expect(args[8]).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balances and statement', () => {
|
||||
it('flips the sign: in Tahesab negative means the customer owes', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply(200, { MandeHesab: [{ Code: '1', MandeyeMali: '-238999230', MandeyeVazni: '-177.431' }, { Code: '2', MandeyeMali: '5', MandeyeVazni: '1.5' }] }),
|
||||
);
|
||||
const b = await t.getBalances(s, ['1', '2', '999']);
|
||||
expect(sent()).toEqual({ getmandehesabbycode: ['1', '2', '999'] });
|
||||
expect(b).toEqual([
|
||||
{ code: '1', goldGrams: new Prisma.Decimal('177.431'), rial: new Prisma.Decimal('238999230') },
|
||||
{ code: '2', goldGrams: new Prisma.Decimal('-1.5'), rial: new Prisma.Decimal('-5') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps DoListAsnad rows (real test-server rows)', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply(200, {
|
||||
'11558': {
|
||||
ID: 11558, Radif: 1, Factor_Code: '123456789123421578191111985', MCode: 1, Tarikh: '1405/07/11', Tarikh_Time: '19:47:44',
|
||||
NO: 'ورود کارساخته', Sh_Factor: 228, Vazn: 1.25, No_Kar: 'testmm', Ayar: 750, Fi: 10000, Mazaneh: 'null', Count: 1,
|
||||
TabdilVazn: 1.25, Mali: 100000, Sharh1: 'sharhtest', TahesabVazni: 11526.174, TahesabMali: -209358396276,
|
||||
Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: false, DarsadSood: 5.5,
|
||||
},
|
||||
'12261': {
|
||||
ID: 12261, Radif: 1, Factor_Code: 'GBORDX', MCode: 1, Tarikh: '1405/07/12', NO: 'خروج کارساخته', Vazn: 2,
|
||||
TabdilVazn: -2, Mali: 'null', TahesabVazni: 0, TahesabMali: 0, Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rows = await t.getStatement(s, { code: '1', from: { jy: 1405, jm: 6, jd: 1 }, to: { jy: 1405, jm: 7, jd: 13 } });
|
||||
expect(sent()).toEqual({ DoListAsnad: [-1, 1, '1405-06-01', '1405-07-13', '', 0, 'IRR'] });
|
||||
expect(rows[0]).toMatchObject({
|
||||
documentRef: '123456789123421578191111985',
|
||||
accountCode: '1',
|
||||
title: 'ورود کارساخته',
|
||||
itemName: 'testmm',
|
||||
metal: 'طلا',
|
||||
temporary: false,
|
||||
});
|
||||
expect(rows[0].goldIn.toString()).toBe('1.25');
|
||||
expect(rows[0].goldOut.toString()).toBe('0');
|
||||
expect(rows[0].rialIn.toString()).toBe('100000');
|
||||
expect(rows[0].goldBalance?.toString()).toBe('-11526.174');
|
||||
expect(rows[0].rialBalance?.toString()).toBe('209358396276');
|
||||
// A temporary row moves nothing yet: no balance of its own.
|
||||
expect(rows[1]).toMatchObject({ documentRef: 'gbordx', temporary: true, goldBalance: null });
|
||||
expect(rows[1].goldOut.toString()).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('order documents', () => {
|
||||
const doc: OrderDocumentInput = {
|
||||
factorCode: 'gbordcm1abcdefghijklmnopqrstuv',
|
||||
customerCode: '2',
|
||||
date: new Date('2026-10-05T08:00:00Z'),
|
||||
description: 'سفارش 1001 از اپ',
|
||||
temporary: true,
|
||||
lines: [
|
||||
{ itemCode: 'انگشتر', itemName: 'انگشتر', weightGrams: new Prisma.Decimal('4.200'), count: 2, wagePercent: new Prisma.Decimal(10), flatRial: new Prisma.Decimal(0), karat: 750, description: 'line 1' },
|
||||
{ itemCode: 'النگو', itemName: 'النگو', weightGrams: new Prisma.Decimal('10'), count: 1, wagePercent: new Prisma.Decimal('18.5'), flatRial: new Prisma.Decimal(500000), karat: 750, description: 'line 2' },
|
||||
],
|
||||
};
|
||||
|
||||
it('raises the karat by the making charge — the only field that moves the gold balance', () => {
|
||||
expect(karatWithWage(750, new Prisma.Decimal(10)).toString()).toBe('825');
|
||||
expect(karatWithWage(750, new Prisma.Decimal('18.5')).toString()).toBe('888.75');
|
||||
});
|
||||
|
||||
it('posts one temporary exit row per line under one Factor_Code', async () => {
|
||||
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
|
||||
const progress: unknown[] = [];
|
||||
const res = await t.postOrderDocument(s, doc, {}, async (p) => {
|
||||
progress.push(p);
|
||||
});
|
||||
|
||||
const r1 = sent(0).DoNewSanadVKHKar;
|
||||
const r2 = sent(1).DoNewSanadVKHKar;
|
||||
expect(r1).toHaveLength(27);
|
||||
expect(r1[0]).toBe(0); // temporary
|
||||
expect(r1[1]).toBe(2); // Moshtari_Code
|
||||
expect([r1[3], r2[3]]).toEqual([1, 2]); // Radif_Number
|
||||
expect([r1[4], r1[5], r1[6]]).toEqual([1405, 7, 13]);
|
||||
expect(r1[7]).toBe(4.2); // physical weight
|
||||
expect(r1[8]).toBe(825); // karat with the charge
|
||||
expect(r1[9]).toBe(2);
|
||||
expect(r1[10]).toBe('انگشتر');
|
||||
expect(r1[11]).toBe(1); // exit
|
||||
expect(r1[16]).toBe(0); // Darsad_Talayee never double-counts
|
||||
expect(r2[13]).toBe(500000); // stones in MablaghKol
|
||||
expect(r2[20]).toBe(0); // not PoolSang
|
||||
expect(r1[22]).toBe(doc.factorCode);
|
||||
expect(r1[24]).toBe(1); // MultiRadif
|
||||
expect(progress).toEqual([{ doneLines: 1 }, { doneLines: 2 }]);
|
||||
expect(res.ref).toBe(doc.factorCode);
|
||||
});
|
||||
|
||||
it('resumes after the last confirmed line', async () => {
|
||||
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
|
||||
await t.postOrderDocument(s, doc, { doneLines: 1 }, async () => undefined);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
expect(sent(0).DoNewSanadVKHKar[3]).toBe(2);
|
||||
});
|
||||
|
||||
it('treats a duplicate-row rejection as "already there", which makes a retry safe', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(
|
||||
reply(400, {
|
||||
ERROR:
|
||||
"[EUniError] The statement has been terminated.\r\nCannot insert duplicate key row in object 'dbo.hesab_detail' with unique index 'UX_hesab_detail_UniqRowID'.",
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
|
||||
const res = await t.postOrderDocument(s, doc, {}, async () => undefined);
|
||||
expect(res.progress).toEqual({ doneLines: 2 });
|
||||
});
|
||||
|
||||
it('does not swallow other errors', async () => {
|
||||
http.mockReturnValueOnce(reply(400, { ERROR: 'کد حساب نامعتبر است' }));
|
||||
await expect(t.postOrderDocument(s, doc, {}, async () => undefined)).rejects.toBeInstanceOf(AccountingProviderError);
|
||||
});
|
||||
|
||||
it('inquires and deletes by Factor_Code', async () => {
|
||||
http.mockReturnValueOnce(reply(200, { Found: doc.factorCode })).mockReturnValueOnce(reply(404, { ERROR: 'Not_Found' }));
|
||||
expect(await t.documentExists(s, { factorCode: doc.factorCode, ref: null })).toBe(true);
|
||||
expect(await t.documentExists(s, { factorCode: 'x', ref: null })).toBe(false);
|
||||
|
||||
http.mockReturnValueOnce(reply(404, { ERROR: 'سند یافت نشد.' }));
|
||||
expect((await t.deleteDocument(s, { factorCode: 'x', ref: null, customerCode: '2' })).response).toEqual({ alreadyDeleted: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('items', () => {
|
||||
it('skips item names that lost their encoding and reads stock per name', async () => {
|
||||
http.mockReturnValueOnce(reply(200, [{ Name: 'انگشتر', Ayar: 750 }, { Name: '???????0.400????' }]));
|
||||
const items = await t.listKarSakhte(s);
|
||||
expect(items.map((i) => i.name)).toEqual(['انگشتر']);
|
||||
|
||||
http.mockReturnValueOnce(reply(200, { Name: 'انگشتر', VaznMojood: 12.5, TedadMojood: 3, MaxAyar: 750 }));
|
||||
const stock = await t.getKarSakhteStock(s, { code: 'انگشتر', name: 'انگشتر' });
|
||||
expect(sent(1)).toEqual({ GetMojoodiKarSakhte: ['انگشتر', 0] });
|
||||
expect(stock.weight?.toString()).toBe('12.5');
|
||||
expect(stock.count?.toString()).toBe('3');
|
||||
});
|
||||
|
||||
it('cannot create items through the API', async () => {
|
||||
await expect(t.createKarSakhte(s, { name: 'x', groupId: null, typeId: null })).rejects.toThrow('وجود ندارد');
|
||||
});
|
||||
});
|
||||
|
||||
describe('change feed (real-time sync)', () => {
|
||||
const row = (ID: number, MCode: number, extra: Record<string, unknown> = {}) => ({
|
||||
ID,
|
||||
MCode,
|
||||
NO: 'فروش طلا',
|
||||
No_Kar: 'null',
|
||||
ZamanEdit: null,
|
||||
...extra,
|
||||
});
|
||||
const feed = (rows: ReturnType<typeof row>[]) => reply(200, Object.fromEntries(rows.map((r) => [String(r.ID), r])));
|
||||
|
||||
it('the first poll only returns the starting cursor', async () => {
|
||||
http.mockReturnValueOnce(feed([row(10, 1), row(11, 2)]));
|
||||
expect(await t.getChanges(s, null)).toEqual({ cursor: '11|', accountCodes: [], itemNames: [], overflow: false });
|
||||
expect(sent()).toEqual({ DoListUpdatedAsnad: [200] });
|
||||
});
|
||||
|
||||
it('later polls report the accounts and کارساخته of new documents only', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
feed([row(11, 2), row(12, 7), row(13, 7), row(14, 9, { NO: 'خروج کارساخته', No_Kar: 'انگشتر' })]),
|
||||
);
|
||||
const r = await t.getChanges(s, '11|');
|
||||
expect(r.accountCodes.sort()).toEqual(['7', '9']);
|
||||
expect(r.itemNames).toEqual(['انگشتر']);
|
||||
expect(r.cursor).toBe('14|');
|
||||
expect(r.overflow).toBe(false);
|
||||
});
|
||||
|
||||
it('sees an edit of an old document through ZamanEdit', async () => {
|
||||
http.mockReturnValueOnce(feed([row(11, 2, { ZamanEdit: '1405/07/12 18:00:00' }), row(12, 3)]));
|
||||
const r = await t.getChanges(s, '12|1405/07/12 17:00:00');
|
||||
expect(r.accountCodes).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('says overflow when every row of the window is new', async () => {
|
||||
const rows = Array.from({ length: 200 }, (_, i) => row(100 + i, i));
|
||||
http.mockReturnValueOnce(feed(rows));
|
||||
expect((await t.getChanges(s, '50|')).overflow).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing when nothing changed', async () => {
|
||||
http.mockReturnValueOnce(feed([row(11, 2), row(12, 3)]));
|
||||
expect(await t.getChanges(s, '12|')).toMatchObject({ accountCodes: [], itemNames: [], overflow: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||||
import { formatJalali, toJalali } from '../jalali';
|
||||
import {
|
||||
AccountingNotSupportedError,
|
||||
AccountingProvider,
|
||||
AccountingProviderError,
|
||||
ExternalCustomer,
|
||||
ProviderSettings,
|
||||
StatementRow,
|
||||
ZERO,
|
||||
dec,
|
||||
normalizeMobile,
|
||||
str,
|
||||
} from './accounting-provider';
|
||||
|
||||
/**
|
||||
* Tahesab (تهحساب) — https://tahesab.ir/APiDoc/ (OpenAPI 3.1, API 10.1405.x).
|
||||
*
|
||||
* Wire format, confirmed against the public test server on 2026-10-05:
|
||||
* POST <base>/ body {"<Method>": [args by position]}
|
||||
* headers Authorization: Bearer <token>, DBname: TahesabDB (SQL Server) | DB (Access)
|
||||
* The server dispatches on the body key; the path in the docs is cosmetic.
|
||||
* Errors come back as {"ERROR": "..."} with HTTP 400/404.
|
||||
*
|
||||
* Behaviour measured on the test server (plan §9), which this driver relies on:
|
||||
* * Balance sign: an exit (خروج) LOWERS MandeyeVazni — negative = the
|
||||
* customer owes, as henza_server's `* -1` assumed. Flipped here.
|
||||
* * A temporary (موقت) document does not move the balance until the
|
||||
* accountant finalises it.
|
||||
* * The gold a document moves is Vazn × Ayar / 750. Darsad_Talayee and
|
||||
* Ojrat are stored but do NOT move the balance, so the making charge is sent
|
||||
* as a raised Ayar (750 at 10% → 825); Vazn stays the physical weight.
|
||||
* * PoolSang does not move the rial balance; MablaghKol does.
|
||||
* * Factor_Code is stored lowercased; re-sending a row with the same
|
||||
* Factor_Code + Radif_Number is rejected as a duplicate key — which is what
|
||||
* makes a retry safe.
|
||||
*/
|
||||
|
||||
const CUSTOMER_PAGE = 500;
|
||||
/** Consecutive empty code windows before the account list is considered done. */
|
||||
const EMPTY_WINDOWS_TO_STOP = 4;
|
||||
const MAX_CUSTOMER_CODE = 2_000_000;
|
||||
const BALANCE_CHUNK = 100;
|
||||
const RECENT_DOCUMENTS = 1000;
|
||||
const CHANGE_WINDOW = 200;
|
||||
|
||||
function baseUrl(s: ProviderSettings) {
|
||||
return s.baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function headers(s: ProviderSettings) {
|
||||
return {
|
||||
Authorization: `Bearer ${s.token}`,
|
||||
DBname: s.dbName || 'TahesabDB',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFoundMessage(msg: string) {
|
||||
return /یافت نشد|Not_Found|not found/i.test(msg);
|
||||
}
|
||||
|
||||
async function call(s: ProviderSettings, method: string, args: unknown[]): Promise<unknown> {
|
||||
let res;
|
||||
try {
|
||||
res = await httpRequest({
|
||||
method: 'POST',
|
||||
url: `${baseUrl(s)}/`,
|
||||
headers: headers(s),
|
||||
body: JSON.stringify({ [method]: args }),
|
||||
timeoutMs: s.timeoutMs,
|
||||
ca: s.caCert,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) {
|
||||
throw new AccountingProviderError(`تهحساب (${method}): ${err.message}`, err.ambiguous);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = parseLooseJson(res.body);
|
||||
} catch {
|
||||
throw new AccountingProviderError(
|
||||
`تهحساب (${method}): پاسخ نامعتبر (HTTP ${res.status})`,
|
||||
res.status >= 200 && res.status < 300,
|
||||
res.body.slice(0, 500),
|
||||
);
|
||||
}
|
||||
|
||||
const error = json && typeof json === 'object' && !Array.isArray(json) ? (json as { ERROR?: unknown }).ERROR : undefined;
|
||||
if (error !== undefined || res.status < 200 || res.status >= 300) {
|
||||
const msg = typeof error === 'string' ? error : `HTTP ${res.status}`;
|
||||
throw new AccountingProviderError(
|
||||
`تهحساب (${method}): ${msg.split('\r\n')[0]}`,
|
||||
false,
|
||||
trimDiagnostics(json),
|
||||
res.status === 404 || isNotFoundMessage(msg),
|
||||
);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Tahesab error bodies carry ~30 diagnostic fields; keep only the message. */
|
||||
function trimDiagnostics(json: unknown) {
|
||||
if (json && typeof json === 'object' && 'ERROR' in (json as object)) {
|
||||
return { ERROR: (json as { ERROR: unknown }).ERROR };
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** A method that answers "nothing found" with an ERROR, as an empty result. */
|
||||
async function callList(s: ProviderSettings, method: string, args: unknown[]) {
|
||||
try {
|
||||
return await call(s, method, args);
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tahesab keys row objects by id: {"1": {...}, "2": {...}}. */
|
||||
function rowsOf(json: unknown): Record<string, unknown>[] {
|
||||
if (!json) return [];
|
||||
if (Array.isArray(json)) return json as Record<string, unknown>[];
|
||||
if (typeof json === 'object') {
|
||||
return Object.values(json as Record<string, unknown>).filter(
|
||||
(v): v is Record<string, unknown> => !!v && typeof v === 'object',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toCustomer(r: Record<string, unknown>): ExternalCustomer {
|
||||
return {
|
||||
code: String(r.Code),
|
||||
name: str(r.Name) ?? '',
|
||||
mobile: normalizeMobile(r.Mobile),
|
||||
phone: null,
|
||||
nationalCode: str(r.CodeMelli),
|
||||
address: str(r.Address),
|
||||
groupId: str(r.GID),
|
||||
groupName: str(r.GoroupName),
|
||||
};
|
||||
}
|
||||
|
||||
const neg = (d: Prisma.Decimal | null) => (d ? d.negated() : null);
|
||||
|
||||
const METALS: Record<string, string> = { '0': 'طلا', '1': 'پلاتین', '2': 'نقره' };
|
||||
|
||||
/**
|
||||
* Rounds the raised karat to 3 decimals, which is what the desktop app keeps.
|
||||
* 750 × (1 + 10/100) = 825.
|
||||
*/
|
||||
export function karatWithWage(karat: number, wagePercent: Prisma.Decimal) {
|
||||
return new Prisma.Decimal(karat).mul(new Prisma.Decimal(1).add(wagePercent.div(100))).toDecimalPlaces(3);
|
||||
}
|
||||
|
||||
export const tahesabProvider: AccountingProvider = {
|
||||
name: 'tahesab',
|
||||
label: 'تهحساب',
|
||||
capabilities: { createKarSakhte: false, documentInquiry: true, movementScope: 'recent-documents', changeFeed: true },
|
||||
|
||||
async health(s) {
|
||||
const path = baseUrl(s).startsWith('https:') ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL';
|
||||
let info: Record<string, unknown> = {};
|
||||
try {
|
||||
const res = await httpRequest({
|
||||
method: 'GET',
|
||||
url: `${baseUrl(s)}/${path}`,
|
||||
headers: headers(s),
|
||||
timeoutMs: s.timeoutMs,
|
||||
ca: s.caCert,
|
||||
});
|
||||
const json = parseLooseJson(res.body) as Record<string, unknown>;
|
||||
info = {
|
||||
status: json.Status,
|
||||
api: json.Api_Status,
|
||||
db: json.DB_Status,
|
||||
version: json.TahesabVersion,
|
||||
dbType: json.DBType,
|
||||
dbName: json.DBName,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) throw new AccountingProviderError(`تهحساب: ${err.message}`);
|
||||
throw new AccountingProviderError('تهحساب: پاسخ CheckHealth نامعتبر است');
|
||||
}
|
||||
// CheckHealth ignores the token and DBname; a real call proves both.
|
||||
const groups = rowsOf(await call(s, 'DoListGorooh', []));
|
||||
return { ...info, customerGroups: groups.length };
|
||||
},
|
||||
|
||||
async listCustomerGroups(s) {
|
||||
const groups = rowsOf(await call(s, 'DoListGorooh', [])).map((g) => ({
|
||||
id: String(g.GID),
|
||||
name: str(g.Name) ?? String(g.GID),
|
||||
}));
|
||||
// Accounts outside every defined group carry GID 0 (seen on the test
|
||||
// server as «مشتري»); offer it so it can be picked for import.
|
||||
if (!groups.some((g) => g.id === '0')) groups.push({ id: '0', name: 'بدون گروه (GID 0)' });
|
||||
return groups;
|
||||
},
|
||||
|
||||
async listCustomers(s, cursor) {
|
||||
let [start, empty] = (cursor ?? '1:0').split(':').map(Number);
|
||||
if (!Number.isFinite(start) || start < 1) start = 1;
|
||||
if (!Number.isFinite(empty)) empty = 0;
|
||||
|
||||
const json = await callList(s, 'Dolistmoshtari', [start, start + CUSTOMER_PAGE - 1]);
|
||||
const items = rowsOf(json).map(toCustomer);
|
||||
empty = items.length === 0 ? empty + 1 : 0;
|
||||
const nextStart = start + CUSTOMER_PAGE;
|
||||
const done = empty >= EMPTY_WINDOWS_TO_STOP || nextStart > MAX_CUSTOMER_CODE;
|
||||
return { items, next: done ? null : `${nextStart}:${empty}` };
|
||||
},
|
||||
|
||||
async findCustomersByMobile(s, mobile) {
|
||||
const target = normalizeMobile(mobile);
|
||||
if (!target) return [];
|
||||
// The account's Mobile is free text: some rows hold 9121234567, others
|
||||
// 09121234567, and the lookup matches the stored form. Ask both ways.
|
||||
const found = new Map<string, ExternalCustomer>();
|
||||
for (const form of [Number(target.slice(1)), target]) {
|
||||
for (const r of rowsOf(await callList(s, 'DoListMoshtari', [form]))) {
|
||||
const c = toCustomer(r);
|
||||
if (c.mobile === target) found.set(c.code, c);
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
},
|
||||
|
||||
async createCustomer(s, p) {
|
||||
const json = (await call(s, 'DoNewMoshtari', [
|
||||
p.fullName,
|
||||
p.group.name ?? '',
|
||||
p.mobile,
|
||||
p.address ?? '',
|
||||
p.nationalCode ?? '',
|
||||
'',
|
||||
'',
|
||||
'-1',
|
||||
-1, // Moshtari_Code -1: Tahesab assigns the code
|
||||
0, // Jens_Felez: gold
|
||||
])) as { OK?: unknown };
|
||||
if (json?.OK === undefined || json.OK === null) {
|
||||
throw new AccountingProviderError('تهحساب: کد حساب جدید برنگشت', false, json);
|
||||
}
|
||||
return { code: String(json.OK), response: json };
|
||||
},
|
||||
|
||||
async updateCustomer(s, code, p) {
|
||||
// "-1" leaves a field unchanged (DoEditMoshtari example in the docs).
|
||||
const json = await call(s, 'DoEditMoshtari', [
|
||||
p.fullName,
|
||||
p.group.name ?? '-1',
|
||||
p.mobile,
|
||||
p.address ?? '-1',
|
||||
p.nationalCode ?? '-1',
|
||||
'-1',
|
||||
'-1',
|
||||
'-1',
|
||||
Number(code),
|
||||
]);
|
||||
return { response: json };
|
||||
},
|
||||
|
||||
async getBalances(s, codes) {
|
||||
const out = [];
|
||||
for (let i = 0; i < codes.length; i += BALANCE_CHUNK) {
|
||||
const chunk = codes.slice(i, i + BALANCE_CHUNK);
|
||||
// Unknown codes are simply left out of MandeHesab.
|
||||
const json = (await callList(s, 'getmandehesabbycode', chunk)) as { MandeHesab?: unknown } | null;
|
||||
for (const r of rowsOf(json?.MandeHesab)) {
|
||||
out.push({
|
||||
code: String(r.Code),
|
||||
goldGrams: (dec(r.MandeyeVazni) ?? ZERO).negated(),
|
||||
rial: (dec(r.MandeyeMali) ?? ZERO).negated(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getChanges(s, cursor) {
|
||||
// DoListUpdatedAsnad lists the newest documents by registration/edit time.
|
||||
// Cursor "maxId|latestEdit": a row is news when its ID is higher, or it
|
||||
// carries a later ZamanEdit. Nothing older than the window is visible, so
|
||||
// a window with no already-seen row means "too many changes: refresh all".
|
||||
const rows = rowsOf(await callList(s, 'DoListUpdatedAsnad', [CHANGE_WINDOW]));
|
||||
let maxId = 0;
|
||||
let latestEdit = '';
|
||||
for (const r of rows) {
|
||||
maxId = Math.max(maxId, Number(r.ID) || 0);
|
||||
const edited = String(r.ZamanEdit ?? '');
|
||||
if (edited && edited !== 'null' && edited > latestEdit) latestEdit = edited;
|
||||
}
|
||||
const next = `${maxId}|${latestEdit}`;
|
||||
if (cursor === null) return { cursor: next, accountCodes: [], itemNames: [], overflow: false };
|
||||
|
||||
const [seenId, seenEdit] = [Number(cursor.split('|')[0]) || 0, cursor.split('|')[1] ?? ''];
|
||||
const fresh = rows.filter((r) => {
|
||||
const edited = String(r.ZamanEdit ?? '');
|
||||
return (Number(r.ID) || 0) > seenId || (!!edited && edited !== 'null' && edited > seenEdit);
|
||||
});
|
||||
const accounts = new Set<string>();
|
||||
const items = new Set<string>();
|
||||
for (const r of fresh) {
|
||||
const code = str(r.MCode);
|
||||
if (code) accounts.add(code);
|
||||
const kar = str(r.No_Kar);
|
||||
if (kar && /کار/.test(String(r.NO ?? ''))) items.add(kar);
|
||||
}
|
||||
return {
|
||||
cursor: next,
|
||||
accountCodes: [...accounts],
|
||||
itemNames: [...items],
|
||||
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getStatement(s, q) {
|
||||
// Count_Last = -1 asks for the running balance on every row.
|
||||
const json = await callList(s, 'DoListAsnad', [
|
||||
-1,
|
||||
Number(q.code),
|
||||
formatJalali(q.from),
|
||||
formatJalali(q.to),
|
||||
'',
|
||||
0,
|
||||
'IRR',
|
||||
]);
|
||||
return rowsOf(json).map((r) => toStatementRow(r));
|
||||
},
|
||||
|
||||
async listKarSakhteGroups() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async listKarSakhteTypes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async listKarSakhte(s) {
|
||||
// Only items that already have a document appear here (Tahesab has no
|
||||
// item master in its API). Names that arrive as "????" lost their
|
||||
// encoding upstream and cannot be matched back; they are skipped.
|
||||
return rowsOf(await callList(s, 'DoListNameKarsakhte', []))
|
||||
.map((r) => str(r.Name))
|
||||
.filter((name): name is string => !!name && !name.includes('??'))
|
||||
.map((name) => ({
|
||||
code: name,
|
||||
name,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
typeId: null,
|
||||
typeName: null,
|
||||
saleWagePercent: null,
|
||||
stockWeight: null,
|
||||
stockCount: null,
|
||||
}));
|
||||
},
|
||||
|
||||
async getKarSakhteStock(s, item) {
|
||||
const json = (await callList(s, 'GetMojoodiKarSakhte', [item.name, 0])) as Record<string, unknown> | null;
|
||||
return { weight: dec(json?.VaznMojood), count: dec(json?.TedadMojood) };
|
||||
},
|
||||
|
||||
async getKarSakhteMovement(s, from, to) {
|
||||
// Tahesab cannot list documents across all accounts by date; the closest
|
||||
// is the most recent N documents, filtered here.
|
||||
const lo = formatJalali(from, '/');
|
||||
const hi = formatJalali(to, '/');
|
||||
return rowsOf(await callList(s, 'DoListUpdatedAsnad', [RECENT_DOCUMENTS]))
|
||||
.filter((r) => /کار/.test(String(r.NO ?? '')))
|
||||
.filter((r) => {
|
||||
const d = String(r.Tarikh ?? '');
|
||||
return d >= lo && d <= hi;
|
||||
})
|
||||
.map((r) => ({
|
||||
itemCode: str(r.No_Kar),
|
||||
itemName: str(r.No_Kar) ?? '—',
|
||||
date: String(r.Tarikh ?? ''),
|
||||
description: [str(r.NO), str(r.Sharh1)].filter(Boolean).join(' — ') || null,
|
||||
weight: dec(r.Vazn),
|
||||
amount: dec(r.Mali),
|
||||
count: dec(r.Count),
|
||||
}));
|
||||
},
|
||||
|
||||
async createKarSakhte() {
|
||||
throw new AccountingNotSupportedError('ساخت کارساخته');
|
||||
},
|
||||
|
||||
async postOrderDocument(s, doc, progress, onProgress) {
|
||||
const d = toJalali(doc.date);
|
||||
const multi = doc.lines.length > 1 ? 1 : -1;
|
||||
let done = progress.doneLines ?? 0;
|
||||
let last: unknown = null;
|
||||
|
||||
for (let i = done; i < doc.lines.length; i += 1) {
|
||||
const line = doc.lines[i];
|
||||
const args = [
|
||||
doc.temporary ? 0 : 1, // Sabte_Kol_Or_Movaghat: 0 = temporary
|
||||
Number(doc.customerCode),
|
||||
-1, // Factor_Number: assigned by Tahesab
|
||||
i + 1, // Radif_Number
|
||||
d.jy,
|
||||
d.jm,
|
||||
d.jd,
|
||||
Number(line.weightGrams.toFixed(3)),
|
||||
Number(karatWithWage(line.karat, line.wagePercent).toString()),
|
||||
line.count,
|
||||
line.itemName,
|
||||
1, // IsVoroodOrKhorooj: 1 = exit (goods to the customer)
|
||||
1, // OjratTedadiOrGerami: per gram
|
||||
Number(line.flatRial.toFixed(0)), // MablaghKol — the only rial field that moves the balance
|
||||
0,
|
||||
0,
|
||||
0, // Darsad_Talayee: the charge is already in Ayar; never count it twice
|
||||
0,
|
||||
'',
|
||||
0,
|
||||
0, // PoolSang: does not move the balance; the stones are in MablaghKol
|
||||
line.description,
|
||||
doc.factorCode,
|
||||
-1,
|
||||
multi,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
try {
|
||||
last = await call(s, 'DoNewSanadVKHKar', args);
|
||||
} catch (err) {
|
||||
// The row is already there (an earlier attempt landed but its reply
|
||||
// was lost): Factor_Code + Radif_Number is a unique key in Tahesab.
|
||||
if (!(err instanceof AccountingProviderError && /duplicate key/i.test(err.message + JSON.stringify(err.response ?? '')))) {
|
||||
throw err;
|
||||
}
|
||||
last = { duplicate: true };
|
||||
}
|
||||
done = i + 1;
|
||||
await onProgress({ doneLines: done });
|
||||
}
|
||||
|
||||
const ref = String((last as { Factor_Code?: unknown })?.Factor_Code ?? doc.factorCode).toLowerCase();
|
||||
return { ref, progress: { doneLines: done }, response: last };
|
||||
},
|
||||
|
||||
async documentExists(s, doc) {
|
||||
try {
|
||||
await call(s, 'DoNewSanadInquiry', [doc.factorCode]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return false;
|
||||
// A multi-row document answers {"ERROR":"Duplicate"}: found, more than
|
||||
// one row under the code (seen on the test server).
|
||||
if (err instanceof AccountingProviderError && /:\s*Duplicate$/i.test(err.message)) return true;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDocument(s, doc) {
|
||||
try {
|
||||
return { response: await call(s, 'DoDeleteSanad', [doc.factorCode]) };
|
||||
} catch (err) {
|
||||
// Already gone — the goal is reached.
|
||||
if (err instanceof AccountingProviderError && err.notFound) return { response: { alreadyDeleted: true } };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function toStatementRow(r: Record<string, unknown>): StatementRow {
|
||||
const metal = String(r.Jens_Felez ?? '0');
|
||||
const temporary = r.isSanadMovaghat === true;
|
||||
const isGold = metal === '0';
|
||||
const isRial = !r.Arz || String(r.Arz).toUpperCase() === 'IRR';
|
||||
// Positive TabdilVazn / Mali = in the customer's favour (they gave us gold
|
||||
// or money); negative = they took goods or money from us.
|
||||
const tabdil = isGold ? dec(r.TabdilVazn) ?? ZERO : ZERO;
|
||||
const mali = isRial ? dec(r.Mali) ?? ZERO : ZERO;
|
||||
return {
|
||||
key: `${r.ID}-${r.Radif ?? 1}`,
|
||||
documentRef: String(r.Factor_Code ?? r.ID).toLowerCase(),
|
||||
accountCode: String(r.MCode ?? ''),
|
||||
documentNo: String(r.Sh_Factor ?? ''),
|
||||
date: String(r.Tarikh ?? ''),
|
||||
time: str(r.Tarikh_Time),
|
||||
title: str(r.NO) ?? '',
|
||||
description: [str(r.Sharh1), str(r.Sharh2), str(r.Baabat)].filter(Boolean).join(' — ') || null,
|
||||
itemName: str(r.No_Kar),
|
||||
weight: dec(r.Vazn),
|
||||
karat: dec(r.Ayar)?.toNumber() ?? null,
|
||||
count: dec(r.Count),
|
||||
unitPrice: dec(r.Fi) ?? dec(r.Mazaneh),
|
||||
wagePercent: dec(r.DarsadSood),
|
||||
goldIn: tabdil.gt(0) ? tabdil : ZERO,
|
||||
goldOut: tabdil.lt(0) ? tabdil.negated() : ZERO,
|
||||
rialIn: mali.gt(0) ? mali : ZERO,
|
||||
rialOut: mali.lt(0) ? mali.negated() : ZERO,
|
||||
// A temporary row reports TahesabVazni/Mali = 0 because it moves nothing
|
||||
// yet; null lets the report carry the previous balance forward instead.
|
||||
goldBalance: temporary ? null : neg(dec(r.TahesabVazni)),
|
||||
rialBalance: temporary ? null : neg(dec(r.TahesabMali)),
|
||||
metal: METALS[metal] ?? metal,
|
||||
temporary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { httpRequest } from '../accounting-http';
|
||||
import { OrderDocumentInput, ProviderSettings } from './accounting-provider';
|
||||
import { __zarrinInternals, zarrinProvider as z } from './zarrin.provider';
|
||||
|
||||
jest.mock('../accounting-http', () => ({
|
||||
...jest.requireActual('../accounting-http'),
|
||||
httpRequest: jest.fn(),
|
||||
}));
|
||||
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
|
||||
|
||||
const s: ProviderSettings = {
|
||||
baseUrl: 'http://zarrin.local:8000/ZarrinService/accounting',
|
||||
token: 'SECRET',
|
||||
caCert: null,
|
||||
timeoutMs: 5000,
|
||||
balanceOwesWhen: 'negative',
|
||||
};
|
||||
|
||||
const reply = (body: unknown, status = 200) =>
|
||||
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
||||
|
||||
const url = (n = 0) => new URL(http.mock.calls[n][0].url);
|
||||
|
||||
beforeEach(() => http.mockReset());
|
||||
|
||||
describe('wire format', () => {
|
||||
it('sends GET ?kind=…&token=… with the parameters, and nothing blank', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '4', name: 'مشتری (خریدار)' }] }));
|
||||
const groups = await z.listCustomerGroups(s);
|
||||
const u = url();
|
||||
expect(http.mock.calls[0][0].method).toBe('GET');
|
||||
expect(u.pathname).toBe('/ZarrinService/accounting');
|
||||
expect(u.searchParams.get('kind')).toBe('listGorooheHesabha');
|
||||
expect(u.searchParams.get('token')).toBe('SECRET');
|
||||
expect(groups).toEqual([{ id: '4', name: 'مشتری (خریدار)' }]);
|
||||
});
|
||||
|
||||
it('unwraps the JSON-in-a-string replies the documentation shows', async () => {
|
||||
const inner = JSON.stringify({ state: true, msg: 'حساب با موفقیت ایجاد شد', id: 132 });
|
||||
http.mockReturnValueOnce(reply(JSON.stringify(inner)));
|
||||
const res = await z.createCustomer(s, {
|
||||
firstName: 'تست',
|
||||
lastName: 'تست',
|
||||
fullName: 'تست تست',
|
||||
mobile: '09121112233',
|
||||
phone: null,
|
||||
address: null,
|
||||
nationalCode: null,
|
||||
group: { id: '4', name: 'مشتری' },
|
||||
});
|
||||
expect(res.code).toBe('132');
|
||||
expect(url().searchParams.get('fkNoe')).toBe('4');
|
||||
expect(url().searchParams.has('telephone')).toBe(false);
|
||||
});
|
||||
|
||||
it('turns state:false into an error carrying the message', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: false, msg: 'دسترسی مجاز نیست' }));
|
||||
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
|
||||
});
|
||||
|
||||
it('lists the different envelopes Zarrin uses', () => {
|
||||
const { listOf, fullJalali } = __zarrinInternals;
|
||||
expect(listOf({ data: [1] })).toEqual([1]);
|
||||
expect(listOf({ date: [2] })).toEqual([2]); // Get100Account's typo
|
||||
expect(listOf([3])).toEqual([3]);
|
||||
expect(listOf({ id: '84', nam: 'x' })).toEqual([{ id: '84', nam: 'x' }]);
|
||||
expect(fullJalali('04/05/29')).toBe('1404/05/29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customers', () => {
|
||||
it('pages with Get100Account and fills groups from listhesabha', async () => {
|
||||
const page = Array.from({ length: 100 }, (_, i) => ({ code: 81 + i, name: 'n', namKhanevadegi: `f${i}`, mobile: i === 0 ? '09121112233' : '' }));
|
||||
http
|
||||
.mockReturnValueOnce(reply({ state: true, msg: '', date: page }))
|
||||
.mockReturnValueOnce(reply({ data: [{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' }] }));
|
||||
const res = await z.listCustomers(s, null);
|
||||
expect(url(0).searchParams.get('id')).toBe('0');
|
||||
expect(url(1).searchParams.get('az')).toBe('81');
|
||||
expect(url(1).searchParams.get('ta')).toBe('180');
|
||||
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
|
||||
expect(res.next).toBe('181');
|
||||
});
|
||||
|
||||
it('keeps the account in its group on edit', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'ویرایش اطلاعات با موفقیت انجام شد' }));
|
||||
await z.updateCustomer(s, '101', {
|
||||
firstName: 'حسین',
|
||||
lastName: 'اصغری',
|
||||
fullName: 'حسین اصغری',
|
||||
mobile: '09121427233',
|
||||
phone: null,
|
||||
address: 'خیابان مولوی',
|
||||
nationalCode: null,
|
||||
group: { id: null, name: 'واسطه' },
|
||||
});
|
||||
expect(url().searchParams.get('nameG')).toBe('واسطه');
|
||||
expect(url().searchParams.get('id')).toBe('101');
|
||||
});
|
||||
});
|
||||
|
||||
describe('balances and statement', () => {
|
||||
it('applies the configured sign', async () => {
|
||||
http.mockReturnValue(reply({ state: true, msg: 'OK', data: [{ id: '108', name: 'x', geram: '-75.409', pool: '-3.211960271E9' }, { id: '9', geram: '1', pool: '1' }] }));
|
||||
const neg = await z.getBalances(s, ['108', '5']);
|
||||
expect(neg).toHaveLength(1);
|
||||
expect(neg[0].goldGrams.toString()).toBe('75.409');
|
||||
expect(neg[0].rial.toString()).toBe('3211960271');
|
||||
const pos = await z.getBalances({ ...s, balanceOwesWhen: 'positive' }, ['108', '9']);
|
||||
expect(pos[0].goldGrams.toString()).toBe('-75.409');
|
||||
});
|
||||
|
||||
it('folds a running balance from «مانده از قبل»', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
state: true,
|
||||
detail: 'OK',
|
||||
data: [
|
||||
{ idRadif: '0', idFactor: '0', idHesab: '81', sharh: 'مانده از قبل', vaznTarazooPardakht: '10.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0' },
|
||||
{ idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const rows = await z.getStatement(s, { code: '81', from: { jy: 1404, jm: 5, jd: 1 }, to: { jy: 1404, jm: 5, jd: 31 } });
|
||||
expect(url().searchParams.get('dateAz')).toBe('2025-07-23');
|
||||
expect(rows[0].goldBalance?.toString()).toBe('10');
|
||||
expect(rows[1]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک' });
|
||||
expect(rows[1].goldBalance?.toString()).toBe('7.5');
|
||||
expect(rows[1].rialBalance?.toString()).toBe('810000000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('order documents', () => {
|
||||
const line = (code: string, wage: number) => ({
|
||||
itemCode: code,
|
||||
itemName: code,
|
||||
weightGrams: new Prisma.Decimal('10'),
|
||||
count: 1,
|
||||
wagePercent: new Prisma.Decimal(wage),
|
||||
flatRial: new Prisma.Decimal(0),
|
||||
karat: 750,
|
||||
description: 'd',
|
||||
});
|
||||
const doc: OrderDocumentInput = {
|
||||
factorCode: 'gbordx00000000000000000',
|
||||
customerCode: '81',
|
||||
date: new Date(),
|
||||
description: 'سفارش',
|
||||
temporary: true,
|
||||
lines: [line('19', 12), line('20', 8)],
|
||||
};
|
||||
|
||||
it('opens the invoice with the account, appends with the invoice id, in bonak mode', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } })).mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } }));
|
||||
const progress: unknown[] = [];
|
||||
const res = await z.postOrderDocument(s, doc, {}, async (p) => {
|
||||
progress.push(p);
|
||||
});
|
||||
const a = url(0).searchParams;
|
||||
const b = url(1).searchParams;
|
||||
expect(a.get('kind')).toBe('foroushSakhte');
|
||||
expect(a.get('idHesab')).toBe('81');
|
||||
expect(a.has('idFactor')).toBe(false);
|
||||
expect(a.get('idSakhte')).toBe('19');
|
||||
expect(a.get('darsad')).toBe('12');
|
||||
expect(a.get('vazn')).toBe('10.000');
|
||||
expect(a.get('pd')).toBe('-1');
|
||||
expect(a.get('bonak')).toBe('1');
|
||||
expect(a.get('movaghat')).toBe('1');
|
||||
expect(b.get('idFactor')).toBe('117764');
|
||||
expect(b.has('idHesab')).toBe(false);
|
||||
expect(b.get('movaghat')).toBe('1'); // temporary, as the owner ruled
|
||||
expect(progress).toEqual([
|
||||
{ factorId: '117764', doneLines: 1 },
|
||||
{ factorId: '117764', doneLines: 2 },
|
||||
]);
|
||||
expect(res.ref).toBe('117764');
|
||||
});
|
||||
|
||||
it('resumes into the same invoice', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, data: { idFactor: 5 } }));
|
||||
await z.postOrderDocument(s, doc, { factorId: '5', doneLines: 1 }, async () => undefined);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
expect(url().searchParams.get('idFactor')).toBe('5');
|
||||
expect(url().searchParams.get('idSakhte')).toBe('20');
|
||||
});
|
||||
|
||||
it('finds the row to delete in the ledger, since foroushSakhte never returned it', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '0', idFactor: '0' }, { idRadif: '731', idFactor: '390' }] }))
|
||||
.mockReturnValueOnce(reply({ state: true, msg: 'حذف با موفقیت انجام شد :)' }));
|
||||
await z.deleteDocument(s, { factorCode: 'x', ref: '390', customerCode: '81' });
|
||||
expect(url(1).searchParams.get('kind')).toBe('hazfRadifSanad');
|
||||
expect(url(1).searchParams.get('pkRadifSanad')).toBe('731');
|
||||
expect(url(1).searchParams.get('kol')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('change feed (real-time sync)', () => {
|
||||
it('reads the newest journal rows and reports accounts above the cursor', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }, { idRadif: '733', idHesab: '40' }, { idRadif: '734', idHesab: '40' }] }),
|
||||
);
|
||||
const r = await z.getChanges(s, '731');
|
||||
expect(url().searchParams.get('kind')).toBe('parLastTenRadif');
|
||||
expect(url().searchParams.has('idHesab')).toBe(false);
|
||||
expect(r).toMatchObject({ cursor: '734', accountCodes: ['40'], overflow: false });
|
||||
});
|
||||
|
||||
it('first poll only returns the cursor', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }] }));
|
||||
expect(await z.getChanges(s, null)).toEqual({ cursor: '731', accountCodes: [], itemNames: [], overflow: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||||
import { jalaliToIsoDate, toIsoDateTehran } from '../jalali';
|
||||
import {
|
||||
AccountingProvider,
|
||||
AccountingProviderError,
|
||||
ExternalCustomer,
|
||||
ProviderSettings,
|
||||
StatementRow,
|
||||
ZERO,
|
||||
dec,
|
||||
normalizeMobile,
|
||||
splitName,
|
||||
str,
|
||||
} from './accounting-provider';
|
||||
|
||||
/**
|
||||
* Zarrin (زرین) — https://zarrin.ir/api-docs/ (the page renders
|
||||
* /wp-content/themes/Hirad/data/api.json, 82 entries).
|
||||
*
|
||||
* Wire format: GET <base>?kind=<api>&token=<token>&<params>, where <base> is
|
||||
* the installation's `http://host:8000/ZarrinService/accounting`. The token
|
||||
* travels in the query string, so a URL built here must never be logged.
|
||||
*
|
||||
* NOT verified against a live installation — the owner has none to test on
|
||||
* (plan, decision 11). Everything below follows the documentation and its
|
||||
* sample outputs, and is covered by unit tests built from those samples. The
|
||||
* open points (balance sign, which rial field the stones belong in) are
|
||||
* configurable or flagged, and the integration is to be switched on for real
|
||||
* only after a trial period on temporary documents.
|
||||
*
|
||||
* Differences from Tahesab that shape the sync:
|
||||
* * No idempotency key on foroushSakhte. A timed-out document call cannot be
|
||||
* retried blindly — the worker parks it as NEEDS_REVIEW.
|
||||
* * foroushSakhte does not return the row id needed to delete a document;
|
||||
* deleteDocument looks it up from the account's ledger (parvande).
|
||||
* * کارساخته can be created (addFelezsakhteModel) but never edited.
|
||||
*/
|
||||
|
||||
const STATEMENT_PAGE = 250;
|
||||
const CHANGE_WINDOW = 50;
|
||||
const MAX_STATEMENT_PAGES = 20;
|
||||
|
||||
function endpoint(s: ProviderSettings) {
|
||||
return s.baseUrl.trim();
|
||||
}
|
||||
|
||||
async function call(
|
||||
s: ProviderSettings,
|
||||
kind: string,
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<Record<string, unknown> | unknown[]> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint(s));
|
||||
} catch {
|
||||
throw new AccountingProviderError('زرین: آدرس سرور نامعتبر است');
|
||||
}
|
||||
url.searchParams.set('kind', kind);
|
||||
url.searchParams.set('token', s.token);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v));
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: s.timeoutMs, ca: s.caCert });
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) {
|
||||
throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new AccountingProviderError(`زرین (${kind}): HTTP ${res.status}`, false, res.body.slice(0, 500), res.status === 404);
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = parseLooseJson(res.body);
|
||||
// The documented outputs are JSON-encoded strings of JSON ("{\"state\":…}").
|
||||
if (typeof json === 'string' && /^\s*[{[]/.test(json)) json = parseLooseJson(json);
|
||||
} catch {
|
||||
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, res.body.slice(0, 500));
|
||||
}
|
||||
if (!json || typeof json !== 'object') {
|
||||
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, json);
|
||||
}
|
||||
|
||||
const o = json as Record<string, unknown>;
|
||||
if (o.state === false || o.status === false) {
|
||||
const msg = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? 'خطای نامشخص';
|
||||
throw new AccountingProviderError(`زرین (${kind}): ${msg}`, false, json, /یافت نشد|موجود نیست/.test(msg));
|
||||
}
|
||||
return json as Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
/** Zarrin wraps lists as `data`, once as `date` (Get100Account), or not at all. */
|
||||
function listOf(json: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(json)) return json as Record<string, unknown>[];
|
||||
if (json && typeof json === 'object') {
|
||||
const o = json as Record<string, unknown>;
|
||||
for (const key of ['data', 'date', 'mande']) {
|
||||
if (Array.isArray(o[key])) return o[key] as Record<string, unknown>[];
|
||||
}
|
||||
if ('id' in o || 'code' in o) return [o];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function dataOf(json: unknown): Record<string, unknown> {
|
||||
const o = (json ?? {}) as Record<string, unknown>;
|
||||
const d = o.data;
|
||||
if (d && typeof d === 'object' && !Array.isArray(d)) return d as Record<string, unknown>;
|
||||
return o;
|
||||
}
|
||||
|
||||
function joinName(first: unknown, last: unknown) {
|
||||
return [str(first), str(last)].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
/** Zarrin prints dates as yy/mm/dd of the 1400s: "04/05/29" → "1404/05/29". */
|
||||
function fullJalali(v: unknown): string {
|
||||
const s = str(v) ?? '';
|
||||
const m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
|
||||
if (!m) return s;
|
||||
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function signed(s: ProviderSettings, v: Prisma.Decimal) {
|
||||
// Until checked on a real installation (plan §9) the sign is a setting.
|
||||
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
|
||||
}
|
||||
|
||||
export const zarrinProvider: AccountingProvider = {
|
||||
name: 'zarrin',
|
||||
label: 'زرین',
|
||||
capabilities: { createKarSakhte: true, documentInquiry: false, movementScope: 'all', changeFeed: true },
|
||||
|
||||
async health(s) {
|
||||
const v = (await call(s, 'version')) as Record<string, unknown>;
|
||||
// `version` may not check the token; a group listing does.
|
||||
const groups = listOf(await call(s, 'listGorooheHesabha'));
|
||||
return { version: v.version, message: v.message, customerGroups: groups.length };
|
||||
},
|
||||
|
||||
async listCustomerGroups(s) {
|
||||
return listOf(await call(s, 'listGorooheHesabha')).map((g) => ({
|
||||
id: String(g.id),
|
||||
name: str(g.name) ?? String(g.id),
|
||||
}));
|
||||
},
|
||||
|
||||
async listCustomers(s, cursor) {
|
||||
const from = Number(cursor ?? 0) || 0;
|
||||
// Get100Account pages by code (100 accounts from `id` on) but carries no
|
||||
// group or address; listhesabha over the same code span fills those in.
|
||||
const page = listOf(await call(s, 'Get100Account', { id: from }));
|
||||
if (page.length === 0) return { items: [], next: null };
|
||||
|
||||
const codes = page.map((r) => Number(r.code)).filter(Number.isFinite);
|
||||
const lo = Math.min(...codes);
|
||||
const hi = Math.max(...codes);
|
||||
const details = new Map<string, Record<string, unknown>>();
|
||||
for (const r of listOf(await call(s, 'listhesabha', { az: lo, ta: hi }))) details.set(String(r.id), r);
|
||||
|
||||
const items: ExternalCustomer[] = page.map((r) => {
|
||||
const code = String(r.code);
|
||||
const d = details.get(code) ?? {};
|
||||
return {
|
||||
code,
|
||||
name: str(d.nam) ?? joinName(r.name, r.namKhanevadegi),
|
||||
mobile: normalizeMobile(d.mobile ?? r.mobile),
|
||||
phone: str(d.tell),
|
||||
nationalCode: str(d.codeMeli),
|
||||
address: str(d.address),
|
||||
groupId: null,
|
||||
groupName: str(d.gorooh),
|
||||
};
|
||||
});
|
||||
const next = page.length < 100 ? null : String(hi + 1);
|
||||
return { items, next };
|
||||
},
|
||||
|
||||
async findCustomersByMobile(s, mobile) {
|
||||
const target = normalizeMobile(mobile);
|
||||
if (!target) return [];
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = listOf(await call(s, 'getinfoByPhone', { number: target }));
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return [];
|
||||
throw err;
|
||||
}
|
||||
return rows
|
||||
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
|
||||
.map((r) => ({
|
||||
code: String(r.code),
|
||||
name: joinName(r.nam, r.namKhanevadegi),
|
||||
mobile: target,
|
||||
phone: str(r.tel),
|
||||
nationalCode: null,
|
||||
address: str(r.address),
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
}));
|
||||
},
|
||||
|
||||
async createCustomer(s, p) {
|
||||
const json = (await call(s, 'addAccount', {
|
||||
firstName: p.firstName || p.fullName,
|
||||
lastName: p.lastName || '-',
|
||||
fkNoe: p.group.id,
|
||||
mobile: p.mobile,
|
||||
telephone: p.phone,
|
||||
address: p.address,
|
||||
codeMelli: p.nationalCode,
|
||||
})) as Record<string, unknown>;
|
||||
const id = json.id ?? dataOf(json).id;
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد حساب جدید برنگشت', false, json);
|
||||
return { code: String(id), response: json };
|
||||
},
|
||||
|
||||
async updateCustomer(s, code, p) {
|
||||
const json = await call(s, 'edithesab', {
|
||||
id: code,
|
||||
name: p.firstName || p.fullName,
|
||||
family: p.lastName,
|
||||
mobile: p.mobile,
|
||||
tel: p.phone,
|
||||
address: p.address,
|
||||
codeMeli: p.nationalCode,
|
||||
// Without nameG Zarrin moves the account into its customers group, so the
|
||||
// current group is always sent back.
|
||||
nameG: p.group.name,
|
||||
});
|
||||
return { response: json };
|
||||
},
|
||||
|
||||
async getBalances(s, codes) {
|
||||
if (codes.length === 0) return [];
|
||||
const wanted = new Set(codes);
|
||||
const rows =
|
||||
codes.length === 1
|
||||
? listOf(await call(s, 'listMandeHesabha', { id: codes[0], isRial: 0 }))
|
||||
: listOf(await call(s, 'listMandeHesabha', { isRial: 0 }));
|
||||
return rows
|
||||
.filter((r) => wanted.has(String(r.id)))
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
goldGrams: signed(s, dec(r.geram) ?? ZERO),
|
||||
rial: signed(s, dec(r.pool) ?? ZERO),
|
||||
}));
|
||||
},
|
||||
|
||||
async getChanges(s, cursor) {
|
||||
// parLastTenRadif without an account is the newest rows of the whole
|
||||
// journal; each row names its account (idHesab) and has an ascending idRadif.
|
||||
// Not yet seen on a real install (ACC-01).
|
||||
const rows = listOf(await call(s, 'parLastTenRadif', { limit: CHANGE_WINDOW }));
|
||||
let maxId = 0;
|
||||
for (const r of rows) maxId = Math.max(maxId, Number(r.idRadif) || 0);
|
||||
if (cursor === null) return { cursor: String(maxId), accountCodes: [], itemNames: [], overflow: false };
|
||||
const seen = Number(cursor) || 0;
|
||||
const fresh = rows.filter((r) => (Number(r.idRadif) || 0) > seen);
|
||||
const accounts = new Set<string>();
|
||||
for (const r of fresh) {
|
||||
const code = str(r.idHesab);
|
||||
if (code) accounts.add(code);
|
||||
}
|
||||
return {
|
||||
cursor: String(Math.max(maxId, seen)),
|
||||
accountCodes: [...accounts],
|
||||
itemNames: [],
|
||||
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getStatement(s, q) {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) {
|
||||
const batch = listOf(
|
||||
await call(s, 'parvande', {
|
||||
id: q.code,
|
||||
dateAz: jalaliToIsoDate(q.from),
|
||||
dateTa: jalaliToIsoDate(q.to),
|
||||
pageNum: page,
|
||||
detailCount: STATEMENT_PAGE,
|
||||
}),
|
||||
);
|
||||
rows.push(...batch);
|
||||
if (batch.length < STATEMENT_PAGE) break;
|
||||
}
|
||||
|
||||
// parvande has no running balance; it opens with «مانده از قبل» and the
|
||||
// balance is folded from there. پرداخت = we gave = the customer owes more.
|
||||
let gold = ZERO;
|
||||
let rial = ZERO;
|
||||
return rows.map((r): StatementRow => {
|
||||
const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO;
|
||||
const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO;
|
||||
const rialIn = dec(r.poolDaryaft) ?? ZERO;
|
||||
const rialOut = dec(r.poolPardakht) ?? ZERO;
|
||||
gold = gold.add(goldOut).sub(goldIn);
|
||||
rial = rial.add(rialOut).sub(rialIn);
|
||||
const factor = str(r.idFactor);
|
||||
return {
|
||||
key: String(r.idRadif ?? ''),
|
||||
documentRef: factor && factor !== '0' ? factor : '',
|
||||
accountCode: String(r.idHesab ?? q.code),
|
||||
documentNo: factor && factor !== '0' ? factor : '',
|
||||
date: fullJalali(r.tarikh),
|
||||
time: null,
|
||||
title: str(r.noeRadif) ?? str(r.noeFactor) ?? str(r.sharh) ?? '',
|
||||
description: str(r.sharh),
|
||||
itemName: str(r.namFelez),
|
||||
weight: dec(r.vaznDaryaft)?.gt(0) ? dec(r.vaznDaryaft) : dec(r.vaznPardakht),
|
||||
karat: dec(r.ayar)?.toNumber() || null,
|
||||
count: null,
|
||||
unitPrice: null,
|
||||
wagePercent: null,
|
||||
goldIn,
|
||||
goldOut,
|
||||
rialIn,
|
||||
rialOut,
|
||||
goldBalance: gold,
|
||||
rialBalance: rial,
|
||||
metal: null,
|
||||
temporary: false,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async listKarSakhteGroups(s) {
|
||||
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
|
||||
},
|
||||
|
||||
async listKarSakhteTypes(s) {
|
||||
const types = new Map<string, string>();
|
||||
for (const r of listOf(await call(s, 'listKarSakhte'))) {
|
||||
if (r.fkNoe !== undefined) types.set(String(r.fkNoe), str(r.NoeName) ?? String(r.fkNoe));
|
||||
}
|
||||
return [...types].map(([id, name]) => ({ id, name }));
|
||||
},
|
||||
|
||||
async listKarSakhte(s) {
|
||||
return listOf(await call(s, 'listKarSakhte'))
|
||||
.filter((r) => r.id !== undefined && str(r.namFelezsakhthe))
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
name: str(r.namFelezsakhthe) as string,
|
||||
// listKarSakhte carries the item's type (گرمی/تعدادی…) but not its
|
||||
// group (النگو/انگشتر…); category stays the app's call (D27).
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
typeId: str(r.fkNoe),
|
||||
typeName: str(r.NoeName),
|
||||
saleWagePercent: dec(r.darsadForoosh),
|
||||
stockWeight: dec(r.vazn),
|
||||
stockCount: dec(r.tedad),
|
||||
}));
|
||||
},
|
||||
|
||||
async getKarSakhteStock(s, item) {
|
||||
const d = dataOf(await call(s, 'mojoodiKala', { id: item.code, dateTa: toIsoDateTehran(new Date()) }));
|
||||
const qty = dec(d.mojoodi);
|
||||
const byWeight = /گرم/.test(String(d.noeName ?? 'گرمی'));
|
||||
return { weight: byWeight ? qty : null, count: byWeight ? null : qty };
|
||||
},
|
||||
|
||||
async getKarSakhteMovement(s, from, to) {
|
||||
return listOf(await call(s, 'infogardeshsakhte', { az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) })).map(
|
||||
(r) => ({
|
||||
itemCode: str(r.code_kar),
|
||||
itemName: str(r.nam_kar) ?? '—',
|
||||
date: str(r.tarikh_akharin_gardesh) ?? '',
|
||||
description: str(r.sharh_akharin_gardesh),
|
||||
weight: dec(r.vazn_akharin_gardesh),
|
||||
amount: dec(r.mablagh_akharin_gardesh),
|
||||
count: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async createKarSakhte(s, item) {
|
||||
const json = (await call(s, 'addFelezsakhteModel', {
|
||||
nameKar: item.name,
|
||||
noeKar: item.typeId,
|
||||
gorooheKar: item.groupId,
|
||||
})) as Record<string, unknown>;
|
||||
const id = json.idKar ?? dataOf(json).idKar;
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد کارساختهی جدید برنگشت', false, json);
|
||||
return { code: String(id), response: json };
|
||||
},
|
||||
|
||||
async postOrderDocument(s, doc, progress, onProgress) {
|
||||
let factorId = progress.factorId;
|
||||
let done = progress.doneLines ?? 0;
|
||||
let last: unknown = null;
|
||||
|
||||
for (let i = done; i < doc.lines.length; i += 1) {
|
||||
const line = doc.lines[i];
|
||||
const isLast = i === doc.lines.length - 1;
|
||||
last = await call(s, 'foroushSakhte', {
|
||||
// The first call opens the invoice for the account; the rest append.
|
||||
...(factorId ? { idFactor: factorId } : { idHesab: doc.customerCode }),
|
||||
idSakhte: line.itemCode,
|
||||
// Bonak mode: mablagh is the rial charge, darsad the per-gram making charge.
|
||||
mablagh: line.flatRial.toFixed(0),
|
||||
sharh: line.description,
|
||||
vazn: line.weightGrams.toFixed(3),
|
||||
vaznSang: 0,
|
||||
mablaghSang: 0,
|
||||
pd: -1, // پرداخت: goods leave to the customer
|
||||
darsad: line.wagePercent.toString(),
|
||||
// The invoice has to stay open while lines are still to come; the last
|
||||
// line closes it unless the document is to stay temporary (D27: it is).
|
||||
movaghat: doc.temporary || !isLast ? 1 : 0,
|
||||
bonak: 1,
|
||||
});
|
||||
const id = (dataOf(last).idFactor ?? (last as Record<string, unknown>).idFactor) as unknown;
|
||||
if (!factorId) {
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: شماره فاکتور برنگشت', true, last);
|
||||
factorId = String(id);
|
||||
}
|
||||
done = i + 1;
|
||||
await onProgress({ factorId, doneLines: done });
|
||||
}
|
||||
|
||||
return { ref: String(factorId ?? ''), progress: { factorId, doneLines: done }, response: last };
|
||||
},
|
||||
|
||||
async documentExists() {
|
||||
// No lookup by our own key exists in Zarrin's API.
|
||||
return false;
|
||||
},
|
||||
|
||||
async deleteDocument(s, doc) {
|
||||
if (!doc.ref) throw new AccountingProviderError('زرین: شماره فاکتور سند معلوم نیست');
|
||||
// hazfRadifSanad needs a row id, which foroushSakhte never returned; find
|
||||
// one in the account's ledger for the last year.
|
||||
const today = new Date();
|
||||
const yearAgo = new Date(today.getTime() - 366 * 24 * 3600 * 1000);
|
||||
const rows = listOf(
|
||||
await call(s, 'parvande', {
|
||||
id: doc.customerCode,
|
||||
dateAz: toIsoDateTehran(yearAgo),
|
||||
dateTa: toIsoDateTehran(today),
|
||||
pageNum: 1,
|
||||
detailCount: STATEMENT_PAGE,
|
||||
}),
|
||||
);
|
||||
const row = rows.find((r) => String(r.idFactor) === doc.ref && str(r.idRadif) && String(r.idRadif) !== '0');
|
||||
if (!row) return { response: { alreadyDeleted: true } };
|
||||
const json = await call(s, 'hazfRadifSanad', {
|
||||
pkRadifSanad: String(row.idRadif),
|
||||
kol: 1,
|
||||
commentUser: 'لغو سفارش در اپ',
|
||||
});
|
||||
return { response: json };
|
||||
},
|
||||
};
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __zarrinInternals = { listOf, fullJalali };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||
import { envOrNull } from '../../common/env';
|
||||
|
||||
/**
|
||||
* Encrypts the accounting-software token at rest (D27).
|
||||
*
|
||||
* The token sits in the Setting table, so a database dump or a backup would
|
||||
* otherwise hand out a live credential to the shop's books. AES-256-GCM with a
|
||||
* key derived from `ACCOUNTING_SECRET_KEY`.
|
||||
*
|
||||
* When that variable is unset the key falls back to one derived from
|
||||
* `JWT_SECRET`, with a loud warning at boot — the same "warn, don't refuse"
|
||||
* trade-off D23 made — so an admin can configure the integration before ops
|
||||
* add the variable. The cost: rotating JWT_SECRET then makes the stored token
|
||||
* unreadable and the admin has to enter it again (the screen says so).
|
||||
*/
|
||||
|
||||
const PREFIX = 'enc:v1:';
|
||||
|
||||
export function secretKeySource(): 'ACCOUNTING_SECRET_KEY' | 'JWT_SECRET' | null {
|
||||
if (envOrNull('ACCOUNTING_SECRET_KEY')) return 'ACCOUNTING_SECRET_KEY';
|
||||
if (envOrNull('JWT_SECRET')) return 'JWT_SECRET';
|
||||
return null;
|
||||
}
|
||||
|
||||
function key(): Buffer {
|
||||
const material = envOrNull('ACCOUNTING_SECRET_KEY') ?? envOrNull('JWT_SECRET');
|
||||
if (!material) {
|
||||
throw new Error('ACCOUNTING_SECRET_KEY تنظیم نشده است؛ ذخیرهی توکن حسابداری ممکن نیست.');
|
||||
}
|
||||
return createHash('sha256').update(`goldbank-accounting:${material}`).digest();
|
||||
}
|
||||
|
||||
export function sealSecret(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return PREFIX + Buffer.concat([iv, tag, body]).toString('base64');
|
||||
}
|
||||
|
||||
/** Null when the value cannot be decrypted (wrong key, tampered, malformed). */
|
||||
export function openSecret(sealed: string | null | undefined): string | null {
|
||||
if (!sealed || !sealed.startsWith(PREFIX)) return null;
|
||||
try {
|
||||
const raw = Buffer.from(sealed.slice(PREFIX.length), 'base64');
|
||||
const iv = raw.subarray(0, 12);
|
||||
const tag = raw.subarray(12, 28);
|
||||
const body = raw.subarray(28);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
|
||||
/**
|
||||
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
|
||||
@@ -97,6 +98,7 @@ export class AdminCustomersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -802,7 +804,16 @@ export class AdminCustomersService {
|
||||
patch.shopImageUrl = (data.shop_image as string) || null;
|
||||
}
|
||||
|
||||
return this.prisma.customer.update({ where: { id }, data: patch });
|
||||
// D27 — the fields the accounting account carries. A change to one of them
|
||||
// is pushed to a linked account.
|
||||
const contactChanged = ['fname', 'lname', 'name', 'mobile', 'national_code', 'phone', 'address'].some(
|
||||
(k) => data[k] !== undefined,
|
||||
);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.customer.update({ where: { id }, data: patch });
|
||||
if (contactChanged) await this.accounting.customerEdited(tx, id);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -957,7 +968,15 @@ export class AdminCustomersService {
|
||||
throw new BadRequestException('مرحلهی تایید مشخص نشده است');
|
||||
}
|
||||
|
||||
return this.prisma.customer.update({ where: { id }, data: { verificationStep: next } });
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.customer.update({ where: { id }, data: { verificationStep: next } });
|
||||
// D27 — a verified customer gets an account in the accounting software
|
||||
// (queued; searched by mobile first, so an existing account is linked).
|
||||
if (next === VerificationStep.VERIFIED && c.verificationStep !== VerificationStep.VERIFIED) {
|
||||
await this.accounting.customerVerified(tx, id);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET major/archive/{id}` — MajorCustomerController::archive. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AdminMiscService } from './admin-misc.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { isAccountingSettingKey } from '../accounting-sync/accounting-config';
|
||||
|
||||
/**
|
||||
* Cities, SMS, notifications, newsletter, activity log and settings — the
|
||||
@@ -227,7 +228,7 @@ export class AdminMiscController {
|
||||
// `custom.config.statics.slider`) simply by calling `const/mobile_main_slider`
|
||||
// directly, since the underlying Setting row is keyed the same either way.
|
||||
private assertNotSliderKey(key: string) {
|
||||
if (key === 'mobile_main_slider') {
|
||||
if (key === 'mobile_main_slider' || isAccountingSettingKey(key)) {
|
||||
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MessageAudience, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
|
||||
|
||||
/**
|
||||
* The remaining admin screens — legacy views/city, views/sms, views/notify,
|
||||
@@ -477,7 +478,13 @@ export class AdminMiscService {
|
||||
}
|
||||
|
||||
async listConsts() {
|
||||
const rows = await this.prisma.setting.findMany({ orderBy: { key: 'asc' } });
|
||||
// The accounting integration's rows (connection, encrypted token, worker
|
||||
// lease) have their own `config.accounting` screen and never appear here
|
||||
// (D27); the single-key routes refuse them in the controller.
|
||||
const rows = await this.prisma.setting.findMany({
|
||||
where: { NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } },
|
||||
orderBy: { key: 'asc' },
|
||||
});
|
||||
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
import type { UpdateOrderStatusDto } from './update-status.dto';
|
||||
|
||||
@@ -40,6 +41,7 @@ export class AdminOrdersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly orders: OrdersService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
) {}
|
||||
|
||||
/** Fields the XTable filter bar and sorter may reference. */
|
||||
@@ -220,11 +222,14 @@ export class AdminOrdersService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.orderStatusEvent.deleteMany({ where: { orderId: id } }),
|
||||
this.prisma.orderItem.deleteMany({ where: { orderId: id } }),
|
||||
this.prisma.order.delete({ where: { id } }),
|
||||
]);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// D27 — a temporary document may already be in the books (the rule can
|
||||
// post it at placement); withdraw it like a cancellation.
|
||||
await this.accounting.orderCancelled(tx, id);
|
||||
await tx.orderStatusEvent.deleteMany({ where: { orderId: id } });
|
||||
await tx.orderItem.deleteMany({ where: { orderId: id } });
|
||||
await tx.order.delete({ where: { id } });
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -405,9 +410,12 @@ export class AdminOrdersService {
|
||||
where: { orderId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: latest?.toStatus ?? OrderStatus.PLACED },
|
||||
const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.order.update({ where: { id: orderId }, data: { status: rolledBackTo } });
|
||||
// D27 — rolled back below the document rule: the پیشفاکتور is withdrawn.
|
||||
await this.accounting.orderStatusReached(tx, orderId, rolledBackTo);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
|
||||
/**
|
||||
* Product administration — legacy views/apps/product/* served by
|
||||
@@ -24,6 +25,7 @@ export class AdminProductsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
@@ -115,6 +117,11 @@ export class AdminProductsService {
|
||||
: null,
|
||||
// legacy grid slot reads `ProductImage`
|
||||
ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })),
|
||||
// D27 — stock as last read from the accounting software (null = not
|
||||
// linked / never synced).
|
||||
accounting_stock: p.accountingStockAt
|
||||
? { weight: p.accountingStockWeight, count: p.accountingStockCount, at: p.accountingStockAt }
|
||||
: null,
|
||||
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder, role: i.role })),
|
||||
// The order builder's grid reads `thumb.full` and `variants_all` directly
|
||||
// off each row, so both travel with the list as they did in legacy. Legacy's
|
||||
@@ -159,8 +166,16 @@ export class AdminProductsService {
|
||||
});
|
||||
if (!p) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
// D27 — the form locks title/code of a linked product, and offers the
|
||||
// accounting software's sale making charge as the default for new variants.
|
||||
const link = await this.accounting.productLink(p.id);
|
||||
const snapshot = (link?.snapshot ?? {}) as { saleWagePercent?: string | null };
|
||||
|
||||
return {
|
||||
...this.toRow(p),
|
||||
accounting: link
|
||||
? { locked: true, code: link.externalCode, origin: link.origin, sale_wage_percent: snapshot.saleWagePercent ?? null }
|
||||
: null,
|
||||
body_html: p.description,
|
||||
brand_id: p.brandId,
|
||||
cats: p.categoryId ? [p.categoryId] : [],
|
||||
@@ -446,6 +461,10 @@ export class AdminProductsService {
|
||||
}
|
||||
}
|
||||
|
||||
// D27 — create the matching کارساخته in the accounting software (queued;
|
||||
// only where the software's API can create items).
|
||||
await this.accounting.productCreated(this.prisma, created.id);
|
||||
|
||||
return this.findOne(created.id);
|
||||
}
|
||||
|
||||
@@ -454,6 +473,21 @@ export class AdminProductsService {
|
||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
// D27 — title and code of a product linked to the accounting software are
|
||||
// that software's; the form shows them locked. Re-posting the same values
|
||||
// is fine, changing them is not.
|
||||
const link = await this.accounting.productLink(id);
|
||||
if (link) {
|
||||
const titleChanged = data.title !== undefined && String(data.title).trim() !== existing.title;
|
||||
const codeChanged =
|
||||
data.product_code !== undefined && ((data.product_code as string) || null) !== existing.productCode;
|
||||
if (titleChanged || codeChanged) {
|
||||
throw new BadRequestException(
|
||||
'عنوان و کد این محصول از نرمافزار حسابداری میآید؛ برای تغییر، آن را در حسابداری ویرایش کنید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Prisma.ProductUpdateInput = {};
|
||||
if (data.title !== undefined) patch.title = String(data.title);
|
||||
if (data.body_html !== undefined) patch.description = String(data.body_html);
|
||||
|
||||
@@ -32,6 +32,8 @@ export const ALL_RIDS = [
|
||||
'comment.state',
|
||||
'comment.view',
|
||||
'comment.view.edit',
|
||||
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
|
||||
'config.accounting',
|
||||
'config.report',
|
||||
'config.statics',
|
||||
'config.statics.brand',
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
) {}
|
||||
|
||||
listProducts() {
|
||||
return this.prisma.product.findMany({
|
||||
where: { isActive: true },
|
||||
async listProducts() {
|
||||
const products = await this.prisma.product.findMany({
|
||||
// D27 — a product with no image is never shown in the app. Products now
|
||||
// also arrive from the accounting software, which carries no pictures;
|
||||
// they appear once an admin photographs them.
|
||||
where: { isActive: true, images: { some: {} } },
|
||||
include: {
|
||||
// Only orderable variants reach the storefront. An inactive one used to
|
||||
// be listed and then rejected by getVariantOrThrow at checkout, so the
|
||||
@@ -26,13 +33,49 @@ export class CatalogService {
|
||||
collections: { include: { collection: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const out = [];
|
||||
for (const p of products) {
|
||||
const { accountingStockWeight, accountingStockCount, accountingStockAt, ...product } = p;
|
||||
// D27 — out of stock in the accounting software: every variant reads as
|
||||
// qty 0, which the apps already render as «ناموجود» and refuse to add to
|
||||
// the cart unless it is a pre-order variant. The stock figures themselves
|
||||
// stay server-side; this endpoint is public.
|
||||
const blocked = await this.accounting.stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
|
||||
out.push(blocked ? { ...product, variants: product.variants.map((v) => (v.isPreOrder ? v : { ...v, qty: 0 })) } : product);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async getVariantOrThrow(variantId: string) {
|
||||
const variant = await this.prisma.productVariant.findUnique({ where: { id: variantId } });
|
||||
const variant = await this.prisma.productVariant.findUnique({
|
||||
where: { id: variantId },
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
accountingStockWeight: true,
|
||||
accountingStockCount: true,
|
||||
accountingStockAt: true,
|
||||
_count: { select: { images: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!variant || !variant.isActive) {
|
||||
throw new NotFoundException(`Product variant ${variantId} not found or inactive`);
|
||||
}
|
||||
return variant;
|
||||
// The same two rules the listing applies, so a stale client cannot order
|
||||
// around them.
|
||||
if (variant.product._count.images === 0) {
|
||||
throw new BadRequestException('این محصول هنوز تصویر ندارد و قابل سفارش نیست');
|
||||
}
|
||||
// Live from the accounting software at order time; on no answer the last
|
||||
// stored figure decides (liveStock).
|
||||
if (!variant.isPreOrder && (await this.accounting.stockBlocked(await this.accounting.liveStock(variant.product)))) {
|
||||
throw new BadRequestException('این محصول در انبار موجود نیست');
|
||||
}
|
||||
const { product: _product, ...plain } = variant;
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,11 +558,11 @@ export class CustomerAuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the code to the SMS gateway (SmsService -> Trez, per Sms.php:57-82).
|
||||
* Hands the code to the SMS gateway (SmsService -> the SMS_PROVIDER driver,
|
||||
* relay by default per D26).
|
||||
*
|
||||
* When no credentials are configured, SmsService does not send and the code
|
||||
* is logged instead — development only; production refuses to boot without
|
||||
* credentials (assertSmsConfigured in main.ts).
|
||||
* When no gateway is configured, SmsService does not send and the code is
|
||||
* logged instead — outside production only (D20).
|
||||
*
|
||||
* A failed send does NOT throw. The reference ignored Sms::Send's result, so
|
||||
* a gateway outage never blocked the flow that triggered it; the customer
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
|
||||
import { jalaliToGregorian, parseJalali } from '../accounting-sync/jalali';
|
||||
import type { StatementRow } from '../accounting-sync/providers';
|
||||
|
||||
/**
|
||||
* "گزارش" — the customer's account statement.
|
||||
@@ -30,7 +33,26 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerReportService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
private readonly logger = new Logger(CustomerReportService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accounting: AccountingSyncService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The accounting statement, or null to fall back to the internal ledger —
|
||||
* when the integration is off, the customer is not linked, or the software
|
||||
* cannot be reached (the report must still open).
|
||||
*/
|
||||
private async accountingStatement(customerId: string) {
|
||||
try {
|
||||
return await this.accounting.customerStatement(customerId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accounting statement unavailable for ${customerId}, using the ledger: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The reference's page size (report/index.vue:96). */
|
||||
private static readonly LIMIT = 50;
|
||||
@@ -51,6 +73,21 @@ export class CustomerReportService {
|
||||
* display (report/index.vue:191), so the app shows newest first. Same here.
|
||||
*/
|
||||
async summary(customerId: string, page = 1) {
|
||||
// D27 — from the accounting software when the integration covers this
|
||||
// customer, so the statement agrees with the balance they are shown.
|
||||
const statement = await this.accountingStatement(customerId);
|
||||
if (statement) {
|
||||
const rows = statementToSummary(statement.rows).reverse();
|
||||
const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT;
|
||||
return {
|
||||
result: rows.slice(start, start + CustomerReportService.LIMIT),
|
||||
count: rows.length,
|
||||
page: Math.max(1, page),
|
||||
limit: CustomerReportService.LIMIT,
|
||||
source: 'accounting',
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await this.prisma.ledgerEntry.findMany({
|
||||
// Only confirmed entries move a balance — the same rule the cached
|
||||
// Customer.goldDebtGrams / fiatDebtRial follow, so the statement's final
|
||||
@@ -129,6 +166,22 @@ export class CustomerReportService {
|
||||
* from the client beyond being a filter.
|
||||
*/
|
||||
async detail(customerId: string, entryId?: string, page = 1) {
|
||||
const statement = await this.accountingStatement(customerId);
|
||||
if (statement) {
|
||||
// SEC-16 holds: the rows are already limited to this customer's own
|
||||
// account (AccountingSyncService.customerStatement), and the id is only a
|
||||
// filter over them — another customer's document id matches nothing.
|
||||
let rows = statement.rows.map(statementToDetail).reverse();
|
||||
if (entryId) rows = rows.filter((r) => r.factor_Code === entryId);
|
||||
const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT;
|
||||
return {
|
||||
result: entryId ? rows : rows.slice(start, start + CustomerReportService.LIMIT),
|
||||
page: Math.max(1, page),
|
||||
limit: CustomerReportService.LIMIT,
|
||||
source: 'accounting',
|
||||
};
|
||||
}
|
||||
|
||||
const where: Prisma.LedgerEntryWhereInput = {
|
||||
customerId,
|
||||
confirmed: true,
|
||||
@@ -215,6 +268,22 @@ export class CustomerReportService {
|
||||
* flip is applied and the figure matches every other screen.
|
||||
*/
|
||||
async balances(customerId: string) {
|
||||
// D27 — the accounting software's balance when the integration covers
|
||||
// this customer; the same figure /customer/me shows.
|
||||
const accounting = await this.accounting.customerBalance(customerId);
|
||||
if (accounting) {
|
||||
return {
|
||||
status: 200,
|
||||
message: '',
|
||||
source: 'accounting',
|
||||
at: accounting.at,
|
||||
result: [
|
||||
{ arz: 'IRR', mandeh: accounting.rial.toFixed(2) },
|
||||
{ arz: 'gold', mandeh: accounting.gold.toFixed(3) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const c = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { goldDebtGrams: true, fiatDebtRial: true },
|
||||
@@ -242,3 +311,82 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
WALLET_BONUS: 'هدیه',
|
||||
ADJUSTMENT: 'اصلاحیه',
|
||||
};
|
||||
|
||||
// ── D27: accounting statement → the report's existing shape ─────────────
|
||||
//
|
||||
// The apps bind the reference's field names (henza_pwa/src/type/accounting.ts)
|
||||
// and format `tarikh` as an ISO instant, so the accounting rows are mapped onto
|
||||
// exactly what the ledger-based report already returns.
|
||||
|
||||
/** `1405/07/12` + `20:20:41` → ISO in Tehran time, which the apps' jdate() reads. */
|
||||
function isoFromJalali(date: string, time: string | null): string {
|
||||
const j = parseJalali(date);
|
||||
if (!j) return date;
|
||||
const g = jalaliToGregorian(j.jy, j.jm, j.jd);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const t = time && /^\d{1,2}:\d{2}(:\d{2})?$/.test(time) ? (time.length === 5 ? `${time}:00` : time) : '12:00:00';
|
||||
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}T${t.padStart(8, '0')}+03:30`;
|
||||
}
|
||||
|
||||
const fixed = (d: Prisma.Decimal | null, places: number) => (d ?? new Prisma.Decimal(0)).toFixed(places);
|
||||
|
||||
/**
|
||||
* Oldest first, with a running balance on every row. The software's own
|
||||
* running balance wins where it reports one; a row without one (a temporary
|
||||
* Tahesab document, which moves nothing yet) carries the previous figure.
|
||||
*/
|
||||
function statementToSummary(rows: StatementRow[]) {
|
||||
let gold = new Prisma.Decimal(0);
|
||||
let rial = new Prisma.Decimal(0);
|
||||
return rows.map((r, index) => {
|
||||
if (r.goldBalance) gold = r.goldBalance;
|
||||
else if (!r.temporary) gold = gold.add(r.goldOut).sub(r.goldIn);
|
||||
if (r.rialBalance) rial = r.rialBalance;
|
||||
else if (!r.temporary) rial = rial.add(r.rialOut).sub(r.rialIn);
|
||||
return {
|
||||
id: index + 1,
|
||||
factor_Code: r.documentRef || r.key,
|
||||
sh_factor: r.documentNo,
|
||||
tarikh: isoFromJalali(r.date, r.time),
|
||||
geram_v: fixed(r.goldIn, 3),
|
||||
geram_kh: fixed(r.goldOut, 3),
|
||||
rial_v: fixed(r.rialIn, 2),
|
||||
rial_kh: fixed(r.rialOut, 2),
|
||||
tahesab_T: gold.toFixed(3),
|
||||
tahesab_P: rial.toFixed(2),
|
||||
rial_V2: '0.00',
|
||||
rial_Kh2: '0.00',
|
||||
tahesabP2: '0.00',
|
||||
sourceType: 'ACCOUNTING',
|
||||
note: [r.title, r.description, r.temporary ? '(موقت — هنوز در مانده اثر ندارد)' : null].filter(Boolean).join(' — '),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function statementToDetail(r: StatementRow) {
|
||||
return {
|
||||
factor_Code: r.documentRef || r.key,
|
||||
sh_factor: r.documentNo,
|
||||
tarikh: isoFromJalali(r.date, r.time),
|
||||
vazn: fixed(r.weight, 3),
|
||||
ayar: r.karat,
|
||||
no: r.title,
|
||||
sh_sharti: null,
|
||||
name_az: null,
|
||||
tabdil750_v: fixed(r.goldIn, 3),
|
||||
tabdil750_kh: fixed(r.goldOut, 3),
|
||||
fi: r.unitPrice ? r.unitPrice.toFixed(2) : null,
|
||||
fiF: r.rialIn.gt(0) ? r.rialIn.toFixed(2) : r.rialOut.gt(0) ? r.rialOut.toFixed(2) : null,
|
||||
Darsad_Talayee: r.wagePercent ? r.wagePercent.toString() : null,
|
||||
accountName: null,
|
||||
bankName: null,
|
||||
transferTo: null,
|
||||
receivedAt: null,
|
||||
note: [r.description, r.temporary ? 'سند موقت' : null].filter(Boolean).join(' — ') || null,
|
||||
// REP-01: the accounting software does carry these.
|
||||
nO_kar: r.itemName,
|
||||
jens_felez: r.metal,
|
||||
Darsad: null,
|
||||
maliatDarsad: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
|
||||
|
||||
// passwordHash must never leave the API — every customer read goes through this.
|
||||
const PUBLIC_CUSTOMER_SELECT = {
|
||||
@@ -35,7 +36,10 @@ const PUBLIC_CUSTOMER_SELECT = {
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accounting: AccountingSyncService,
|
||||
) {}
|
||||
|
||||
async getOrThrow(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
@@ -43,7 +47,21 @@ export class CustomersService {
|
||||
select: PUBLIC_CUSTOMER_SELECT,
|
||||
});
|
||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
return customer;
|
||||
|
||||
// D27 — with the accounting integration on, the customer sees the balance
|
||||
// the accounting software holds. The apps only display these two fields;
|
||||
// the credit-limit check runs server-side on the internal ledger, untouched.
|
||||
const accounting = await this.accounting.customerBalance(customerId);
|
||||
if (accounting) {
|
||||
return {
|
||||
...customer,
|
||||
goldDebtGrams: accounting.gold,
|
||||
fiatDebtRial: accounting.rial,
|
||||
balanceSource: 'accounting' as const,
|
||||
balanceAt: accounting.at,
|
||||
};
|
||||
}
|
||||
return { ...customer, balanceSource: 'app' as const, balanceAt: null };
|
||||
}
|
||||
|
||||
getOrders(customerId: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
|
||||
interface ComputedItem {
|
||||
@@ -23,6 +24,7 @@ export class OrdersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalog: CatalogService,
|
||||
private readonly ledger: LedgerService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -121,6 +123,9 @@ export class OrdersService {
|
||||
include: { items: true, statusEvents: true },
|
||||
});
|
||||
|
||||
// D27 — the document rule may say «at order placement».
|
||||
await this.accounting.orderStatusReached(tx, order.id, initialStatus);
|
||||
|
||||
return order;
|
||||
});
|
||||
}
|
||||
@@ -151,6 +156,14 @@ export class OrdersService {
|
||||
await this.ledger.postOrderDebt(orderId, tx);
|
||||
}
|
||||
|
||||
// D27 — the accounting document (پیشفاکتور) follows the owner's rule on
|
||||
// which status posts it, queued in this same transaction.
|
||||
if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) {
|
||||
await this.accounting.orderCancelled(tx, orderId);
|
||||
} else if (newStatus !== order.status) {
|
||||
await this.accounting.orderStatusReached(tx, orderId, newStatus);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { relayDriver } from './relay.driver';
|
||||
import { SmsDriver } from './sms-driver';
|
||||
import { trezDriver } from './trez.driver';
|
||||
|
||||
/**
|
||||
* Every SMS gateway, keyed by its `SMS_PROVIDER` value. A new provider is one
|
||||
* driver file plus one line here.
|
||||
*/
|
||||
export const SMS_DRIVERS: Readonly<Record<string, SmsDriver>> = {
|
||||
[relayDriver.name]: relayDriver,
|
||||
[trezDriver.name]: trezDriver,
|
||||
};
|
||||
|
||||
/** Used when `SMS_PROVIDER` is unset or blank (D26). */
|
||||
export const DEFAULT_SMS_PROVIDER = relayDriver.name;
|
||||
|
||||
export type { SmsDriver, SmsSendResult } from './sms-driver';
|
||||
@@ -0,0 +1,110 @@
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import { envOr, envOrNull } from '../../../common/env';
|
||||
import { SmsDriver, postWithTimeout } from './sms-driver';
|
||||
|
||||
/**
|
||||
* The owner's SMS gateway (`/api/createsms`) — a relay that serves the SMS of
|
||||
* several panels and apps and forwards to the actual provider. The default
|
||||
* driver since D26.
|
||||
*
|
||||
* Shape, from the owner's PHP client:
|
||||
* * POST JSON, 15s timeout, auth header `secret_key`
|
||||
* * body: { phone, host, text, platform }
|
||||
* * `host` identifies the sending server: a configured value, else the
|
||||
* server's own IP (`gethostbyname(gethostname())`)
|
||||
* * `platform: "kave"` routes through Kavenegar; null lets the gateway pick.
|
||||
* The owner wants every message through Kavenegar.
|
||||
*
|
||||
* The secret is in source on the owner's decision (D26): the gateway has a
|
||||
* second verification factor held elsewhere. `SMS_RELAY_SECRET` overrides it.
|
||||
*
|
||||
* The PHP client forced IPv4 (CURLOPT_IPRESOLVE); the URL is an IP literal, so
|
||||
* there is nothing to resolve and fetch needs no equivalent.
|
||||
*/
|
||||
|
||||
const DEFAULT_RELAY_URL = 'http://185.231.112.101/api/createsms';
|
||||
const DEFAULT_RELAY_SECRET = 'f21debfb-83ac-4624-a2f4-f08375043181';
|
||||
const DEFAULT_RELAY_PLATFORM = 'kave';
|
||||
|
||||
/** `Http::timeout(15)` in the owner's client. */
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** The first external IPv4 of this machine — Node's `gethostbyname(gethostname())`. */
|
||||
export function serverIpv4(): string | null {
|
||||
for (const addresses of Object.values(networkInterfaces())) {
|
||||
for (const a of addresses ?? []) {
|
||||
if (a.family === 'IPv4' && !a.internal) return a.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function relayConfig() {
|
||||
const platform = envOr('SMS_RELAY_PLATFORM', DEFAULT_RELAY_PLATFORM);
|
||||
return {
|
||||
url: envOr('SMS_RELAY_URL', DEFAULT_RELAY_URL),
|
||||
secret: envOr('SMS_RELAY_SECRET', DEFAULT_RELAY_SECRET),
|
||||
host: envOrNull('SMS_RELAY_HOST') ?? serverIpv4(),
|
||||
// "default" sends null, so the gateway chooses the line itself.
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway's reply format is not documented, so a 2xx is trusted unless the
|
||||
* body plainly says otherwise. Returns the reason, or null when the reply
|
||||
* reads as accepted.
|
||||
*
|
||||
* A non-empty body that is not JSON counts as a failure: the gateway is a JSON
|
||||
* API, and its own PHP errors arrive as bare text with HTTP 200 — seen live on
|
||||
* 2026-10-05 as "cURL library is not loaded", with nothing delivered.
|
||||
*/
|
||||
export function relayRejection(body: string): string | null {
|
||||
const text = body.trim();
|
||||
if (text === '') return null;
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return text.slice(0, 200);
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
||||
|
||||
const d = data as Record<string, unknown>;
|
||||
const failedStatus =
|
||||
d.status === false ||
|
||||
(typeof d.status === 'string' && /^(error|fail|failed|failure)$/i.test(d.status));
|
||||
const failed = failedStatus || d.success === false || d.ok === false || Boolean(d.error);
|
||||
if (!failed) return null;
|
||||
|
||||
if (typeof d.error === 'string' && d.error) return d.error;
|
||||
if (typeof d.message === 'string' && d.message) return d.message;
|
||||
return 'rejected-by-gateway';
|
||||
}
|
||||
|
||||
export const relayDriver: SmsDriver = {
|
||||
name: 'relay',
|
||||
|
||||
missingConfig() {
|
||||
// The URL and secret always have defaults; only the host can be missing,
|
||||
// on a machine with no external IPv4.
|
||||
return relayConfig().host ? [] : ['SMS_RELAY_HOST'];
|
||||
},
|
||||
|
||||
async deliver(mobile, text) {
|
||||
const c = relayConfig();
|
||||
const reply = await postWithTimeout(
|
||||
c.url,
|
||||
{ 'Content-Type': 'application/json', secret_key: c.secret },
|
||||
JSON.stringify({ phone: mobile, host: c.host, text }),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` };
|
||||
|
||||
const rejection = relayRejection(reply.body);
|
||||
if (rejection) return { ok: false, response: reply.body, error: rejection };
|
||||
|
||||
return { ok: true, response: reply.body };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* One SMS gateway. SmsService picks a driver by `SMS_PROVIDER` and owns
|
||||
* everything around the call — trimming, persistence, logging and the
|
||||
* never-throw guarantee — so a driver only has to talk to its provider.
|
||||
*/
|
||||
|
||||
export interface SmsSendResult {
|
||||
ok: boolean;
|
||||
/** Provider response body, when one was received. */
|
||||
response?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SmsDriver {
|
||||
/** The `SMS_PROVIDER` value that selects this driver. */
|
||||
readonly name: string;
|
||||
|
||||
/** Names of the env variables this driver still needs; empty when it can send. */
|
||||
missingConfig(): string[];
|
||||
|
||||
/**
|
||||
* Sends one already-trimmed message. May throw on a network error or
|
||||
* timeout — SmsService turns that into `{ ok: false }`.
|
||||
*/
|
||||
deliver(mobile: string, text: string): Promise<SmsSendResult>;
|
||||
}
|
||||
|
||||
export interface HttpReply {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** POST with an abort-based timeout. Throws on network failure or timeout. */
|
||||
export async function postWithTimeout(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
timeoutMs: number,
|
||||
): Promise<HttpReply> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return { ok: response.ok, status: response.status, body: await response.text() };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { envOr } from '../../../common/env';
|
||||
import { SmsDriver, postWithTimeout } from './sms-driver';
|
||||
|
||||
/**
|
||||
* Trez panel — port of henza_server/app/Models/Sms.php.
|
||||
*
|
||||
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
|
||||
* * POST, form-urlencoded, 20s timeout
|
||||
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
|
||||
*
|
||||
* Credentials come from the environment. The reference hardcodes live
|
||||
* production credentials in the source (SEC-12); they are not copied here.
|
||||
*
|
||||
* No longer the default since D26, kept selectable with `SMS_PROVIDER=trez`.
|
||||
*/
|
||||
|
||||
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
|
||||
export const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
|
||||
|
||||
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function trezConfig() {
|
||||
return {
|
||||
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
|
||||
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||||
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||||
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export const trezDriver: SmsDriver = {
|
||||
name: 'trez',
|
||||
|
||||
missingConfig() {
|
||||
const c = trezConfig();
|
||||
return (
|
||||
[
|
||||
['SMS_TREZ_USERNAME', c.username],
|
||||
['SMS_TREZ_PASSWORD', c.password],
|
||||
['SMS_TREZ_SENDER_LINE', c.senderLine],
|
||||
] as const
|
||||
)
|
||||
.filter(([, v]) => !v)
|
||||
.map(([name]) => name);
|
||||
},
|
||||
|
||||
async deliver(mobile, text) {
|
||||
const c = trezConfig();
|
||||
const reply = await postWithTimeout(
|
||||
c.url,
|
||||
{ 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
new URLSearchParams({
|
||||
PhoneNumber: c.senderLine,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Mobile: mobile,
|
||||
Message: text,
|
||||
}).toString(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` };
|
||||
return { ok: true, response: reply.body };
|
||||
},
|
||||
};
|
||||
@@ -1,125 +1,308 @@
|
||||
import { NetworkInterfaceInfo, networkInterfaces } from 'node:os';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { assertSmsConfigured, smsConfig, smsConfigured } from './sms.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { DEFAULT_TREZ_URL, trezConfig } from './drivers/trez.driver';
|
||||
import { relayConfig, relayRejection } from './drivers/relay.driver';
|
||||
import {
|
||||
SmsService,
|
||||
assertSmsConfigured,
|
||||
smsConfigured,
|
||||
smsProvider,
|
||||
} from './sms.service';
|
||||
|
||||
const SMS_ENV = [
|
||||
'SMS_PROVIDER',
|
||||
'SMS_RELAY_URL',
|
||||
'SMS_RELAY_SECRET',
|
||||
'SMS_RELAY_HOST',
|
||||
'SMS_RELAY_PLATFORM',
|
||||
'SMS_TREZ_URL',
|
||||
'SMS_TREZ_USERNAME',
|
||||
'SMS_TREZ_PASSWORD',
|
||||
'SMS_TREZ_SENDER_LINE',
|
||||
'NODE_ENV',
|
||||
];
|
||||
|
||||
// Node's own module namespace cannot be spied on, so the lookup is mocked.
|
||||
jest.mock('node:os', () => ({ ...jest.requireActual('node:os'), networkInterfaces: jest.fn() }));
|
||||
const interfaces = networkInterfaces as jest.MockedFunction<typeof networkInterfaces>;
|
||||
|
||||
const iface = (address: string, internal: boolean) =>
|
||||
({ address, internal, family: 'IPv4' }) as NetworkInterfaceInfo;
|
||||
|
||||
/** A machine with only loopback, so the relay has no host to report. */
|
||||
const noExternalIp = () => interfaces.mockReturnValue({ lo: [iface('127.0.0.1', true)] });
|
||||
|
||||
const saved = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of SMS_ENV) delete process.env[key];
|
||||
jest.restoreAllMocks();
|
||||
interfaces.mockReturnValue({ eth0: [iface('10.9.9.9', false)] });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
/**
|
||||
* Covers the boot-time contract as REVISED by D20:
|
||||
* * while no key is set, development behaviour is unchanged;
|
||||
* Covers the boot-time contract as REVISED by D20 and the driver choice of D26:
|
||||
* * a missing key NEVER stops the boot, in any environment — it warns, and
|
||||
* sending silently declines. (D14's "fail to boot in production" rule was
|
||||
* removed by the owner for the delivery.)
|
||||
* sending silently declines;
|
||||
* * `relay` is the default provider; `trez` stays selectable.
|
||||
*/
|
||||
describe('SMS configuration', () => {
|
||||
const saved = { ...process.env };
|
||||
// Silence the warnings these tests deliberately provoke.
|
||||
const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger;
|
||||
const warned = () => (logger.warn as jest.Mock).mock.calls.flat().join(' ');
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [
|
||||
'SMS_PROVIDER',
|
||||
'SMS_TREZ_URL',
|
||||
'SMS_TREZ_USERNAME',
|
||||
'SMS_TREZ_PASSWORD',
|
||||
'SMS_TREZ_SENDER_LINE',
|
||||
'NODE_ENV',
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
afterAll(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
const configureFully = () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
};
|
||||
|
||||
describe('smsConfig', () => {
|
||||
it('defaults the provider to trez and the URL to the reference endpoint', () => {
|
||||
const c = smsConfig();
|
||||
expect(c.provider).toBe('trez');
|
||||
expect(c.url).toBe('https://smspanel.trez.ir/SendMessageWithCode.ashx');
|
||||
describe('provider selection (D26)', () => {
|
||||
it('defaults to relay', () => {
|
||||
expect(smsProvider()).toBe('relay');
|
||||
});
|
||||
|
||||
it('lets the URL be overridden for staging', () => {
|
||||
process.env.SMS_TREZ_URL = 'https://staging.example/send';
|
||||
expect(smsConfig().url).toBe('https://staging.example/send');
|
||||
it('treats a blank SMS_PROVIDER, as docker-compose passes it, as the default', () => {
|
||||
process.env.SMS_PROVIDER = '';
|
||||
expect(smsProvider()).toBe('relay');
|
||||
});
|
||||
|
||||
it('still selects trez on request', () => {
|
||||
process.env.SMS_PROVIDER = 'Trez';
|
||||
expect(smsProvider()).toBe('trez');
|
||||
});
|
||||
});
|
||||
|
||||
describe('smsConfigured', () => {
|
||||
it('is false when nothing is set', () => {
|
||||
expect(smsConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when only some credentials are set', () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once all three are set', () => {
|
||||
configureFully();
|
||||
describe('relay', () => {
|
||||
it('works out of the box, sending through Kavenegar', () => {
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
const c = relayConfig();
|
||||
expect(c.url).toBe('http://185.231.112.101/api/createsms');
|
||||
expect(c.secret).toBeTruthy();
|
||||
expect(c.platform).toBe('kave');
|
||||
expect(smsConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the provider is explicitly disabled', () => {
|
||||
configureFully();
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
it('falls back to the server IP for host, skipping loopback', () => {
|
||||
interfaces.mockReturnValue({
|
||||
lo: [iface('127.0.0.1', true)],
|
||||
eth0: [iface('192.168.1.20', false)],
|
||||
});
|
||||
expect(relayConfig().host).toBe('192.168.1.20');
|
||||
});
|
||||
|
||||
it('lets SMS_RELAY_HOST override the server IP', () => {
|
||||
process.env.SMS_RELAY_HOST = 'gold.example';
|
||||
expect(relayConfig().host).toBe('gold.example');
|
||||
});
|
||||
|
||||
it('sends platform null when SMS_RELAY_PLATFORM=default', () => {
|
||||
process.env.SMS_RELAY_PLATFORM = 'default';
|
||||
expect(relayConfig().platform).toBeNull();
|
||||
});
|
||||
|
||||
it('is not configured, and warns by name, when no host can be found', () => {
|
||||
noExternalIp();
|
||||
expect(smsConfigured()).toBe(false);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(warned()).toMatch(/SMS_RELAY_HOST/);
|
||||
});
|
||||
|
||||
it('passes silently once a host is known', () => {
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
assertSmsConfigured(logger);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* D20 REVERSED the original boot contract. SMS must never stop the server
|
||||
* from starting, in any environment — an earlier revision of these tests
|
||||
* asserted the opposite (a throw in production) and is intentionally gone.
|
||||
*/
|
||||
describe('assertSmsConfigured (D20 — never throws)', () => {
|
||||
it('warns outside production when keys are missing', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
describe('trez', () => {
|
||||
beforeEach(() => {
|
||||
process.env.SMS_PROVIDER = 'trez';
|
||||
});
|
||||
|
||||
const configureFully = () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
};
|
||||
|
||||
it('defaults the URL to the reference endpoint and lets staging override it', () => {
|
||||
expect(trezConfig().url).toBe(DEFAULT_TREZ_URL);
|
||||
process.env.SMS_TREZ_URL = 'https://staging.example/send';
|
||||
expect(trezConfig().url).toBe('https://staging.example/send');
|
||||
});
|
||||
|
||||
it('is configured only once all three credentials are set', () => {
|
||||
expect(smsConfigured()).toBe(false);
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
expect(smsConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('warns in production when keys are missing, naming them, and still boots', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
const warned = (logger.warn as jest.Mock).mock.calls.flat().join(' ');
|
||||
expect(warned).toMatch(/SMS_TREZ_USERNAME/);
|
||||
expect(warned).toMatch(/SMS_TREZ_PASSWORD/);
|
||||
expect(warned).toMatch(/SMS_TREZ_SENDER_LINE/);
|
||||
expect(warned()).toMatch(/SMS_TREZ_USERNAME/);
|
||||
expect(warned()).toMatch(/SMS_TREZ_PASSWORD/);
|
||||
expect(warned()).toMatch(/SMS_TREZ_SENDER_LINE/);
|
||||
});
|
||||
|
||||
it('passes silently in production once everything is set', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
configureFully();
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
assertSmsConfigured(logger);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows SMS to be explicitly disabled in production', () => {
|
||||
describe('none and unknown providers (D20 — never throws)', () => {
|
||||
it('allows SMS to be explicitly disabled, in production too', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows SMS to be disabled outside production', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
});
|
||||
|
||||
it('warns about an unknown provider name instead of refusing to boot', () => {
|
||||
process.env.SMS_PROVIDER = 'kavenegar';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect((logger.warn as jest.Mock).mock.calls.flat().join(' ')).toMatch(
|
||||
/Unknown SMS_PROVIDER/,
|
||||
);
|
||||
expect(warned()).toMatch(/Unknown SMS_PROVIDER/);
|
||||
expect(warned()).toMatch(/"relay", "trez", "none"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('relayRejection', () => {
|
||||
it('accepts empty and neutral JSON bodies', () => {
|
||||
expect(relayRejection('')).toBeNull();
|
||||
expect(relayRejection('{"status":true,"id":12}')).toBeNull();
|
||||
expect(relayRejection('{"status":"success"}')).toBeNull();
|
||||
expect(relayRejection('[1,2]')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects bodies that say they failed', () => {
|
||||
expect(relayRejection('{"status":false}')).toBe('rejected-by-gateway');
|
||||
expect(relayRejection('{"success":false,"message":"bad phone"}')).toBe('bad phone');
|
||||
expect(relayRejection('{"status":"error"}')).toBe('rejected-by-gateway');
|
||||
expect(relayRejection('{"error":"invalid key"}')).toBe('invalid key');
|
||||
});
|
||||
|
||||
it('rejects a bare-text body — the gateway reports its own PHP errors that way', () => {
|
||||
expect(relayRejection('cURL library is not loaded')).toBe('cURL library is not loaded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SmsService.send', () => {
|
||||
const create = jest.fn();
|
||||
const service = new SmsService({ smsMessage: { create } } as unknown as PrismaService);
|
||||
let fetchMock: jest.SpyInstance;
|
||||
|
||||
const reply = (status: number, body: string) =>
|
||||
fetchMock.mockResolvedValue(new Response(body, { status }));
|
||||
|
||||
beforeEach(() => {
|
||||
create.mockReset().mockResolvedValue({});
|
||||
fetchMock = jest.spyOn(globalThis, 'fetch');
|
||||
// Keep the deliberate failure logs out of the test output.
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
});
|
||||
|
||||
it('posts the shape of the owner PHP client to the relay', async () => {
|
||||
reply(200, '{"status":true}');
|
||||
|
||||
const result = await service.send('09120000000', ' سلام ');
|
||||
|
||||
expect(result).toEqual({ ok: true, response: '{"status":true}' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://185.231.112.101/api/createsms');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
secret_key: relayConfig().secret,
|
||||
});
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
phone: '09120000000',
|
||||
host: '10.0.0.5',
|
||||
text: 'سلام',
|
||||
platform: 'kave',
|
||||
});
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ recipients: '09120000000', body: 'سلام' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an HTTP error without throwing, and still records the message', async () => {
|
||||
reply(500, 'boom');
|
||||
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
||||
ok: false,
|
||||
response: 'boom',
|
||||
error: 'http-500',
|
||||
});
|
||||
expect(create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a 200 that the gateway marks as failed', async () => {
|
||||
reply(200, '{"status":false,"message":"no credit"}');
|
||||
const result = await service.send('09120000000', 'x');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('no credit');
|
||||
});
|
||||
|
||||
it('turns a network failure into ok:false', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'ECONNREFUSED',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the gateway bare-text PHP error, as seen live', async () => {
|
||||
reply(200, 'cURL library is not loaded');
|
||||
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
||||
ok: false,
|
||||
response: 'cURL library is not loaded',
|
||||
error: 'cURL library is not loaded',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours persist:false', async () => {
|
||||
reply(200, '{}');
|
||||
await service.send('09120000000', 'x', { persist: false });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends nothing when the provider is disabled', async () => {
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
const result = await service.send('09120000000', 'x');
|
||||
expect(result).toEqual({ ok: false, error: 'sms-not-configured' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still drives trez when selected', async () => {
|
||||
process.env.SMS_PROVIDER = 'trez';
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
reply(200, '1');
|
||||
|
||||
await service.send('09120000000', 'x');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(DEFAULT_TREZ_URL);
|
||||
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
|
||||
PhoneNumber: '9998882797',
|
||||
Username: 'u',
|
||||
Password: 'p',
|
||||
Mobile: '09120000000',
|
||||
Message: 'x',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,22 +2,22 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MessageAudience } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers';
|
||||
|
||||
/**
|
||||
* SMS delivery — port of henza_server/app/Models/Sms.php.
|
||||
* SMS delivery — port of henza_server/app/Models/Sms.php, with the gateway
|
||||
* pulled out into drivers (`./drivers`) selected by `SMS_PROVIDER`:
|
||||
* * `relay` (default, D26) — the owner's createsms gateway
|
||||
* * `trez` — the reference's Trez panel
|
||||
* * `none` — send nothing
|
||||
*
|
||||
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
|
||||
* * POST, form-urlencoded, to the Trez panel, 20s timeout
|
||||
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
|
||||
* * the message is trim()ed before sending
|
||||
* Reference behaviour kept for every driver:
|
||||
* * the message is trim()ed before sending (`Sms.php:77`)
|
||||
* * EVERY message is persisted, via `Sms::set()` (`:80`)
|
||||
* * the response body is returned and the callers ignore it
|
||||
*
|
||||
* Two things differ, both deliberate:
|
||||
* 1. Credentials come from the environment. The reference hardcodes live
|
||||
* production credentials in the source (SEC-12); they are not copied here.
|
||||
* 2. The OTP message body is NOT persisted in readable form — see
|
||||
* CustomerAuthService.deliver() and SEC-11.
|
||||
* The OTP message body is NOT persisted in readable form — see
|
||||
* CustomerAuthService.deliver() and SEC-11.
|
||||
*
|
||||
* Failure handling matches the reference: `Sms::Send` is called inline and its
|
||||
* result is discarded, so a failed send never aborted the order, the document
|
||||
@@ -25,18 +25,7 @@ import { envOr } from '../../common/env';
|
||||
* never throws — it logs and reports failure in its return value.
|
||||
*/
|
||||
|
||||
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
|
||||
const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
|
||||
|
||||
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
export interface SmsSendResult {
|
||||
ok: boolean;
|
||||
/** Provider response body, when one was received. */
|
||||
response?: string;
|
||||
error?: string;
|
||||
}
|
||||
export type { SmsSendResult } from './drivers';
|
||||
|
||||
export interface SmsSendOptions {
|
||||
/**
|
||||
@@ -47,15 +36,14 @@ export interface SmsSendOptions {
|
||||
audience?: MessageAudience;
|
||||
}
|
||||
|
||||
/** Reads the provider config. Exported so main.ts can validate it at boot. */
|
||||
export function smsConfig() {
|
||||
return {
|
||||
provider: envOr('SMS_PROVIDER', 'trez').toLowerCase(),
|
||||
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
|
||||
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||||
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||||
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||||
};
|
||||
/** The selected `SMS_PROVIDER`, lower-cased. */
|
||||
export function smsProvider(): string {
|
||||
return envOr('SMS_PROVIDER', DEFAULT_SMS_PROVIDER).toLowerCase();
|
||||
}
|
||||
|
||||
/** The selected driver, or null for `none` and unknown names. */
|
||||
export function smsDriver(): SmsDriver | null {
|
||||
return SMS_DRIVERS[smsProvider()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,9 +51,8 @@ export function smsConfig() {
|
||||
* development mode: nothing is sent, and OTP codes are logged locally.
|
||||
*/
|
||||
export function smsConfigured(): boolean {
|
||||
const c = smsConfig();
|
||||
if (c.provider === 'none') return false;
|
||||
return Boolean(c.username && c.password && c.senderLine);
|
||||
const driver = smsDriver();
|
||||
return driver !== null && driver.missingConfig().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,35 +67,30 @@ export function smsConfigured(): boolean {
|
||||
* login code, which is why `OTP_MASTER_CODE` exists for the delivery.
|
||||
*/
|
||||
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
|
||||
const c = smsConfig();
|
||||
const provider = smsProvider();
|
||||
|
||||
if (c.provider === 'none') {
|
||||
if (provider === 'none') {
|
||||
logger.warn('SMS_PROVIDER=none — no SMS will be sent.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (c.provider !== 'trez') {
|
||||
const driver = smsDriver();
|
||||
if (!driver) {
|
||||
const supported = [...Object.keys(SMS_DRIVERS), 'none'].map((n) => `"${n}"`).join(', ');
|
||||
logger.warn(
|
||||
`Unknown SMS_PROVIDER "${c.provider}" (supported: "trez", "none"). ` +
|
||||
'No SMS will be sent.',
|
||||
`Unknown SMS_PROVIDER "${provider}" (supported: ${supported}). No SMS will be sent.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = (
|
||||
[
|
||||
['SMS_TREZ_USERNAME', c.username],
|
||||
['SMS_TREZ_PASSWORD', c.password],
|
||||
['SMS_TREZ_SENDER_LINE', c.senderLine],
|
||||
] as const
|
||||
)
|
||||
.filter(([, v]) => !v)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (missing.length === 0) return;
|
||||
const missing = driver.missingConfig();
|
||||
if (missing.length === 0) {
|
||||
logger.log(`SMS provider: ${driver.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`SMS provider "trez" is selected but these are not set: ${missing.join(', ')}. ` +
|
||||
`SMS provider "${driver.name}" is selected but these are not set: ${missing.join(', ')}. ` +
|
||||
'Messages are logged, not sent. Customers cannot receive a login code.',
|
||||
);
|
||||
}
|
||||
@@ -128,14 +110,8 @@ export class SmsService {
|
||||
// The reference trims before sending (`Sms.php:77`).
|
||||
const text = body.trim();
|
||||
|
||||
let result: SmsSendResult;
|
||||
|
||||
if (!smsConfigured()) {
|
||||
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
|
||||
result = { ok: false, error: 'sms-not-configured' };
|
||||
} else {
|
||||
result = await this.deliverViaTrez(mobile, text);
|
||||
}
|
||||
console.log("mobile ==>" ,mobile)
|
||||
const result = await this.deliver(mobile, text);
|
||||
|
||||
if (persist) {
|
||||
// `Sms::set()` records every message, delivered or not.
|
||||
@@ -145,42 +121,26 @@ export class SmsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** POST to the Trez panel, shaped exactly as the reference's form post. */
|
||||
private async deliverViaTrez(mobile: string, text: string): Promise<SmsSendResult> {
|
||||
const c = smsConfig();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(c.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
PhoneNumber: c.senderLine,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Mobile: mobile,
|
||||
Message: text,
|
||||
}).toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
// Logged, not thrown — see the class comment.
|
||||
this.logger.error(`SMS to ${mobile} failed: HTTP ${response.status} ${responseBody}`);
|
||||
return { ok: false, response: responseBody, error: `http-${response.status}` };
|
||||
}
|
||||
|
||||
return { ok: true, response: responseBody };
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`SMS to ${mobile} failed: ${error}`);
|
||||
return { ok: false, error };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
private async deliver(mobile: string, text: string): Promise<SmsSendResult> {
|
||||
const driver = smsDriver();
|
||||
if (!driver || driver.missingConfig().length > 0) {
|
||||
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
|
||||
return { ok: false, error: 'sms-not-configured' };
|
||||
}
|
||||
|
||||
let result: SmsSendResult;
|
||||
try {
|
||||
result = await driver.deliver(mobile, text);
|
||||
} catch (e) {
|
||||
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
// Logged, not thrown — see the class comment.
|
||||
const detail = result.response ? ` ${result.response}` : '';
|
||||
this.logger.error(`SMS to ${mobile} via ${driver.name} failed: ${result.error}${detail}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The `messages` row the reference writes for every send (`Sms.php:120-129`). */
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d,f as i,c as _,i as r,b as e,_ as h,u as v,p as o,z as b,B as k,F as f,r as w,m as n,t as u,e as x,I as c,N as g}from"./index-BWyw3RAx.js";import{P as y}from"./PageBar-D5DXU6sO.js";const C=d({__name:"ToggleSwitch",props:{on:{type:Boolean}},setup(p){return(a,l)=>(i(),_("span",{class:r(["tsw",{"is-on":p.on}]),"aria-hidden":"true"},[...l[0]||(l[0]=[e("span",{class:"tsw__knob"},null,-1)])],2))}}),B=h(C,[["__scopeId","data-v-3f7a6b1f"]]),T={class:"ap"},S={class:"ap__section"},V={class:"ap__themes",role:"radiogroup","aria-labelledby":"ap-theme"},z=["aria-checked","onClick"],I={class:"ap__themeLabel"},L={class:"ap__check","aria-hidden":"true"},M={key:0,class:"ap__note"},N={class:"ap__section"},$=["aria-checked"],A={class:"ap__rowIcon","aria-hidden":"true"},E=d({__name:"AppearanceView",setup(p){const a=v(),l=[{value:"light",label:"روشن",icon:"vuesax-linear:sun-1"},{value:"dark",label:"تیره",icon:"vuesax-linear:moon"},{value:"system",label:"مطابق سیستم",icon:"vuesax-linear:monitor-mobbile"}];return(P,s)=>{const m=k("container");return i(),_("div",T,[o(y,{title:"تنظیمات ظاهری","back-to":"userPage::hasSubRoute"}),o(m,{class:"ap__body"},{default:b(()=>[e("section",S,[s[2]||(s[2]=e("header",{class:"ap__head"},[e("h2",{id:"ap-theme",class:"ap__title"},"پوسته برنامه"),e("p",{class:"ap__sub"},"ظاهر روشن یا تیره، یا هماهنگ با تنظیمات گوشی شما")],-1)),e("div",V,[(i(),_(f,null,w(l,t=>e("button",{key:t.value,type:"button",role:"radio",class:r(["ap__theme",{"is-on":n(a).theme===t.value}]),"aria-checked":n(a).theme===t.value,onClick:F=>n(a).setTheme(t.value)},[e("span",{class:r(["ap__preview",`ap__preview--${t.value}`]),"aria-hidden":"true"},[...s[1]||(s[1]=[e("span",{class:"ap__pvBar"},null,-1),e("span",{class:"ap__pvCard"},null,-1),e("span",{class:"ap__pvLine"},null,-1),e("span",{class:"ap__pvLine ap__pvLine--short"},null,-1),e("span",{class:"ap__pvBtn"},null,-1)])],2),e("span",I,[o(c,{icon:t.icon,size:"15"},null,8,["icon"]),g(" "+u(t.label),1)]),e("span",L,[o(c,{icon:"vuesax-linear:tick-circle",size:"18"})])],10,z)),64))]),n(a).theme==="system"?(i(),_("p",M," الان "+u(n(a).resolvedTheme==="dark"?"تیره":"روشن")+" است، مطابق تنظیمات گوشی. ",1)):x("",!0)]),e("section",N,[s[4]||(s[4]=e("header",{class:"ap__head"},[e("h2",{class:"ap__title"},"انیمیشنها"),e("p",{class:"ap__sub"},"برای سرعت بیشتر روی گوشیهای ضعیفتر")],-1)),e("button",{type:"button",role:"switch",class:"ap__row","aria-checked":n(a).reduceMotion,onClick:s[0]||(s[0]=t=>n(a).setReduceMotion(!n(a).reduceMotion))},[e("span",A,[o(c,{icon:"vuesax-linear:flash",size:"18"})]),s[3]||(s[3]=e("span",{class:"ap__rowText"},[e("b",null,"حالت سبک و سریع"),e("small",null," حرکتهای سنگین، افکتهای شیشهای و اسلاید خودکار خاموش میشوند؛ بازخورد کوتاه لمسها و جابهجاییهای سریع باقی میمانند تا برنامه خشک و بیروح نشود. ")],-1)),o(B,{on:n(a).reduceMotion},null,8,["on"])],8,$)])]),_:1})])}}}),H=h(E,[["__scopeId","data-v-d05eb581"]]);export{H as default};
|
||||
import{d,f as i,c as _,i as r,b as e,_ as h,u as v,p as o,z as b,B as k,F as f,r as w,m as n,t as u,e as x,I as c,N as g}from"./index-Dp9sxvBv.js";import{P as y}from"./PageBar-El-laHqK.js";const C=d({__name:"ToggleSwitch",props:{on:{type:Boolean}},setup(p){return(a,l)=>(i(),_("span",{class:r(["tsw",{"is-on":p.on}]),"aria-hidden":"true"},[...l[0]||(l[0]=[e("span",{class:"tsw__knob"},null,-1)])],2))}}),B=h(C,[["__scopeId","data-v-3f7a6b1f"]]),T={class:"ap"},S={class:"ap__section"},V={class:"ap__themes",role:"radiogroup","aria-labelledby":"ap-theme"},z=["aria-checked","onClick"],I={class:"ap__themeLabel"},L={class:"ap__check","aria-hidden":"true"},M={key:0,class:"ap__note"},N={class:"ap__section"},$=["aria-checked"],A={class:"ap__rowIcon","aria-hidden":"true"},E=d({__name:"AppearanceView",setup(p){const a=v(),l=[{value:"light",label:"روشن",icon:"vuesax-linear:sun-1"},{value:"dark",label:"تیره",icon:"vuesax-linear:moon"},{value:"system",label:"مطابق سیستم",icon:"vuesax-linear:monitor-mobbile"}];return(P,s)=>{const m=k("container");return i(),_("div",T,[o(y,{title:"تنظیمات ظاهری","back-to":"userPage::hasSubRoute"}),o(m,{class:"ap__body"},{default:b(()=>[e("section",S,[s[2]||(s[2]=e("header",{class:"ap__head"},[e("h2",{id:"ap-theme",class:"ap__title"},"پوسته برنامه"),e("p",{class:"ap__sub"},"ظاهر روشن یا تیره، یا هماهنگ با تنظیمات گوشی شما")],-1)),e("div",V,[(i(),_(f,null,w(l,t=>e("button",{key:t.value,type:"button",role:"radio",class:r(["ap__theme",{"is-on":n(a).theme===t.value}]),"aria-checked":n(a).theme===t.value,onClick:F=>n(a).setTheme(t.value)},[e("span",{class:r(["ap__preview",`ap__preview--${t.value}`]),"aria-hidden":"true"},[...s[1]||(s[1]=[e("span",{class:"ap__pvBar"},null,-1),e("span",{class:"ap__pvCard"},null,-1),e("span",{class:"ap__pvLine"},null,-1),e("span",{class:"ap__pvLine ap__pvLine--short"},null,-1),e("span",{class:"ap__pvBtn"},null,-1)])],2),e("span",I,[o(c,{icon:t.icon,size:"15"},null,8,["icon"]),g(" "+u(t.label),1)]),e("span",L,[o(c,{icon:"vuesax-linear:tick-circle",size:"18"})])],10,z)),64))]),n(a).theme==="system"?(i(),_("p",M," الان "+u(n(a).resolvedTheme==="dark"?"تیره":"روشن")+" است، مطابق تنظیمات گوشی. ",1)):x("",!0)]),e("section",N,[s[4]||(s[4]=e("header",{class:"ap__head"},[e("h2",{class:"ap__title"},"انیمیشنها"),e("p",{class:"ap__sub"},"برای سرعت بیشتر روی گوشیهای ضعیفتر")],-1)),e("button",{type:"button",role:"switch",class:"ap__row","aria-checked":n(a).reduceMotion,onClick:s[0]||(s[0]=t=>n(a).setReduceMotion(!n(a).reduceMotion))},[e("span",A,[o(c,{icon:"vuesax-linear:flash",size:"18"})]),s[3]||(s[3]=e("span",{class:"ap__rowText"},[e("b",null,"حالت سبک و سریع"),e("small",null," حرکتهای سنگین، افکتهای شیشهای و اسلاید خودکار خاموش میشوند؛ بازخورد کوتاه لمسها و جابهجاییهای سریع باقی میمانند تا برنامه خشک و بیروح نشود. ")],-1)),o(B,{on:n(a).reduceMotion},null,8,["on"])],8,$)])]),_:1})])}}}),H=h(E,[["__scopeId","data-v-d05eb581"]]);export{H as default};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.cho__barExtra[data-v-24fb71cb]{padding:0 16px 12px}.cho__body[data-v-24fb71cb]{padding-top:132px;padding-bottom:90px}.cho__list[data-v-24fb71cb]{display:flex;flex-direction:column;padding-top:8px}.cho__card[data-v-24fb71cb]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline);cursor:pointer}.cho__card[data-v-24fb71cb]:last-child{border-bottom:none}.cho__card[data-v-24fb71cb]:active{opacity:.75}.cho__top[data-v-24fb71cb]{display:flex;justify-content:space-between;gap:12px}.cho__right[data-v-24fb71cb]{display:flex;flex-direction:column;align-items:flex-end;gap:8px}.cho__num[data-v-24fb71cb]{font-size:15px;font-weight:700;margin-bottom:10px}.cho__muted[data-v-24fb71cb]{color:var(--c-inkFaint);font-weight:500;margin-left:4px}.cho__meta[data-v-24fb71cb]{display:flex;align-items:baseline;gap:4px}.cho__strong[data-v-24fb71cb]{font-size:15px;font-weight:700}.cho__unit[data-v-24fb71cb]{font-size:12px;color:var(--c-inkFaint)}.cho__status[data-v-24fb71cb]{padding:5px 14px;border-radius:999px;font-size:11.5px;font-weight:600;color:#06b6d4;background:#06b6d41f;white-space:nowrap}.cho__divider[data-v-24fb71cb]{height:10px}.cho__bottom[data-v-24fb71cb]{display:flex;justify-content:space-between;align-items:center}.cho__customer[data-v-24fb71cb]{font-size:13.5px;font-weight:600;color:var(--c-inkMuted)}.cho__more[data-v-24fb71cb],.cho__sentinel[data-v-24fb71cb]{padding:12px 0;text-align:center;font-size:12px;color:var(--c-inkFaint)}.cho__empty[data-v-24fb71cb]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.cho__empty p[data-v-24fb71cb]{margin:0;font-size:12.5px}.cho__retry[data-v-24fb71cb]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.cho__detail[data-v-24fb71cb]{padding:20px 16px 24px}.cho__detailTitle[data-v-24fb71cb]{margin:0 0 16px;font-size:17px;font-weight:700}.cho__rows[data-v-24fb71cb]{margin:0;display:flex;flex-direction:column}.cho__row[data-v-24fb71cb]{display:flex;justify-content:space-between;align-items:center;padding:13px 2px;border-bottom:1px solid var(--c-hairline)}.cho__row[data-v-24fb71cb]:last-child{border-bottom:none}.cho__row dt[data-v-24fb71cb]{font-size:13px;color:var(--c-inkFaint)}.cho__row dd[data-v-24fb71cb]{margin:0;font-size:14px;font-weight:600}.cho__close[data-v-24fb71cb]{margin-top:18px;width:100%;padding:13px;border:none;border-radius:14px;background:var(--c-ink);color:var(--c-onInk);font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.cho__barExtra[data-v-83f2fdbb]{padding:0 16px 12px}.cho__body[data-v-83f2fdbb]{padding-top:132px;padding-bottom:90px}.cho__list[data-v-83f2fdbb]{display:flex;flex-direction:column;padding-top:8px}.cho__card[data-v-83f2fdbb]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline);cursor:pointer}.cho__card[data-v-83f2fdbb]:last-child{border-bottom:none}.cho__card[data-v-83f2fdbb]:active{opacity:.75}.cho__top[data-v-83f2fdbb]{display:flex;justify-content:space-between;gap:12px}.cho__right[data-v-83f2fdbb]{display:flex;flex-direction:column;align-items:flex-end;gap:8px}.cho__num[data-v-83f2fdbb]{font-size:15px;font-weight:700;margin-bottom:10px}.cho__muted[data-v-83f2fdbb]{color:var(--c-inkFaint);font-weight:500;margin-left:4px}.cho__meta[data-v-83f2fdbb]{display:flex;align-items:baseline;gap:4px}.cho__strong[data-v-83f2fdbb]{font-size:15px;font-weight:700}.cho__unit[data-v-83f2fdbb]{font-size:12px;color:var(--c-inkFaint)}.cho__status[data-v-83f2fdbb]{padding:5px 14px;border-radius:999px;font-size:11.5px;font-weight:600;color:#06b6d4;background:#06b6d41f;white-space:nowrap}.cho__divider[data-v-83f2fdbb]{height:10px}.cho__bottom[data-v-83f2fdbb]{display:flex;justify-content:space-between;align-items:center}.cho__customer[data-v-83f2fdbb]{font-size:13.5px;font-weight:600;color:var(--c-inkMuted)}.cho__more[data-v-83f2fdbb],.cho__sentinel[data-v-83f2fdbb]{padding:12px 0;text-align:center;font-size:12px;color:var(--c-inkFaint)}.cho__empty[data-v-83f2fdbb]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.cho__empty p[data-v-83f2fdbb]{margin:0;font-size:12.5px}.cho__retry[data-v-83f2fdbb]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.cho__detail[data-v-83f2fdbb]{padding:20px 16px 24px}.cho__detailTitle[data-v-83f2fdbb]{margin:0 0 16px;font-size:17px;font-weight:700}.cho__rows[data-v-83f2fdbb]{margin:0;display:flex;flex-direction:column}.cho__row[data-v-83f2fdbb]{display:flex;justify-content:space-between;align-items:center;padding:13px 2px;border-bottom:1px solid var(--c-hairline)}.cho__row[data-v-83f2fdbb]:last-child{border-bottom:none}.cho__row dt[data-v-83f2fdbb]{font-size:13px;color:var(--c-inkFaint)}.cho__row dd[data-v-83f2fdbb]{margin:0;font-size:14px;font-weight:600}.cho__close[data-v-83f2fdbb]{margin-top:18px;width:100%;padding:13px;border:none;border-radius:14px;background:var(--c-ink);color:var(--c-onInk);font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{d as f,o as v,s as g,c as s,p as e,z as u,J as y,g as d,h as _,f as t,F as P,r as k,I as C,b as h,e as x,B,S as b,j as V,_ as w}from"./index-BWyw3RAx.js";import{P as I}from"./PageBar-D5DXU6sO.js";import{S as N}from"./SectionHead-BiCEpXAe.js";import{P as S}from"./ProductCard-nL92ZIkB.js";const z={class:"coll"},F={key:0,class:"coll__grid"},j={key:1,class:"coll__empty"},q=f({__name:"CollectionView",setup(E){const l=b(),o=d(!1),n=d([]),p=_(()=>l.query.page||"مجموعه"),c=_(()=>n.value.filter(i=>i.collections.some(a=>a.collectionId===l.params.id)));return v(async()=>{o.value=!0;try{n.value=await g.listProducts()}finally{o.value=!1}}),(i,a)=>{const m=B("container");return t(),s("div",z,[e(I,{title:p.value,"back-to":"search"},null,8,["title"]),e(y,{spinning:o.value,tip:"درحال دریافت اطلاعات"},{default:u(()=>[e(m,{class:"coll__body"},{default:u(()=>[e(N,{title:"محصولات این مجموعه",subtitle:"برای دیدن جزئیات، روی محصول ضربه بزنید"}),c.value.length?(t(),s("div",F,[(t(!0),s(P,null,k(c.value,r=>(t(),V(S,{key:r.id,value:r},null,8,["value"]))),128))])):o.value?x("",!0):(t(),s("section",j,[e(C,{icon:"vuesax-linear:box",size:"26"}),a[0]||(a[0]=h("p",null,"در این مجموعه محصولی ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["spinning"])])}}}),R=w(q,[["__scopeId","data-v-7e172d86"]]);export{R as default};
|
||||
import{d as f,o as v,s as g,c as s,p as e,z as u,J as y,g as d,h as _,f as t,F as P,r as k,I as C,b as h,e as x,B,S as b,j as V,_ as w}from"./index-Dp9sxvBv.js";import{P as I}from"./PageBar-El-laHqK.js";import{S as N}from"./SectionHead-DNG9ywW4.js";import{P as S}from"./ProductCard-CvKEozHI.js";const z={class:"coll"},F={key:0,class:"coll__grid"},j={key:1,class:"coll__empty"},q=f({__name:"CollectionView",setup(E){const l=b(),o=d(!1),n=d([]),p=_(()=>l.query.page||"مجموعه"),c=_(()=>n.value.filter(i=>i.collections.some(a=>a.collectionId===l.params.id)));return v(async()=>{o.value=!0;try{n.value=await g.listProducts()}finally{o.value=!1}}),(i,a)=>{const m=B("container");return t(),s("div",z,[e(I,{title:p.value,"back-to":"search"},null,8,["title"]),e(y,{spinning:o.value,tip:"درحال دریافت اطلاعات"},{default:u(()=>[e(m,{class:"coll__body"},{default:u(()=>[e(N,{title:"محصولات این مجموعه",subtitle:"برای دیدن جزئیات، روی محصول ضربه بزنید"}),c.value.length?(t(),s("div",F,[(t(!0),s(P,null,k(c.value,r=>(t(),V(S,{key:r.id,value:r},null,8,["value"]))),128))])):o.value?x("",!0):(t(),s("section",j,[e(C,{icon:"vuesax-linear:box",size:"26"}),a[0]||(a[0]=h("p",null,"در این مجموعه محصولی ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["spinning"])])}}}),R=w(q,[["__scopeId","data-v-7e172d86"]]);export{R as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d as B,f as r,c,i as $,b as d,p as n,I as w,t as h,e as x,g as o,s as P,_ as I,z as C,J as S,P as U,aN as z,K as D,B as V,C as N,U as R}from"./index-BWyw3RAx.js";import{P as T}from"./PageBar-D5DXU6sO.js";const j=["disabled"],E=["src"],L={key:1,class:"siu__icon"},M={class:"siu__text"},A={key:2,class:"siu__badge"},J={key:0,class:"siu__error"},K=8*1024*1024,O=B({__name:"SingleImageUploader",props:{uploadText:{},purpose:{}},emits:["done"],setup(g,{emit:b}){const a=g,_=b,l=o(null),i=o(!1),t=o(null),u=o(null),m=o(null);async function s(v){var f;const e=v.target,p=(f=e.files)==null?void 0:f[0];if(e.value="",!!p){if(t.value=null,p.size>K){t.value="حجم فایل بیش از حد مجاز است (۸ مگابایت)";return}i.value=!0;try{const k=await P.uploadDocument(p,a.purpose);u.value=k.id,m.value=p.type.startsWith("image/")?URL.createObjectURL(p):null,_("done",k.id)}catch{t.value="بارگذاری انجام نشد. دوباره تلاش کنید.",u.value=null,m.value=null}finally{i.value=!1}}}return(v,e)=>(r(),c("div",{class:$(["siu",{"is-done":!!u.value,"is-error":!!t.value}])},[d("input",{ref_key:"input",ref:l,type:"file",accept:"image/jpeg,image/png,image/webp,application/pdf",class:"siu__input",onChange:s},null,544),d("button",{type:"button",class:"siu__box",disabled:i.value,onClick:e[0]||(e[0]=p=>{var f;return(f=l.value)==null?void 0:f.click()})},[m.value?(r(),c("img",{key:0,src:m.value,class:"siu__preview",alt:""},null,8,E)):(r(),c("span",L,[n(w,{icon:i.value?"vuesax-linear:refresh":"vuesax-linear:gallery-add",size:"26"},null,8,["icon"])])),d("span",M,h(i.value?"در حال بارگذاری…":u.value?"تغییر تصویر":g.uploadText),1),u.value&&!i.value?(r(),c("span",A,[n(w,{icon:"vuesax-linear:tick-circle",size:"16"})])):x("",!0)],8,j),t.value?(r(),c("p",J,h(t.value),1)):x("",!0)],2))}}),y=I(O,[["__scopeId","data-v-3a42e270"]]),W={class:"cpf"},X={key:0,class:"cpf__error"},Y=["disabled"],q=B({__name:"CompleteProfileView",setup(g){const b=N(),a=D({business_license_image_id:null,shop_image_id:null,ncode_image_id:null}),_=o(!1),l=o(!1),i=o(null);async function t(){if(i.value=null,!a.business_license_image_id||!a.shop_image_id||!a.ncode_image_id){R.warning("لطفا تمامی عکس های خواسته شده را آپلود کنید");return}_.value=!0;try{await P.completeProfile({business_license_image_id:a.business_license_image_id,shop_image_id:a.shop_image_id,ncode_image_id:a.ncode_image_id}),l.value=!0}catch{i.value="ثبت اطلاعات انجام نشد. دوباره تلاش کنید."}finally{_.value=!1}}function u(){l.value=!1,b.push({name:"home"})}return(m,s)=>{const v=V("container");return r(),c("div",W,[n(T,{title:"تکمیل پروفایل","back-to":"userPage::hasSubRoute"}),n(S,{spinning:_.value,tip:"در حال ثبت اطلاعات"},{default:C(()=>[n(v,{class:"cpf__body"},{default:C(()=>[s[4]||(s[4]=d("p",{class:"cpf__lead"}," برای تکمیل پروفایل، سه مدرک زیر را بارگذاری کنید. ",-1)),d("form",{class:"cpf__form",onSubmit:z(t,["prevent"])},[n(y,{"upload-text":"بارگذاری عکس جواز کسب",purpose:"business-license",onDone:s[0]||(s[0]=e=>a.business_license_image_id=e)}),n(y,{"upload-text":"بارگذاری عکس سردر مغازه",purpose:"shop-image",onDone:s[1]||(s[1]=e=>a.shop_image_id=e)}),n(y,{"upload-text":"بارگذاری عکس کارت ملی",purpose:"national-card",onDone:s[2]||(s[2]=e=>a.ncode_image_id=e)}),i.value?(r(),c("p",X,h(i.value),1)):x("",!0),d("button",{type:"submit",class:"cpf__submit",disabled:_.value}," بارگذاری اطلاعات ",8,Y)],32)]),_:1})]),_:1},8,["spinning"]),n(U,{show:l.value,"onUpdate:show":s[3]||(s[3]=e=>l.value=e),art:"success",title:"تکمیل اطلاعات شما با موفقیت انجام شد.",description:"مدارک شما با موفقیت بارگذاری شد.","confirm-text":"متوجه شدم","close-on-click-overlay":!1,onConfirm:u},null,8,["show"])])}}}),H=I(q,[["__scopeId","data-v-630e0720"]]);export{H as default};
|
||||
import{d as B,f as r,c,i as $,b as d,p as n,I as w,t as h,e as x,g as o,s as P,_ as I,z as C,J as S,P as U,aN as z,K as D,B as V,C as N,U as R}from"./index-Dp9sxvBv.js";import{P as T}from"./PageBar-El-laHqK.js";const j=["disabled"],E=["src"],L={key:1,class:"siu__icon"},M={class:"siu__text"},A={key:2,class:"siu__badge"},J={key:0,class:"siu__error"},K=8*1024*1024,O=B({__name:"SingleImageUploader",props:{uploadText:{},purpose:{}},emits:["done"],setup(g,{emit:b}){const a=g,_=b,l=o(null),i=o(!1),t=o(null),u=o(null),m=o(null);async function s(v){var f;const e=v.target,p=(f=e.files)==null?void 0:f[0];if(e.value="",!!p){if(t.value=null,p.size>K){t.value="حجم فایل بیش از حد مجاز است (۸ مگابایت)";return}i.value=!0;try{const k=await P.uploadDocument(p,a.purpose);u.value=k.id,m.value=p.type.startsWith("image/")?URL.createObjectURL(p):null,_("done",k.id)}catch{t.value="بارگذاری انجام نشد. دوباره تلاش کنید.",u.value=null,m.value=null}finally{i.value=!1}}}return(v,e)=>(r(),c("div",{class:$(["siu",{"is-done":!!u.value,"is-error":!!t.value}])},[d("input",{ref_key:"input",ref:l,type:"file",accept:"image/jpeg,image/png,image/webp,application/pdf",class:"siu__input",onChange:s},null,544),d("button",{type:"button",class:"siu__box",disabled:i.value,onClick:e[0]||(e[0]=p=>{var f;return(f=l.value)==null?void 0:f.click()})},[m.value?(r(),c("img",{key:0,src:m.value,class:"siu__preview",alt:""},null,8,E)):(r(),c("span",L,[n(w,{icon:i.value?"vuesax-linear:refresh":"vuesax-linear:gallery-add",size:"26"},null,8,["icon"])])),d("span",M,h(i.value?"در حال بارگذاری…":u.value?"تغییر تصویر":g.uploadText),1),u.value&&!i.value?(r(),c("span",A,[n(w,{icon:"vuesax-linear:tick-circle",size:"16"})])):x("",!0)],8,j),t.value?(r(),c("p",J,h(t.value),1)):x("",!0)],2))}}),y=I(O,[["__scopeId","data-v-3a42e270"]]),W={class:"cpf"},X={key:0,class:"cpf__error"},Y=["disabled"],q=B({__name:"CompleteProfileView",setup(g){const b=N(),a=D({business_license_image_id:null,shop_image_id:null,ncode_image_id:null}),_=o(!1),l=o(!1),i=o(null);async function t(){if(i.value=null,!a.business_license_image_id||!a.shop_image_id||!a.ncode_image_id){R.warning("لطفا تمامی عکس های خواسته شده را آپلود کنید");return}_.value=!0;try{await P.completeProfile({business_license_image_id:a.business_license_image_id,shop_image_id:a.shop_image_id,ncode_image_id:a.ncode_image_id}),l.value=!0}catch{i.value="ثبت اطلاعات انجام نشد. دوباره تلاش کنید."}finally{_.value=!1}}function u(){l.value=!1,b.push({name:"home"})}return(m,s)=>{const v=V("container");return r(),c("div",W,[n(T,{title:"تکمیل پروفایل","back-to":"userPage::hasSubRoute"}),n(S,{spinning:_.value,tip:"در حال ثبت اطلاعات"},{default:C(()=>[n(v,{class:"cpf__body"},{default:C(()=>[s[4]||(s[4]=d("p",{class:"cpf__lead"}," برای تکمیل پروفایل، سه مدرک زیر را بارگذاری کنید. ",-1)),d("form",{class:"cpf__form",onSubmit:z(t,["prevent"])},[n(y,{"upload-text":"بارگذاری عکس جواز کسب",purpose:"business-license",onDone:s[0]||(s[0]=e=>a.business_license_image_id=e)}),n(y,{"upload-text":"بارگذاری عکس سردر مغازه",purpose:"shop-image",onDone:s[1]||(s[1]=e=>a.shop_image_id=e)}),n(y,{"upload-text":"بارگذاری عکس کارت ملی",purpose:"national-card",onDone:s[2]||(s[2]=e=>a.ncode_image_id=e)}),i.value?(r(),c("p",X,h(i.value),1)):x("",!0),d("button",{type:"submit",class:"cpf__submit",disabled:_.value}," بارگذاری اطلاعات ",8,Y)],32)]),_:1})]),_:1},8,["spinning"]),n(U,{show:l.value,"onUpdate:show":s[3]||(s[3]=e=>l.value=e),art:"success",title:"تکمیل اطلاعات شما با موفقیت انجام شد.",description:"مدارک شما با موفقیت بارگذاری شد.","confirm-text":"متوجه شدم","close-on-click-overlay":!1,onConfirm:u},null,8,["show"])])}}}),H=I(q,[["__scopeId","data-v-630e0720"]]);export{H as default};
|
||||
@@ -1 +0,0 @@
|
||||
.cch__barExtra[data-v-e8f131ad]{padding:0 16px 12px}.cch__body[data-v-e8f131ad]{padding-top:132px;padding-bottom:90px}.cch__list[data-v-e8f131ad]{display:flex;flex-direction:column;padding-top:8px}.cch__card[data-v-e8f131ad]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline);cursor:pointer}.cch__card[data-v-e8f131ad]:last-child{border-bottom:none}.cch__card[data-v-e8f131ad]:active{opacity:.75}.cch__head[data-v-e8f131ad]{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.cch__name[data-v-e8f131ad]{margin:0;font-size:15.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cch__state[data-v-e8f131ad]{flex:0 0 auto;padding:4px 12px;border-radius:999px;font-size:11.5px;font-weight:600}.cch__state.is-on[data-v-e8f131ad]{color:var(--c-green);background:rgb(var(--c-green-rgb) / 14%)}.cch__state.is-off[data-v-e8f131ad]{color:var(--c-red);background:rgb(var(--c-red-rgb) / 12%)}.cch__facts[data-v-e8f131ad]{display:flex;flex-direction:column;gap:7px}.cch__fact[data-v-e8f131ad]{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:600;color:var(--c-inkFaint)}.cch__empty[data-v-e8f131ad]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.cch__empty p[data-v-e8f131ad]{margin:0;font-size:12.5px}.cch__retry[data-v-e8f131ad]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.cch__detail[data-v-e8f131ad]{padding:20px 16px 24px}.cch__detailTitle[data-v-e8f131ad]{margin:0 0 16px;font-size:17px;font-weight:700}.cch__rows[data-v-e8f131ad]{margin:0;display:flex;flex-direction:column}.cch__row[data-v-e8f131ad]{display:flex;justify-content:space-between;align-items:center;padding:13px 2px;border-bottom:1px solid var(--c-hairline)}.cch__row[data-v-e8f131ad]:last-child{border-bottom:none}.cch__row dt[data-v-e8f131ad]{font-size:13px;color:var(--c-inkFaint)}.cch__row dd[data-v-e8f131ad]{margin:0;font-size:14px;font-weight:600}.cch__close[data-v-e8f131ad]{margin-top:18px;width:100%;padding:13px;border:none;border-radius:14px;background:var(--c-ink);color:var(--c-onInk);font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as S,o as B,c,p as o,z as i,J as N,m as u,g as d,s as P,f as r,b as s,F as D,r as F,I as v,t as l,e as g,B as I,i as E,_ as L}from"./index-Dp9sxvBv.js";import{P as R}from"./PageBar-El-laHqK.js";import{S as $}from"./SearchInput-OrOd8Tqr.js";import{D as U}from"./index-UuozCFb-.js";import{P as J,L as w}from"./index-B0RgkHMm.js";import"./KeyCode-DAs3W-5E.js";import"./classCallCheck-BTVV3tFI.js";import"./raf-nDFcuFT6.js";const M={class:"cch"},O={class:"cch__barExtra"},T={key:0,class:"cch__list"},j=["onClick"],q={class:"cch__head"},A={class:"cch__name"},G={class:"cch__facts"},H={class:"cch__fact"},K={dir:"ltr"},Q={class:"cch__fact"},W={dir:"ltr"},X={key:0,class:"cch__fact"},Y={key:1,class:"cch__empty"},Z={key:2,class:"cch__empty"},ss={key:0,class:"cch__detail"},es={class:"cch__detailTitle"},ts={class:"cch__rows"},os={class:"cch__row"},ls={dir:"ltr"},as={class:"cch__row"},ns={dir:"ltr"},is={class:"cch__row"},cs={class:"cch__row"},rs={class:"cch__row"},ds={dir:"ltr"},_s={class:"cch__row"},us={dir:"ltr"},vs={class:"cch__row"},hs=S({__name:"CustomerChoiceView",setup(ps){const _=d([]),h=d(!1),b=d(!1),p=d(!1),y=d(null),f=d(!1),a=d(null);function C(n){const e=Number(n??0);return Number.isFinite(e)?e.toFixed(3):"0.000"}async function m(){h.value=!0,p.value=!1;try{_.value=await P.getExpertCustomers(y.value)}catch{p.value=!0,_.value=[]}finally{h.value=!1}}async function k(n){y.value=n!=null&&n.trim()?n.trim():null,await m()}async function x(){await m(),b.value=!1}function z(n){a.value=n,f.value=!0}return B(m),(n,e)=>{const V=I("container");return r(),c("div",M,[o(R,{title:"لیست مشتریان","back-to":"userPage::hasSubRoute"},{below:i(()=>[s("div",O,[o($,{onSearch:k,"place-holder":"جستجو"})])]),_:1}),o(N,{spinning:h.value&&!_.value.length,tip:"درحال دریافت اطلاعات"},{default:i(()=>[o(u(J),{modelValue:b.value,"onUpdate:modelValue":e[0]||(e[0]=t=>b.value=t),onRefresh:x},{pulling:i(()=>[o(u(w),{size:"18"})]),loosing:i(()=>[o(u(w),{size:"18"})]),loading:i(()=>[o(u(w),{size:"18"})]),success:i(()=>[...e[3]||(e[3]=[s("span",null,null,-1)])]),default:i(()=>[o(V,{class:"cch__body"},{default:i(()=>[_.value.length?(r(),c("div",T,[(r(!0),c(D,null,F(_.value,t=>(r(),c("article",{key:t.id,class:"cch__card",onClick:fs=>z(t)},[s("div",q,[s("h3",A,l(t.name),1),s("span",{class:E(["cch__state",t.active===1?"is-on":"is-off"])},l(t.active===1?"فعال":"غیرفعال"),3)]),s("div",G,[s("div",H,[o(v,{icon:"vuesax-linear:mobile",size:"15"}),s("span",K,l(t.mobile||"—"),1)]),s("div",Q,[o(v,{icon:"vuesax-linear:call",size:"15"}),s("span",W,l(t.phone||"—"),1)]),t.store_name?(r(),c("div",X,[o(v,{icon:"vuesax-linear:shop",size:"15"}),s("span",null,l(t.store_name),1)])):g("",!0)])],8,j))),128))])):!h.value&&!p.value?(r(),c("section",Y,[o(v,{icon:"vuesax-linear:profile-2user",size:"26"}),s("p",null,l(y.value?"مشتری با این مشخصات پیدا نشد.":"هنوز مشتریای به شما معرفی نشده است."),1)])):g("",!0),p.value?(r(),c("section",Z,[o(v,{icon:"vuesax-linear:danger",size:"26"}),e[4]||(e[4]=s("p",null,"دریافت اطلاعات با خطا روبهرو شد.",-1)),s("button",{type:"button",class:"cch__retry",onClick:m},"تلاش دوباره")])):g("",!0)]),_:1})]),_:1},8,["modelValue"])]),_:1},8,["spinning"]),o(u(U),{open:f.value,"onUpdate:open":e[2]||(e[2]=t=>f.value=t),placement:"bottom",height:"auto",closable:!1,"body-style":{padding:"0"}},{default:i(()=>[a.value?(r(),c("div",ss,[s("h2",es,l(a.value.name),1),s("dl",ts,[s("div",os,[e[5]||(e[5]=s("dt",null,"موبایل",-1)),s("dd",ls,l(a.value.mobile||"—"),1)]),s("div",as,[e[6]||(e[6]=s("dt",null,"تلفن",-1)),s("dd",ns,l(a.value.phone||"—"),1)]),s("div",is,[e[7]||(e[7]=s("dt",null,"نام مغازه",-1)),s("dd",null,l(a.value.store_name||"—"),1)]),s("div",cs,[e[8]||(e[8]=s("dt",null,"نام پاساژ",-1)),s("dd",null,l(a.value.pasazh_name||"—"),1)]),s("div",rs,[e[9]||(e[9]=s("dt",null,"سقف اعتبار",-1)),s("dd",ds,l(C(a.value.gram_level))+" گرم",1)]),s("div",_s,[e[10]||(e[10]=s("dt",null,"بدهی گرمی",-1)),s("dd",us,l(C(a.value.remain))+" گرم",1)]),s("div",vs,[e[11]||(e[11]=s("dt",null,"وضعیت",-1)),s("dd",null,l(a.value.active===1?"فعال":"غیرفعال"),1)])]),s("button",{type:"button",class:"cch__close",onClick:e[1]||(e[1]=t=>f.value=!1)},"بستن")])):g("",!0)]),_:1},8,["open"])])}}}),zs=L(hs,[["__scopeId","data-v-b1fb7f3c"]]);export{zs as default};
|
||||
@@ -0,0 +1 @@
|
||||
.cch__barExtra[data-v-b1fb7f3c]{padding:0 16px 12px}.cch__body[data-v-b1fb7f3c]{padding-top:132px;padding-bottom:90px}.cch__list[data-v-b1fb7f3c]{display:flex;flex-direction:column;padding-top:8px}.cch__card[data-v-b1fb7f3c]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline);cursor:pointer}.cch__card[data-v-b1fb7f3c]:last-child{border-bottom:none}.cch__card[data-v-b1fb7f3c]:active{opacity:.75}.cch__head[data-v-b1fb7f3c]{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.cch__name[data-v-b1fb7f3c]{margin:0;font-size:15.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cch__state[data-v-b1fb7f3c]{flex:0 0 auto;padding:4px 12px;border-radius:999px;font-size:11.5px;font-weight:600}.cch__state.is-on[data-v-b1fb7f3c]{color:var(--c-green);background:rgb(var(--c-green-rgb) / 14%)}.cch__state.is-off[data-v-b1fb7f3c]{color:var(--c-red);background:rgb(var(--c-red-rgb) / 12%)}.cch__facts[data-v-b1fb7f3c]{display:flex;flex-direction:column;gap:7px}.cch__fact[data-v-b1fb7f3c]{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:600;color:var(--c-inkFaint)}.cch__empty[data-v-b1fb7f3c]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.cch__empty p[data-v-b1fb7f3c]{margin:0;font-size:12.5px}.cch__retry[data-v-b1fb7f3c]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.cch__detail[data-v-b1fb7f3c]{padding:20px 16px 24px}.cch__detailTitle[data-v-b1fb7f3c]{margin:0 0 16px;font-size:17px;font-weight:700}.cch__rows[data-v-b1fb7f3c]{margin:0;display:flex;flex-direction:column}.cch__row[data-v-b1fb7f3c]{display:flex;justify-content:space-between;align-items:center;padding:13px 2px;border-bottom:1px solid var(--c-hairline)}.cch__row[data-v-b1fb7f3c]:last-child{border-bottom:none}.cch__row dt[data-v-b1fb7f3c]{font-size:13px;color:var(--c-inkFaint)}.cch__row dd[data-v-b1fb7f3c]{margin:0;font-size:14px;font-weight:600}.cch__close[data-v-b1fb7f3c]{margin-top:18px;width:100%;padding:13px;border:none;border-radius:14px;background:var(--c-ink);color:var(--c-onInk);font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
|
||||
@@ -1 +0,0 @@
|
||||
import{d as V,o as S,c,p as a,z as d,J as B,m as w,g as r,s as N,f as i,b as s,F as P,r as D,I as u,t as o,e as m,B as F,i as I,_ as E}from"./index-BWyw3RAx.js";import{P as R}from"./PageBar-D5DXU6sO.js";import{S as $}from"./SearchInput-BZCG2e_m.js";import{D as U}from"./index-sLHXZqt3.js";import{P as J}from"./index-ecOSUWQv.js";import"./KeyCode-CA5MXqE7.js";import"./classCallCheck-Ou87VTNv.js";import"./raf-nDFcuFT6.js";const L={class:"cch"},M={class:"cch__barExtra"},O={key:0,class:"cch__list"},T=["onClick"],j={class:"cch__head"},q={class:"cch__name"},A={class:"cch__facts"},G={class:"cch__fact"},H={dir:"ltr"},K={class:"cch__fact"},Q={dir:"ltr"},W={key:0,class:"cch__fact"},X={key:1,class:"cch__empty"},Y={key:2,class:"cch__empty"},Z={key:0,class:"cch__detail"},ss={class:"cch__detailTitle"},es={class:"cch__rows"},ts={class:"cch__row"},os={dir:"ltr"},as={class:"cch__row"},ls={dir:"ltr"},ns={class:"cch__row"},cs={class:"cch__row"},is={class:"cch__row"},rs={dir:"ltr"},_s={class:"cch__row"},ds={dir:"ltr"},us={class:"cch__row"},vs=V({__name:"CustomerChoiceView",setup(hs){const _=r([]),v=r(!1),y=r(!1),h=r(!1),b=r(null),p=r(!1),l=r(null);function g(n){const e=Number(n??0);return Number.isFinite(e)?e.toFixed(3):"0.000"}async function f(){v.value=!0,h.value=!1;try{_.value=await N.getExpertCustomers(b.value)}catch{h.value=!0,_.value=[]}finally{v.value=!1}}async function C(n){b.value=n!=null&&n.trim()?n.trim():null,await f()}async function k(){await f(),y.value=!1}function x(n){l.value=n,p.value=!0}return S(f),(n,e)=>{const z=F("container");return i(),c("div",L,[a(R,{title:"لیست مشتریان","back-to":"userPage::hasSubRoute"},{below:d(()=>[s("div",M,[a($,{onSearch:C,"place-holder":"جستجو"})])]),_:1}),a(B,{spinning:v.value&&!_.value.length,tip:"درحال دریافت اطلاعات"},{default:d(()=>[a(w(J),{modelValue:y.value,"onUpdate:modelValue":e[0]||(e[0]=t=>y.value=t),onRefresh:k},{default:d(()=>[a(z,{class:"cch__body"},{default:d(()=>[_.value.length?(i(),c("div",O,[(i(!0),c(P,null,D(_.value,t=>(i(),c("article",{key:t.id,class:"cch__card",onClick:ps=>x(t)},[s("div",j,[s("h3",q,o(t.name),1),s("span",{class:I(["cch__state",t.active===1?"is-on":"is-off"])},o(t.active===1?"فعال":"غیرفعال"),3)]),s("div",A,[s("div",G,[a(u,{icon:"vuesax-linear:mobile",size:"15"}),s("span",H,o(t.mobile||"—"),1)]),s("div",K,[a(u,{icon:"vuesax-linear:call",size:"15"}),s("span",Q,o(t.phone||"—"),1)]),t.store_name?(i(),c("div",W,[a(u,{icon:"vuesax-linear:shop",size:"15"}),s("span",null,o(t.store_name),1)])):m("",!0)])],8,T))),128))])):!v.value&&!h.value?(i(),c("section",X,[a(u,{icon:"vuesax-linear:profile-2user",size:"26"}),s("p",null,o(b.value?"مشتری با این مشخصات پیدا نشد.":"هنوز مشتریای به شما معرفی نشده است."),1)])):m("",!0),h.value?(i(),c("section",Y,[a(u,{icon:"vuesax-linear:danger",size:"26"}),e[3]||(e[3]=s("p",null,"دریافت اطلاعات با خطا روبهرو شد.",-1)),s("button",{type:"button",class:"cch__retry",onClick:f},"تلاش دوباره")])):m("",!0)]),_:1})]),_:1},8,["modelValue"])]),_:1},8,["spinning"]),a(w(U),{open:p.value,"onUpdate:open":e[2]||(e[2]=t=>p.value=t),placement:"bottom",height:"auto",closable:!1,"body-style":{padding:"0"}},{default:d(()=>[l.value?(i(),c("div",Z,[s("h2",ss,o(l.value.name),1),s("dl",es,[s("div",ts,[e[4]||(e[4]=s("dt",null,"موبایل",-1)),s("dd",os,o(l.value.mobile||"—"),1)]),s("div",as,[e[5]||(e[5]=s("dt",null,"تلفن",-1)),s("dd",ls,o(l.value.phone||"—"),1)]),s("div",ns,[e[6]||(e[6]=s("dt",null,"نام مغازه",-1)),s("dd",null,o(l.value.store_name||"—"),1)]),s("div",cs,[e[7]||(e[7]=s("dt",null,"نام پاساژ",-1)),s("dd",null,o(l.value.pasazh_name||"—"),1)]),s("div",is,[e[8]||(e[8]=s("dt",null,"سقف اعتبار",-1)),s("dd",rs,o(g(l.value.gram_level))+" گرم",1)]),s("div",_s,[e[9]||(e[9]=s("dt",null,"بدهی گرمی",-1)),s("dd",ds,o(g(l.value.remain))+" گرم",1)]),s("div",us,[e[10]||(e[10]=s("dt",null,"وضعیت",-1)),s("dd",null,o(l.value.active===1?"فعال":"غیرفعال"),1)])]),s("button",{type:"button",class:"cch__close",onClick:e[1]||(e[1]=t=>p.value=!1)},"بستن")])):m("",!0)]),_:1},8,["open"])])}}}),xs=E(vs,[["__scopeId","data-v-e8f131ad"]]);export{xs as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.dash[data-v-244443b2]{background:var(--c-page);min-height:100vh}.dash__bar[data-v-244443b2]{position:fixed;top:0;left:0;right:0;z-index:20;background:rgb(var(--c-canvas-rgb) / 82%);-webkit-backdrop-filter:blur(18px) saturate(140%);backdrop-filter:blur(18px) saturate(140%);border-bottom:1px solid rgb(var(--c-primary-rgb) / 5%);box-shadow:0 8px 20px -18px rgb(var(--c-primary-rgb) / 55%)}.dash__barInner[data-v-244443b2]{max-width:768px;margin:0 auto;padding:10px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px}.dash__brand[data-v-244443b2]{font-size:14.5px;font-weight:800;color:var(--c-ink)}.dash__exit[data-v-244443b2]{flex:0 0 auto;width:34px;height:34px;border:0;cursor:pointer;border-radius:11px;display:grid;place-items:center;color:var(--c-wine);background:var(--c-wineWash);transition:transform .18s ease}.dash__exit[data-v-244443b2]:active{transform:scale(.92)}.dash__body[data-v-244443b2]{padding-top:58px;padding-bottom:90px}.dash__reveal[data-v-244443b2]{opacity:0;transform:translate3d(0,14px,0) scale(.98);will-change:transform,opacity}.is-revealed .dash__reveal[data-v-244443b2]{opacity:1;transform:translateZ(0) scale(1);transition:opacity .62s cubic-bezier(.32,.72,0,1) var(--reveal-delay, 0ms),transform .62s cubic-bezier(.32,.72,0,1) var(--reveal-delay, 0ms)}@media (prefers-reduced-motion: reduce){.dash__reveal[data-v-244443b2]{opacity:1;transform:none}.is-revealed .dash__reveal[data-v-244443b2]{transition:none}}.dash__hero[data-v-244443b2]{margin-top:16px;margin-bottom:28px;padding:20px 18px 16px;border-radius:26px;background:linear-gradient(155deg,var(--c-heroSurface) 0%,var(--c-heroSurface) 55%,#5E1427 130%);color:#fff;box-shadow:0 22px 40px -26px #14161a52}.dash__heroTop[data-v-244443b2]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.dash__heroIdentity[data-v-244443b2]{display:flex;flex-direction:column;gap:3px;min-width:0}.dash__heroName[data-v-244443b2]{font-size:13.5px;font-weight:800}.dash__heroMobile[data-v-244443b2]{font-size:11px;color:#ffffff8c;direction:ltr;text-align:right}.dash__heroSeal[data-v-244443b2]{flex:0 0 auto;padding:5px 12px;border-radius:999px;font-size:10.5px;font-weight:700}.dash__heroSeal.is-debtor[data-v-244443b2]{color:#fff;background:var(--c-wineSoft)}.dash__heroSeal.is-creditor[data-v-244443b2]{color:#14161a;background:#ffffffe6}.dash__heroFigure[data-v-244443b2]{margin-top:20px;display:flex;align-items:baseline;gap:8px}.dash__heroNum[data-v-244443b2]{font-size:34px;font-weight:800;letter-spacing:-.5px}.dash__heroUnit[data-v-244443b2]{font-size:12px;font-weight:600;color:#ffffff9e}.dash__heroDebt[data-v-244443b2]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding-top:13px;border-top:1px solid rgba(255,255,255,.14);font-size:11.5px;color:#ffffff9e}.dash__heroDebt b[data-v-244443b2]{font-size:13px;font-weight:700;color:#fff}.dash__heroDebt i[data-v-244443b2]{font-style:normal;font-size:10.5px;font-weight:600;color:#ffffff9e;padding-inline-start:3px}.dash__invite[data-v-244443b2]{margin-top:16px;padding:26px 20px;border-radius:26px;background:var(--c-heroSurface);color:#fff;display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px}.dash__inviteIcon[data-v-244443b2]{width:46px;height:46px;border-radius:50%;display:grid;place-items:center;background:#ffffff1a;margin-bottom:2px}.dash__invite h3[data-v-244443b2]{margin:0;font-size:14px;font-weight:800}.dash__invite p[data-v-244443b2]{margin:0;font-size:11.5px;color:#fff9;max-width:240px}.dash__inviteCta[data-v-244443b2]{margin-top:6px;padding:10px 22px;border:0;cursor:pointer;border-radius:999px;font-family:inherit;font-size:12px;font-weight:700;color:#14161a;background:#fff;transition:transform .18s ease}.dash__inviteCta[data-v-244443b2]:active{transform:scale(.96)}.dash__rows[data-v-244443b2]{margin-top:0;display:flex;flex-direction:column;gap:9px}.dash__row[data-v-244443b2]{display:flex;align-items:center;gap:12px;padding:13px 14px;border:1px solid var(--c-hairline);border-radius:17px;cursor:pointer;background:var(--c-canvas);font-family:inherit;text-align:right;transition:transform .18s ease,border-color .18s ease}.dash__row[data-v-244443b2]:active{transform:scale(.985);border-color:rgb(var(--c-wine-rgb) / 20%)}.dash__rowIcon[data-v-244443b2]{flex:0 0 auto;width:38px;height:38px;border-radius:12px;display:grid;place-items:center;color:var(--c-wine);background:var(--c-wineWash)}.dash__rowText[data-v-244443b2]{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.dash__rowText b[data-v-244443b2]{font-size:12.5px;font-weight:700;color:var(--c-ink)}.dash__rowText small[data-v-244443b2]{font-size:10.5px;color:var(--c-inkFaint)}.dash__rate[data-v-244443b2]{margin-top:14px}
|
||||
@@ -0,0 +1 @@
|
||||
.dash[data-v-d6c76569]{background:var(--c-page);min-height:100vh}.dash__bar[data-v-d6c76569]{position:fixed;top:0;left:0;right:0;z-index:20;background:rgb(var(--c-canvas-rgb) / 82%);-webkit-backdrop-filter:blur(18px) saturate(140%);backdrop-filter:blur(18px) saturate(140%);border-bottom:1px solid rgb(var(--c-primary-rgb) / 5%);box-shadow:0 8px 20px -18px rgb(var(--c-primary-rgb) / 55%)}.dash__barInner[data-v-d6c76569]{max-width:768px;margin:0 auto;padding:10px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px}.dash__brand[data-v-d6c76569]{font-size:14.5px;font-weight:800;color:var(--c-ink)}.dash__exit[data-v-d6c76569]{flex:0 0 auto;width:34px;height:34px;border:0;cursor:pointer;border-radius:11px;display:grid;place-items:center;color:var(--c-wine);background:var(--c-wineWash);transition:transform .18s ease}.dash__exit[data-v-d6c76569]:active{transform:scale(.92)}.dash__body[data-v-d6c76569]{padding-top:58px;padding-bottom:90px}.dash__reveal[data-v-d6c76569]{opacity:0;transform:translate3d(0,14px,0) scale(.98);will-change:transform,opacity}.is-revealed .dash__reveal[data-v-d6c76569]{opacity:1;transform:translateZ(0) scale(1);transition:opacity .62s cubic-bezier(.32,.72,0,1) var(--reveal-delay, 0ms),transform .62s cubic-bezier(.32,.72,0,1) var(--reveal-delay, 0ms)}@media (prefers-reduced-motion: reduce){.dash__reveal[data-v-d6c76569]{opacity:1;transform:none}.is-revealed .dash__reveal[data-v-d6c76569]{transition:none}}.dash__hero[data-v-d6c76569]{margin-top:16px;margin-bottom:28px;padding:20px 18px 16px;border-radius:26px;background:linear-gradient(155deg,var(--c-heroSurface) 0%,var(--c-heroSurface) 55%,#5E1427 130%);color:#fff;box-shadow:0 22px 40px -26px #14161a52}.dash__heroTop[data-v-d6c76569]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.dash__heroIdentity[data-v-d6c76569]{display:flex;flex-direction:column;gap:3px;min-width:0}.dash__heroName[data-v-d6c76569]{font-size:13.5px;font-weight:800}.dash__heroMobile[data-v-d6c76569]{font-size:11px;color:#ffffff8c;direction:ltr;text-align:right}.dash__heroSeal[data-v-d6c76569]{flex:0 0 auto;padding:5px 12px;border-radius:999px;font-size:10.5px;font-weight:700}.dash__heroSeal.is-debtor[data-v-d6c76569]{color:#fff;background:var(--c-wineSoft)}.dash__heroSeal.is-creditor[data-v-d6c76569]{color:#14161a;background:#ffffffe6}.dash__heroFigure[data-v-d6c76569]{margin-top:20px;display:flex;align-items:baseline;gap:8px}.dash__heroNum[data-v-d6c76569]{font-size:34px;font-weight:800;letter-spacing:-.5px}.dash__heroUnit[data-v-d6c76569]{font-size:12px;font-weight:600;color:#ffffff9e}.dash__heroDebt[data-v-d6c76569]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding-top:13px;border-top:1px solid rgba(255,255,255,.14);font-size:11.5px;color:#ffffff9e}.dash__heroDebt b[data-v-d6c76569]{font-size:13px;font-weight:700;color:#fff}.dash__heroDebt i[data-v-d6c76569]{font-style:normal;font-size:10.5px;font-weight:600;color:#ffffff9e;padding-inline-start:3px}.dash__invite[data-v-d6c76569]{margin-top:16px;padding:26px 20px;border-radius:26px;background:var(--c-heroSurface);color:#fff;display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px}.dash__inviteIcon[data-v-d6c76569]{width:46px;height:46px;border-radius:50%;display:grid;place-items:center;background:#ffffff1a;margin-bottom:2px}.dash__invite h3[data-v-d6c76569]{margin:0;font-size:14px;font-weight:800}.dash__invite p[data-v-d6c76569]{margin:0;font-size:11.5px;color:#fff9;max-width:240px}.dash__inviteCta[data-v-d6c76569]{margin-top:6px;padding:10px 22px;border:0;cursor:pointer;border-radius:999px;font-family:inherit;font-size:12px;font-weight:700;color:#14161a;background:#fff;transition:transform .18s ease}.dash__inviteCta[data-v-d6c76569]:active{transform:scale(.96)}.dash__rows[data-v-d6c76569]{margin-top:0;display:flex;flex-direction:column;gap:9px}.dash__row[data-v-d6c76569]{display:flex;align-items:center;gap:12px;padding:13px 14px;border:1px solid var(--c-hairline);border-radius:17px;cursor:pointer;background:var(--c-canvas);font-family:inherit;text-align:right;transition:transform .18s ease,border-color .18s ease}.dash__row[data-v-d6c76569]:active{transform:scale(.985);border-color:rgb(var(--c-wine-rgb) / 20%)}.dash__rowIcon[data-v-d6c76569]{flex:0 0 auto;width:38px;height:38px;border-radius:12px;display:grid;place-items:center;color:var(--c-wine);background:var(--c-wineWash)}.dash__rowText[data-v-d6c76569]{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.dash__rowText b[data-v-d6c76569]{font-size:12.5px;font-weight:700;color:var(--c-ink)}.dash__rowText small[data-v-d6c76569]{font-size:10.5px;color:var(--c-inkFaint)}.dash__rate[data-v-d6c76569]{margin-top:14px}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d as m,aM as g,o as y,s as b,c as o,p as e,z as c,J as h,g as d,f as t,F as _,b as l,r as k,I as C,m as P,e as B,C as F,h as x,B as V,j as w,_ as S}from"./index-BWyw3RAx.js";import{P as I}from"./PageBar-D5DXU6sO.js";import{S as N}from"./SectionHead-BiCEpXAe.js";import{P as $}from"./ProductCard-nL92ZIkB.js";const z={class:"favs"},M={class:"favs__grid"},R={key:1,class:"favs__empty"},j=m({__name:"FavoritesView",setup(E){const v=F(),f=g(),a=d(!1),i=d([]),n=x(()=>i.value.filter(u=>f.isFavorite(u.id)));return y(async()=>{a.value=!0;try{i.value=await b.listProducts()}finally{a.value=!1}}),(u,s)=>{const p=V("container");return t(),o("div",z,[e(I,{title:"علاقهمندی","back-to":"userPage::hasSubRoute"}),e(h,{spinning:a.value,tip:"در حال دریافت اطلاعات"},{default:c(()=>[e(p,{class:"favs__body"},{default:c(()=>[n.value.length?(t(),o(_,{key:0},[e(N,{title:"ذخیرهشدهها",subtitle:`${n.value.length} محصول`},null,8,["subtitle"]),l("div",M,[(t(!0),o(_,null,k(n.value,r=>(t(),w($,{key:r.id,value:r},null,8,["value"]))),128))])],64)):a.value?B("",!0):(t(),o("section",R,[e(C,{icon:"vuesax-linear:heart",size:"26"}),s[1]||(s[1]=l("p",null,"هنوز محصولی را به علاقهمندیها اضافه نکردهاید.",-1)),l("button",{type:"button",class:"favs__emptyCta",onClick:s[0]||(s[0]=r=>P(v).push({name:"home"}))}," دیدن محصولات ")]))]),_:1})]),_:1},8,["spinning"])])}}}),A=S(j,[["__scopeId","data-v-d58ee1f5"]]);export{A as default};
|
||||
import{d as m,aM as g,o as y,s as b,c as o,p as e,z as c,J as h,g as d,f as t,F as _,b as l,r as k,I as C,m as P,e as B,C as F,h as x,B as V,j as w,_ as S}from"./index-Dp9sxvBv.js";import{P as I}from"./PageBar-El-laHqK.js";import{S as N}from"./SectionHead-DNG9ywW4.js";import{P as $}from"./ProductCard-CvKEozHI.js";const z={class:"favs"},M={class:"favs__grid"},R={key:1,class:"favs__empty"},j=m({__name:"FavoritesView",setup(E){const v=F(),f=g(),a=d(!1),i=d([]),n=x(()=>i.value.filter(u=>f.isFavorite(u.id)));return y(async()=>{a.value=!0;try{i.value=await b.listProducts()}finally{a.value=!1}}),(u,s)=>{const p=V("container");return t(),o("div",z,[e(I,{title:"علاقهمندی","back-to":"userPage::hasSubRoute"}),e(h,{spinning:a.value,tip:"در حال دریافت اطلاعات"},{default:c(()=>[e(p,{class:"favs__body"},{default:c(()=>[n.value.length?(t(),o(_,{key:0},[e(N,{title:"ذخیرهشدهها",subtitle:`${n.value.length} محصول`},null,8,["subtitle"]),l("div",M,[(t(!0),o(_,null,k(n.value,r=>(t(),w($,{key:r.id,value:r},null,8,["value"]))),128))])],64)):a.value?B("",!0):(t(),o("section",R,[e(C,{icon:"vuesax-linear:heart",size:"26"}),s[1]||(s[1]=l("p",null,"هنوز محصولی را به علاقهمندیها اضافه نکردهاید.",-1)),l("button",{type:"button",class:"favs__emptyCta",onClick:s[0]||(s[0]=r=>P(v).push({name:"home"}))}," دیدن محصولات ")]))]),_:1})]),_:1},8,["spinning"])])}}}),A=S(j,[["__scopeId","data-v-d58ee1f5"]]);export{A as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{a9 as d,h as f,an as g,d as P,ab as p,L as C,Q as T,Y as h,x as m,a as x,p as R,bj as y}from"./index-BWyw3RAx.js";var E=!1;try{var c=Object.defineProperty({},"passive",{get:function(){E=!0}});window.addEventListener("testPassive",null,c),window.removeEventListener("testPassive",null,c)}catch{}var j=Symbol("TriggerContextKey"),K=function(e){return e?d(j,{setPortal:function(){},popPortal:!1}):{setPortal:function(){},popPortal:!1}},v=Symbol("PortalContextKey"),b=function(e){var i=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{inTriggerContext:!0};g(v,{inTriggerContext:i.inTriggerContext,shouldRender:f(function(){var n=e||{},u=n.sPopupVisible,t=n.popupRef,l=n.forceRender,r=n.autoDestroy,s=!1;return(u||t||l)&&(s=!0),!u&&r&&(s=!1),s})})},D=function(){b({},{inTriggerContext:!1});var e=d(v,{shouldRender:f(function(){return!1}),inTriggerContext:!1});return{shouldRender:f(function(){return e.shouldRender.value||e.inTriggerContext===!1})}};const N=P({compatConfig:{MODE:3},name:"Portal",inheritAttrs:!1,props:{getContainer:p.func.isRequired,didUpdate:Function},setup:function(e,i){var n=i.slots,u=!0,t,l=D(),r=l.shouldRender;C(function(){u=!1,r.value&&(t=e.getContainer())});var s=T(r,function(){r.value&&!t&&(t=e.getContainer()),t&&s()});return h(function(){m(function(){if(r.value){var o;(o=e.didUpdate)===null||o===void 0||o.call(e,e)}})}),x(function(){t&&t.parentNode&&t.parentNode.removeChild(t)}),function(){if(!r.value)return null;if(u){var o;return(o=n.default)===null||o===void 0?void 0:o.call(n)}return t?R(y,{to:t},n):null}}});var w={ESC:27,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40};export{w as K,N as P,K as a,E as s,b as u};
|
||||
import{a9 as d,h as f,an as g,d as P,ab as p,L as C,Q as T,Y as h,x as m,a as x,p as R,bj as y}from"./index-Dp9sxvBv.js";var E=!1;try{var c=Object.defineProperty({},"passive",{get:function(){E=!0}});window.addEventListener("testPassive",null,c),window.removeEventListener("testPassive",null,c)}catch{}var j=Symbol("TriggerContextKey"),K=function(e){return e?d(j,{setPortal:function(){},popPortal:!1}):{setPortal:function(){},popPortal:!1}},v=Symbol("PortalContextKey"),b=function(e){var i=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{inTriggerContext:!0};g(v,{inTriggerContext:i.inTriggerContext,shouldRender:f(function(){var n=e||{},u=n.sPopupVisible,t=n.popupRef,l=n.forceRender,r=n.autoDestroy,s=!1;return(u||t||l)&&(s=!0),!u&&r&&(s=!1),s})})},D=function(){b({},{inTriggerContext:!1});var e=d(v,{shouldRender:f(function(){return!1}),inTriggerContext:!1});return{shouldRender:f(function(){return e.shouldRender.value||e.inTriggerContext===!1})}};const N=P({compatConfig:{MODE:3},name:"Portal",inheritAttrs:!1,props:{getContainer:p.func.isRequired,didUpdate:Function},setup:function(e,i){var n=i.slots,u=!0,t,l=D(),r=l.shouldRender;C(function(){u=!1,r.value&&(t=e.getContainer())});var s=T(r,function(){r.value&&!t&&(t=e.getContainer()),t&&s()});return h(function(){m(function(){if(r.value){var o;(o=e.didUpdate)===null||o===void 0||o.call(e,e)}})}),x(function(){t&&t.parentNode&&t.parentNode.removeChild(t)}),function(){if(!r.value)return null;if(u){var o;return(o=n.default)===null||o===void 0?void 0:o.call(n)}return t?R(y,{to:t},n):null}}});var w={ESC:27,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40};export{w as K,N as P,K as a,E as s,b as u};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{d as p,f as n,c,p as a,m as r,E as P,D as b,G as k,n as S,H as B,J as v,j as y,e as h,g as u,s as E,K as L,L as C,z as d,F as I,r as N,I as V,b as $,h as A,B as F,_ as z}from"./index-BWyw3RAx.js";import{P as D}from"./PageBar-D5DXU6sO.js";import{S as H}from"./SectionHead-BiCEpXAe.js";import{P as j}from"./ProductCard-nL92ZIkB.js";const G={class:"flex justify-center items-center empty"},T=p({__name:"EmptyState",props:{description:{},image:{}},setup(e){return(t,s)=>(n(),c("div",G,[a(r(P),{description:"موردی یافت نشد",image:e.image,class:"h-[200px] w-[200px] opacity-70"},null,8,["image"])]))}}),J="/assets/favoritEmpty-kbtPAdi5.svg",K=p({__name:"ScrollFetchSearch",props:{loading:{type:Boolean},flag:{type:Boolean},padding:{}},emits:["fetch"],setup(e,{emit:t}){const s=t,o=b(),l=u(null);return k(l,()=>{s("fetch")},{distance:50}),(i,f)=>(n(),c("main",{class:"flex flex-col flex-1 overflow-auto no-scrollbar w-[100vw] max-w-screen-md m-auto",style:S({paddingTop:e.padding||r(o).headerHeight+"px"}),ref_key:"scrollArea",ref:l},[B(i.$slots,"default"),a(v,{cls:"my-[10px]",spinning:e.loading,indicator:!0,tip:"درحال دریافت اطلاعات"},null,8,["spinning"]),e.flag?(n(),y(T,{key:0,image:r(J)},null,8,["image"])):h("",!0)],4))}});function M(){const e=L({totalPages:0,productList:[],flag:!1});return{productsData:e,getProducts:async(s,o=!1)=>{if(!o){s.value=!0;try{e.productList=await E.listProducts(),e.flag=e.productList.length<1}catch(l){console.log(l)}finally{s.value=!1}}}}}const Z={class:"newest"},q={key:0,class:"newest__grid"},O={key:1,class:"newest__empty"},g=20,Q=p({__name:"NewestView",setup(e){const{productsData:t,getProducts:s}=M(),o=u(!1),l=u(!1),i=u(g),f=A(()=>t.productList.slice(0,i.value));C(async()=>{await s(o,!1)});const w=async()=>{if(i.value<t.productList.length){i.value+=g;return}await s(l,!0)};return(R,m)=>{const x=F("container");return n(),c("div",Z,[a(D,{title:"جدیدترین محصولات","back-to":"home"}),a(v,{spinning:o.value,tip:"درحال دریافت اطلاعات"},{default:d(()=>[a(K,{class:"newest__scroll",loading:l.value,flag:r(t).flag,padding:"0px",onFetch:w},{default:d(()=>[a(x,{class:"newest__body"},{default:d(()=>[a(H,{title:"تازهها",subtitle:"آخرین افزودههای مجموعه"}),r(t).productList.length?(n(),c("div",q,[(n(!0),c(I,null,N(f.value,_=>(n(),y(j,{key:_.id,value:_},null,8,["value"]))),128))])):o.value?h("",!0):(n(),c("section",O,[a(V,{icon:"vuesax-linear:box",size:"26"}),m[0]||(m[0]=$("p",null,"هنوز محصولی در مجموعه ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["loading","flag"])]),_:1},8,["spinning"])])}}}),ee=z(Q,[["__scopeId","data-v-4551f84b"]]);export{ee as default};
|
||||
import{d as p,f as n,c,p as a,m as r,E as P,D as b,G as k,n as S,H as B,J as v,j as y,e as h,g as u,s as E,K as L,L as C,z as d,F as I,r as N,I as V,b as $,h as A,B as F,_ as z}from"./index-Dp9sxvBv.js";import{P as D}from"./PageBar-El-laHqK.js";import{S as H}from"./SectionHead-DNG9ywW4.js";import{P as j}from"./ProductCard-CvKEozHI.js";const G={class:"flex justify-center items-center empty"},T=p({__name:"EmptyState",props:{description:{},image:{}},setup(e){return(t,s)=>(n(),c("div",G,[a(r(P),{description:"موردی یافت نشد",image:e.image,class:"h-[200px] w-[200px] opacity-70"},null,8,["image"])]))}}),J="/assets/favoritEmpty-kbtPAdi5.svg",K=p({__name:"ScrollFetchSearch",props:{loading:{type:Boolean},flag:{type:Boolean},padding:{}},emits:["fetch"],setup(e,{emit:t}){const s=t,o=b(),l=u(null);return k(l,()=>{s("fetch")},{distance:50}),(i,f)=>(n(),c("main",{class:"flex flex-col flex-1 overflow-auto no-scrollbar w-[100vw] max-w-screen-md m-auto",style:S({paddingTop:e.padding||r(o).headerHeight+"px"}),ref_key:"scrollArea",ref:l},[B(i.$slots,"default"),a(v,{cls:"my-[10px]",spinning:e.loading,indicator:!0,tip:"درحال دریافت اطلاعات"},null,8,["spinning"]),e.flag?(n(),y(T,{key:0,image:r(J)},null,8,["image"])):h("",!0)],4))}});function M(){const e=L({totalPages:0,productList:[],flag:!1});return{productsData:e,getProducts:async(s,o=!1)=>{if(!o){s.value=!0;try{e.productList=await E.listProducts(),e.flag=e.productList.length<1}catch(l){console.log(l)}finally{s.value=!1}}}}}const Z={class:"newest"},q={key:0,class:"newest__grid"},O={key:1,class:"newest__empty"},g=20,Q=p({__name:"NewestView",setup(e){const{productsData:t,getProducts:s}=M(),o=u(!1),l=u(!1),i=u(g),f=A(()=>t.productList.slice(0,i.value));C(async()=>{await s(o,!1)});const w=async()=>{if(i.value<t.productList.length){i.value+=g;return}await s(l,!0)};return(R,m)=>{const x=F("container");return n(),c("div",Z,[a(D,{title:"جدیدترین محصولات","back-to":"home"}),a(v,{spinning:o.value,tip:"درحال دریافت اطلاعات"},{default:d(()=>[a(K,{class:"newest__scroll",loading:l.value,flag:r(t).flag,padding:"0px",onFetch:w},{default:d(()=>[a(x,{class:"newest__body"},{default:d(()=>[a(H,{title:"تازهها",subtitle:"آخرین افزودههای مجموعه"}),r(t).productList.length?(n(),c("div",q,[(n(!0),c(I,null,N(f.value,_=>(n(),y(j,{key:_.id,value:_},null,8,["value"]))),128))])):o.value?h("",!0):(n(),c("section",O,[a(V,{icon:"vuesax-linear:box",size:"26"}),m[0]||(m[0]=$("p",null,"هنوز محصولی در مجموعه ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["loading","flag"])]),_:1},8,["spinning"])])}}}),ee=z(Q,[["__scopeId","data-v-4551f84b"]]);export{ee as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d as n,c as a,p as o,z as i,B as c,f as _,b as e,I as r,_ as f}from"./index-BWyw3RAx.js";import{P as p}from"./PageBar-D5DXU6sO.js";const l={class:"notif"},d={class:"notif__empty"},m=n({__name:"NotificationsView",setup(u){return(x,t)=>{const s=c("container");return _(),a("div",l,[o(p,{title:"اعلانها","back-to":"home"}),o(s,{class:"notif__body"},{default:i(()=>[e("section",d,[o(r,{icon:"vuesax-linear:notification",size:"26"}),t[0]||(t[0]=e("p",null,"اعلانی برای نمایش وجود ندارد.",-1))])]),_:1})])}}}),v=f(m,[["__scopeId","data-v-6887fb26"]]);export{v as default};
|
||||
import{d as n,c as a,p as o,z as i,B as c,f as _,b as e,I as r,_ as f}from"./index-Dp9sxvBv.js";import{P as p}from"./PageBar-El-laHqK.js";const l={class:"notif"},d={class:"notif__empty"},m=n({__name:"NotificationsView",setup(u){return(x,t)=>{const s=c("container");return _(),a("div",l,[o(p,{title:"اعلانها","back-to":"home"}),o(s,{class:"notif__body"},{default:i(()=>[e("section",d,[o(r,{icon:"vuesax-linear:notification",size:"26"}),t[0]||(t[0]=e("p",null,"اعلانی برای نمایش وجود ندارد.",-1))])]),_:1})])}}}),v=f(m,[["__scopeId","data-v-6887fb26"]]);export{v as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{d as _,c as t,b as e,p as b,I as d,t as u,H as i,f as o,C as p,_ as k}from"./index-BWyw3RAx.js";const f={class:"bar"},h={class:"bar__inner"},m={key:1,class:"bar__spacer","aria-hidden":"true"},B={class:"bar__title"},g={class:"bar__trailing"},v=_({__name:"PageBar",props:{title:{},back:{type:Boolean,default:!0},backTo:{},backAction:{}},setup(s){const a=s,n=p();function l(){a.backAction?a.backAction():a.backTo?n.push({name:a.backTo}):n.back()}return(r,c)=>(o(),t("header",f,[e("div",h,[s.back?(o(),t("button",{key:0,type:"button",class:"bar__btn","aria-label":"بازگشت",onClick:l},[b(d,{icon:"vuesax-linear:arrow-right-1",size:"20"})])):(o(),t("span",m)),e("h1",B,u(s.title),1),e("div",g,[i(r.$slots,"trailing",{},()=>[c[0]||(c[0]=e("span",{class:"bar__spacer","aria-hidden":"true"},null,-1))],!0)])]),i(r.$slots,"below",{},void 0,!0)]))}}),A=k(v,[["__scopeId","data-v-dc516f82"]]);export{A as P};
|
||||
import{d as _,c as t,b as e,p as b,I as d,t as u,H as i,f as o,C as p,_ as k}from"./index-Dp9sxvBv.js";const f={class:"bar"},h={class:"bar__inner"},m={key:1,class:"bar__spacer","aria-hidden":"true"},B={class:"bar__title"},g={class:"bar__trailing"},v=_({__name:"PageBar",props:{title:{},back:{type:Boolean,default:!0},backTo:{},backAction:{}},setup(s){const a=s,n=p();function l(){a.backAction?a.backAction():a.backTo?n.push({name:a.backTo}):n.back()}return(r,c)=>(o(),t("header",f,[e("div",h,[s.back?(o(),t("button",{key:0,type:"button",class:"bar__btn","aria-label":"بازگشت",onClick:l},[b(d,{icon:"vuesax-linear:arrow-right-1",size:"20"})])):(o(),t("span",m)),e("h1",B,u(s.title),1),e("div",g,[i(r.$slots,"trailing",{},()=>[c[0]||(c[0]=e("span",{class:"bar__spacer","aria-hidden":"true"},null,-1))],!0)])]),i(r.$slots,"below",{},void 0,!0)]))}}),A=k(v,[["__scopeId","data-v-dc516f82"]]);export{A as P};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d as B,aM as N,k as P,l as z,f as s,c as t,b as a,m as e,w as F,p as m,I as h,aN as V,i as D,e as _,t as r,N as I,g as S,A as E,_ as M}from"./index-BWyw3RAx.js";const O={class:"pc__art"},q=["alt"],A={key:1,class:"pc__artEmpty","aria-hidden":"true"},L=["aria-label"],T={key:2,class:"pc__flag"},U={key:3,class:"pc__flag pc__flag--soft"},$={class:"pc__title"},j={class:"pc__weight"},G={key:0,class:"pc__meta"},H={key:0,class:"pc__wageOld"},J={class:"pc__wage"},K=B({__name:"ProductCard",props:{value:{},wageBefore:{}},setup(l){const d=l,o=N(),{image:p,id:i,title:u,min_gram:y,wage:f,is_exist:k,qty:w,isPreOrder:x}=P(d.value),g=S(null);function b(){E(d.value,g.value)}function v(n){return n%1===0?String(n):n.toFixed(2)}return(n,c)=>{const C=z("lazy");return s(),t("article",{ref_key:"root",ref:g,class:"pc","data-product-origin":"",onClick:b},[a("div",O,[e(p)?F((s(),t("img",{key:0,alt:e(u)},null,8,q)),[[C,e(p)]]):(s(),t("div",A,[m(h,{icon:"vuesax-linear:diamonds",size:"26"})])),a("button",{type:"button",class:D(["pc__fav",{"is-on":e(o).isFavorite(e(i))}]),"aria-label":e(o).isFavorite(e(i))?"حذف از علاقهمندی":"افزودن به علاقهمندی",onClick:c[0]||(c[0]=V(Q=>e(o).toggle(e(i)),["stop"]))},[m(h,{icon:e(o).isFavorite(e(i))?"vuesax-linear:heart-fill":"vuesax-linear:heart",size:"16"},null,8,["icon"])],10,L),e(k)?e(x)&&e(w)===0?(s(),t("span",U,"پیشسفارش")):_("",!0):(s(),t("span",T,"ناموجود"))]),a("h3",$,r(e(u)),1),a("p",j,[I(r(e(y))+" ",1),c[1]||(c[1]=a("span",{class:"pc__weightUnit"},"گرم",-1))]),e(f)!=null?(s(),t("div",G,[l.wageBefore!=null?(s(),t("span",H,"اجرت "+r(v(l.wageBefore))+"٪",1)):_("",!0),a("span",J,r(v(e(f)))+"٪ اجرت",1)])):_("",!0)],512)}}}),W=M(K,[["__scopeId","data-v-63b56860"]]);export{W as P};
|
||||
import{d as B,aM as N,k as P,l as z,f as s,c as t,b as a,m as e,w as F,p as m,I as h,aN as V,i as D,e as _,t as r,N as I,g as S,A as E,_ as M}from"./index-Dp9sxvBv.js";const O={class:"pc__art"},q=["alt"],A={key:1,class:"pc__artEmpty","aria-hidden":"true"},L=["aria-label"],T={key:2,class:"pc__flag"},U={key:3,class:"pc__flag pc__flag--soft"},$={class:"pc__title"},j={class:"pc__weight"},G={key:0,class:"pc__meta"},H={key:0,class:"pc__wageOld"},J={class:"pc__wage"},K=B({__name:"ProductCard",props:{value:{},wageBefore:{}},setup(l){const d=l,o=N(),{image:p,id:i,title:u,min_gram:y,wage:f,is_exist:k,qty:w,isPreOrder:x}=P(d.value),g=S(null);function b(){E(d.value,g.value)}function v(n){return n%1===0?String(n):n.toFixed(2)}return(n,c)=>{const C=z("lazy");return s(),t("article",{ref_key:"root",ref:g,class:"pc","data-product-origin":"",onClick:b},[a("div",O,[e(p)?F((s(),t("img",{key:0,alt:e(u)},null,8,q)),[[C,e(p)]]):(s(),t("div",A,[m(h,{icon:"vuesax-linear:diamonds",size:"26"})])),a("button",{type:"button",class:D(["pc__fav",{"is-on":e(o).isFavorite(e(i))}]),"aria-label":e(o).isFavorite(e(i))?"حذف از علاقهمندی":"افزودن به علاقهمندی",onClick:c[0]||(c[0]=V(Q=>e(o).toggle(e(i)),["stop"]))},[m(h,{icon:e(o).isFavorite(e(i))?"vuesax-linear:heart-fill":"vuesax-linear:heart",size:"16"},null,8,["icon"])],10,L),e(k)?e(x)&&e(w)===0?(s(),t("span",U,"پیشسفارش")):_("",!0):(s(),t("span",T,"ناموجود"))]),a("h3",$,r(e(u)),1),a("p",j,[I(r(e(y))+" ",1),c[1]||(c[1]=a("span",{class:"pc__weightUnit"},"گرم",-1))]),e(f)!=null?(s(),t("div",G,[l.wageBefore!=null?(s(),t("span",H,"اجرت "+r(v(l.wageBefore))+"٪",1)):_("",!0),a("span",J,r(v(e(f)))+"٪ اجرت",1)])):_("",!0)],512)}}}),W=M(K,[["__scopeId","data-v-63b56860"]]);export{W as P};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d as y,O as b,o as T,s as k,c as l,p as i,z as V,g as p,B,f as n,b as e,I as d,t as a,e as r,i as C,m as P,h as f,C as g,_ as D}from"./index-BWyw3RAx.js";import{P as N}from"./PageBar-D5DXU6sO.js";import{S as h}from"./SectionHead-BiCEpXAe.js";const R={class:"prof"},x={class:"prof__identity"},F={class:"prof__avatar","aria-hidden":"true"},M={class:"prof__name"},O={key:0,class:"prof__mobile"},w={key:0,class:"prof__block"},z={class:"prof__specs"},U={class:"prof__spec"},L={class:"prof__spec"},A={class:"prof__spec"},W={class:"prof__spec"},H={key:1,class:"prof__empty"},$=y({__name:"ProfileView",setup(j){const _={UNVERIFIED:"احراز نشده",STEP_ONE_SUBMITTED:"مرحله اول ثبت شده",STEP_TWO_SUBMITTED:"مرحله دوم ثبت شده",VERIFIED:"تایید شده"},v=b(),s=p(null),u=p(!1),m=f(()=>{var t;return((t=s.value)==null?void 0:t.verificationStep)==="VERIFIED"}),S=f(()=>{var o;const t=(o=s.value)==null?void 0:o.verificationStep;return!!t&&t!=="VERIFIED"&&t!=="STEP_TWO_SUBMITTED"}),E=g();return T(async()=>{if(v.isLoggedIn){u.value=!0;try{s.value=await k.getMe()}finally{u.value=!1}}}),(t,o)=>{const I=B("container");return n(),l("div",R,[i(N,{title:"اطلاعات کاربری","back-to":"userPage::hasSubRoute"}),i(I,{class:"prof__body"},{default:V(()=>{var c;return[e("section",x,[e("div",F,[i(d,{icon:"vuesax-linear:user",size:"30"})]),e("h2",M,a(((c=s.value)==null?void 0:c.fullName)??"—"),1),s.value?(n(),l("p",O,a(s.value.mobile),1)):r("",!0),s.value?(n(),l("span",{key:1,class:C(["prof__seal",m.value?"is-ok":"is-pending"])},a(_[s.value.verificationStep]??s.value.verificationStep),3)):r("",!0)]),s.value?(n(),l("section",w,[i(h,{title:"مشخصات",subtitle:"اطلاعات ثبتشده حساب شما"}),e("dl",z,[e("div",U,[o[1]||(o[1]=e("dt",null,"نام و نام خانوادگی",-1)),e("dd",null,a(s.value.fullName),1)]),e("div",L,[o[2]||(o[2]=e("dt",null,"تلفن همراه",-1)),e("dd",null,a(s.value.mobile),1)]),e("div",A,[o[3]||(o[3]=e("dt",null,"کد ملی",-1)),e("dd",null,a(s.value.nationalCode),1)]),e("div",W,[o[4]||(o[4]=e("dt",null,"وضعیت احراز هویت",-1)),e("dd",null,a(_[s.value.verificationStep]??s.value.verificationStep),1)])])])):u.value?r("",!0):(n(),l("section",H,[i(d,{icon:"vuesax-linear:user",size:"26"}),o[5]||(o[5]=e("p",null,"اطلاعات حساب در دسترس نیست.",-1))])),S.value?(n(),l("button",{key:2,type:"button",class:"prof__complete",onClick:o[0]||(o[0]=q=>P(E).push({name:"completeProfile"}))}," تکمیل پروفایل ")):r("",!0)]}),_:1})])}}}),Q=D($,[["__scopeId","data-v-a13f377b"]]);export{Q as default};
|
||||
import{d as y,O as b,o as T,s as k,c as l,p as i,z as V,g as p,B,f as n,b as e,I as d,t as a,e as r,i as C,m as P,h as f,C as g,_ as D}from"./index-Dp9sxvBv.js";import{P as N}from"./PageBar-El-laHqK.js";import{S as h}from"./SectionHead-DNG9ywW4.js";const R={class:"prof"},x={class:"prof__identity"},F={class:"prof__avatar","aria-hidden":"true"},M={class:"prof__name"},O={key:0,class:"prof__mobile"},w={key:0,class:"prof__block"},z={class:"prof__specs"},U={class:"prof__spec"},L={class:"prof__spec"},A={class:"prof__spec"},W={class:"prof__spec"},H={key:1,class:"prof__empty"},$=y({__name:"ProfileView",setup(j){const _={UNVERIFIED:"احراز نشده",STEP_ONE_SUBMITTED:"مرحله اول ثبت شده",STEP_TWO_SUBMITTED:"مرحله دوم ثبت شده",VERIFIED:"تایید شده"},v=b(),s=p(null),u=p(!1),m=f(()=>{var t;return((t=s.value)==null?void 0:t.verificationStep)==="VERIFIED"}),S=f(()=>{var o;const t=(o=s.value)==null?void 0:o.verificationStep;return!!t&&t!=="VERIFIED"&&t!=="STEP_TWO_SUBMITTED"}),E=g();return T(async()=>{if(v.isLoggedIn){u.value=!0;try{s.value=await k.getMe()}finally{u.value=!1}}}),(t,o)=>{const I=B("container");return n(),l("div",R,[i(N,{title:"اطلاعات کاربری","back-to":"userPage::hasSubRoute"}),i(I,{class:"prof__body"},{default:V(()=>{var c;return[e("section",x,[e("div",F,[i(d,{icon:"vuesax-linear:user",size:"30"})]),e("h2",M,a(((c=s.value)==null?void 0:c.fullName)??"—"),1),s.value?(n(),l("p",O,a(s.value.mobile),1)):r("",!0),s.value?(n(),l("span",{key:1,class:C(["prof__seal",m.value?"is-ok":"is-pending"])},a(_[s.value.verificationStep]??s.value.verificationStep),3)):r("",!0)]),s.value?(n(),l("section",w,[i(h,{title:"مشخصات",subtitle:"اطلاعات ثبتشده حساب شما"}),e("dl",z,[e("div",U,[o[1]||(o[1]=e("dt",null,"نام و نام خانوادگی",-1)),e("dd",null,a(s.value.fullName),1)]),e("div",L,[o[2]||(o[2]=e("dt",null,"تلفن همراه",-1)),e("dd",null,a(s.value.mobile),1)]),e("div",A,[o[3]||(o[3]=e("dt",null,"کد ملی",-1)),e("dd",null,a(s.value.nationalCode),1)]),e("div",W,[o[4]||(o[4]=e("dt",null,"وضعیت احراز هویت",-1)),e("dd",null,a(_[s.value.verificationStep]??s.value.verificationStep),1)])])])):u.value?r("",!0):(n(),l("section",H,[i(d,{icon:"vuesax-linear:user",size:"26"}),o[5]||(o[5]=e("p",null,"اطلاعات حساب در دسترس نیست.",-1))])),S.value?(n(),l("button",{key:2,type:"button",class:"prof__complete",onClick:o[0]||(o[0]=q=>P(E).push({name:"completeProfile"}))}," تکمیل پروفایل ")):r("",!0)]}),_:1})])}}}),Q=D($,[["__scopeId","data-v-a13f377b"]]);export{Q as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{p as n,j as d}from"./filters-DVocLz3o.js";import{d as l,c as a,i as m,b as s,N as u,t as i,m as c,e as _,h as v,f as r,_ as q}from"./index-BWyw3RAx.js";const f={key:0,class:"rate-strip__value"},y={class:"rate-strip__num"},k={key:1,class:"rate-strip__value rate-strip__value--muted"},x={key:2,class:"rate-strip__time"},B=l({__name:"RateStrip",props:{quote:{},compact:{type:Boolean}},setup(t){const o=t,p=v(()=>o.quote?o.quote.pricePerGramRial.split(".")[0]:"");return(N,e)=>(r(),a("div",{class:m(["rate-strip",{"is-empty":!t.quote,"rate-strip--compact":t.compact}])},[e[1]||(e[1]=s("div",{class:"rate-strip__label"},[s("span",{class:"rate-strip__dot","aria-hidden":"true"}),u(" نرخ طلای ۱۸ عیار ")],-1)),t.quote?(r(),a("div",f,[s("span",y,i(c(n)(p.value)),1),e[0]||(e[0]=s("span",{class:"rate-strip__unit"},"ریال / گرم",-1))])):(r(),a("div",k,"در انتظار اعلام نرخ")),t.quote?(r(),a("div",x,i(c(d)(t.quote.quotedAt)),1)):_("",!0)],2))}}),h=q(B,[["__scopeId","data-v-563349ab"]]);export{h as R};
|
||||
import{p as n,j as d}from"./filters-CUgGgSea.js";import{d as l,c as a,i as m,b as s,N as u,t as i,m as c,e as _,h as v,f as r,_ as q}from"./index-Dp9sxvBv.js";const f={key:0,class:"rate-strip__value"},y={class:"rate-strip__num"},k={key:1,class:"rate-strip__value rate-strip__value--muted"},x={key:2,class:"rate-strip__time"},B=l({__name:"RateStrip",props:{quote:{},compact:{type:Boolean}},setup(t){const o=t,p=v(()=>o.quote?o.quote.pricePerGramRial.split(".")[0]:"");return(N,e)=>(r(),a("div",{class:m(["rate-strip",{"is-empty":!t.quote,"rate-strip--compact":t.compact}])},[e[1]||(e[1]=s("div",{class:"rate-strip__label"},[s("span",{class:"rate-strip__dot","aria-hidden":"true"}),u(" نرخ طلای ۱۸ عیار ")],-1)),t.quote?(r(),a("div",f,[s("span",y,i(c(n)(p.value)),1),e[0]||(e[0]=s("span",{class:"rate-strip__unit"},"ریال / گرم",-1))])):(r(),a("div",k,"در انتظار اعلام نرخ")),t.quote?(r(),a("div",x,i(c(d)(t.quote.quotedAt)),1)):_("",!0)],2))}}),h=q(B,[["__scopeId","data-v-563349ab"]]);export{h as R};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
.doc[data-v-21825ac9]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline)}.doc[data-v-21825ac9]:last-child{border-bottom:none}.doc__head[data-v-21825ac9]{display:flex;justify-content:space-between;gap:10px;margin-bottom:10px;font-size:13px;font-weight:600}.doc__muted[data-v-21825ac9]{color:var(--c-inkMuted);font-weight:500;margin-left:4px}.doc__rows[data-v-21825ac9]{margin:0;display:flex;flex-direction:column}.doc__row[data-v-21825ac9]{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:9px 0;border-bottom:1px dashed var(--c-hairlineSoft)}.doc__row[data-v-21825ac9]:last-child{border-bottom:none}.doc__row dt[data-v-21825ac9]{font-size:12.5px;color:var(--c-inkMuted);white-space:nowrap}.doc__row dd[data-v-21825ac9]{margin:0;font-size:13.5px;font-weight:600;text-align:left}.doc__gold[data-v-21825ac9]{display:flex;justify-content:space-between;align-items:center;margin-top:10px;padding-top:10px;border-top:1px dashed var(--c-hairline);font-size:13px;font-weight:700}.doc__stack[data-v-21825ac9]{display:flex;flex-direction:column;align-items:flex-end;gap:2px}.is-in[data-v-21825ac9]{color:var(--c-green)}.is-out[data-v-21825ac9]{color:var(--c-red)}.rep__tabs[data-v-a1c9c13f]{display:flex;gap:24px;padding:0 16px 10px}.rep__tab[data-v-a1c9c13f]{padding:8px 0;border:none;border-bottom:2px solid transparent;background:transparent;font-family:inherit;font-size:14px;font-weight:600;color:var(--c-inkFaint);cursor:pointer}.rep__tab.is-on[data-v-a1c9c13f]{color:var(--c-ink);border-bottom-color:var(--c-wine)}.rep__body[data-v-a1c9c13f]{padding-top:126px;padding-bottom:90px}.rep__list[data-v-a1c9c13f]{display:flex;flex-direction:column;padding-top:8px}.rep__card[data-v-a1c9c13f]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline)}.rep__card[data-v-a1c9c13f]:last-child{border-bottom:none}.rep__head[data-v-a1c9c13f]{display:flex;justify-content:space-between;gap:10px;margin-bottom:12px;font-size:13px;font-weight:600}.rep__muted[data-v-a1c9c13f]{color:var(--c-inkMuted);font-weight:500;margin-left:4px}.rep__pair[data-v-a1c9c13f]{display:flex;justify-content:space-between;align-items:center;padding:8px 0;font-size:13px;color:var(--c-inkMuted)}.rep__pair.is-balance[data-v-a1c9c13f]{padding-top:10px;border-top:1px dashed var(--c-hairline);color:var(--c-ink);font-weight:700}.rep__stack[data-v-a1c9c13f]{display:flex;flex-direction:column;align-items:flex-end;gap:2px;font-weight:600}.is-in[data-v-a1c9c13f]{color:var(--c-green)}.is-out[data-v-a1c9c13f]{color:var(--c-red)}.rep__more[data-v-a1c9c13f],.rep__sentinel[data-v-a1c9c13f]{padding:12px 0;text-align:center;font-size:12px;color:var(--c-inkMuted)}.rep__more[data-v-a1c9c13f]{width:100%;margin-top:6px;border:none;background:transparent;font-family:inherit;font-weight:700;color:var(--c-wine);cursor:pointer}.rep__empty[data-v-a1c9c13f]{margin-top:40px;padding:34px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkMuted);font-size:13px}.rep__retry[data-v-a1c9c13f]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.rep__modal[data-v-a1c9c13f]{padding:20px 16px 24px}.rep__modalEmpty[data-v-a1c9c13f]{padding:24px 0;text-align:center;font-size:13px;color:var(--c-inkMuted)}.rep__close[data-v-a1c9c13f]{margin-top:18px;width:100%;padding:14px;border:none;border-radius:999px;background:var(--c-wine);color:#fff;font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
|
||||
.doc[data-v-21825ac9]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline)}.doc[data-v-21825ac9]:last-child{border-bottom:none}.doc__head[data-v-21825ac9]{display:flex;justify-content:space-between;gap:10px;margin-bottom:10px;font-size:13px;font-weight:600}.doc__muted[data-v-21825ac9]{color:var(--c-inkMuted);font-weight:500;margin-left:4px}.doc__rows[data-v-21825ac9]{margin:0;display:flex;flex-direction:column}.doc__row[data-v-21825ac9]{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:9px 0;border-bottom:1px dashed var(--c-hairlineSoft)}.doc__row[data-v-21825ac9]:last-child{border-bottom:none}.doc__row dt[data-v-21825ac9]{font-size:12.5px;color:var(--c-inkMuted);white-space:nowrap}.doc__row dd[data-v-21825ac9]{margin:0;font-size:13.5px;font-weight:600;text-align:left}.doc__gold[data-v-21825ac9]{display:flex;justify-content:space-between;align-items:center;margin-top:10px;padding-top:10px;border-top:1px dashed var(--c-hairline);font-size:13px;font-weight:700}.doc__stack[data-v-21825ac9]{display:flex;flex-direction:column;align-items:flex-end;gap:2px}.is-in[data-v-21825ac9]{color:var(--c-green)}.is-out[data-v-21825ac9]{color:var(--c-red)}.rep__tabs[data-v-24011723]{display:flex;gap:24px;padding:0 16px 10px}.rep__tab[data-v-24011723]{padding:8px 0;border:none;border-bottom:2px solid transparent;background:transparent;font-family:inherit;font-size:14px;font-weight:600;color:var(--c-inkFaint);cursor:pointer}.rep__tab.is-on[data-v-24011723]{color:var(--c-ink);border-bottom-color:var(--c-wine)}.rep__body[data-v-24011723]{padding-top:126px;padding-bottom:90px}.rep__list[data-v-24011723]{display:flex;flex-direction:column;padding-top:8px}.rep__card[data-v-24011723]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline)}.rep__card[data-v-24011723]:last-child{border-bottom:none}.rep__head[data-v-24011723]{display:flex;justify-content:space-between;gap:10px;margin-bottom:12px;font-size:13px;font-weight:600}.rep__muted[data-v-24011723]{color:var(--c-inkMuted);font-weight:500;margin-left:4px}.rep__pair[data-v-24011723]{display:flex;justify-content:space-between;align-items:center;padding:8px 0;font-size:13px;color:var(--c-inkMuted)}.rep__pair.is-balance[data-v-24011723]{padding-top:10px;border-top:1px dashed var(--c-hairline);color:var(--c-ink);font-weight:700}.rep__stack[data-v-24011723]{display:flex;flex-direction:column;align-items:flex-end;gap:2px;font-weight:600}.is-in[data-v-24011723]{color:var(--c-green)}.is-out[data-v-24011723]{color:var(--c-red)}.rep__more[data-v-24011723],.rep__sentinel[data-v-24011723]{padding:12px 0;text-align:center;font-size:12px;color:var(--c-inkMuted)}.rep__more[data-v-24011723]{width:100%;margin-top:6px;border:none;background:transparent;font-family:inherit;font-weight:700;color:var(--c-wine);cursor:pointer}.rep__empty[data-v-24011723]{margin-top:40px;padding:34px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkMuted);font-size:13px}.rep__retry[data-v-24011723]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.rep__modal[data-v-24011723]{padding:20px 16px 24px}.rep__modalEmpty[data-v-24011723]{padding:24px 0;text-align:center;font-size:13px;color:var(--c-inkMuted)}.rep__close[data-v-24011723]{margin-top:18px;width:100%;padding:14px;border:none;border-radius:999px;background:var(--c-wine);color:#fff;font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d as v,b3 as h,Q as m,f as t,c as l,p as n,I as r,w as f,b4 as b,b as x,b5 as k,e as I,K as w,g as y,_ as C}from"./index-BWyw3RAx.js";const S={class:"si"},V=["placeholder"],B=v({__name:"SearchInput",props:{placeHolder:{}},emits:["search"],setup(i,{emit:d}){const c=d,a=w({clear:!1}),e=y(void 0),s=()=>{c("search",e.value)},u=()=>{e.value=void 0,a.clear=!1,c("search",void 0)},p=h(e,700);return m(p,()=>{e?a.clear=!0:a.clear=!1,s()}),(K,o)=>(t(),l("div",S,[n(r,{icon:"vuesax-linear:search-normal-1",class:"si__icon",size:"18",onClick:s}),f(x("input",{"onUpdate:modelValue":o[0]||(o[0]=_=>e.value=_),class:"si__input",placeholder:i.placeHolder,onKeyup:k(s,["enter"])},null,40,V),[[b,e.value]]),a.clear?(t(),l("button",{key:0,type:"button",class:"si__clear","aria-label":"پاک کردن",onClick:u},[n(r,{icon:"vuesax-linear:add",size:"16"})])):I("",!0)]))}}),z=C(B,[["__scopeId","data-v-8ebd3183"]]);export{z as S};
|
||||
import{d as v,b3 as h,Q as m,f as t,c as l,p as n,I as r,w as f,b4 as b,b as x,b5 as k,e as I,K as w,g as y,_ as C}from"./index-Dp9sxvBv.js";const S={class:"si"},V=["placeholder"],B=v({__name:"SearchInput",props:{placeHolder:{}},emits:["search"],setup(i,{emit:d}){const c=d,a=w({clear:!1}),e=y(void 0),s=()=>{c("search",e.value)},u=()=>{e.value=void 0,a.clear=!1,c("search",void 0)},p=h(e,700);return m(p,()=>{e?a.clear=!0:a.clear=!1,s()}),(K,o)=>(t(),l("div",S,[n(r,{icon:"vuesax-linear:search-normal-1",class:"si__icon",size:"18",onClick:s}),f(x("input",{"onUpdate:modelValue":o[0]||(o[0]=_=>e.value=_),class:"si__input",placeholder:i.placeHolder,onKeyup:k(s,["enter"])},null,40,V),[[b,e.value]]),a.clear?(t(),l("button",{key:0,type:"button",class:"si__clear","aria-label":"پاک کردن",onClick:u},[n(r,{icon:"vuesax-linear:add",size:"16"})])):I("",!0)]))}}),z=C(B,[["__scopeId","data-v-8ebd3183"]]);export{z as S};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{d as r,f as t,c as s,b as o,t as a,e as n,N as _,p as d,I as u,_ as b}from"./index-BWyw3RAx.js";const m={class:"sec"},f={class:"sec__text"},x={class:"sec__title"},h={key:0,class:"sec__sub"},k=r({__name:"SectionHead",props:{title:{},subtitle:{},actionLabel:{}},emits:["action"],setup(e,{emit:i}){const l=i;return(p,c)=>(t(),s("header",m,[o("div",f,[o("h2",x,a(e.title),1),e.subtitle?(t(),s("p",h,a(e.subtitle),1)):n("",!0)]),e.actionLabel?(t(),s("button",{key:0,type:"button",class:"sec__action",onClick:c[0]||(c[0]=N=>l("action"))},[_(a(e.actionLabel)+" ",1),d(u,{icon:"vuesax-linear:arrow-left-2",size:"15"})])):n("",!0)]))}}),y=b(k,[["__scopeId","data-v-e5c65b7c"]]);export{y as S};
|
||||
import{d as r,f as t,c as s,b as o,t as a,e as n,N as _,p as d,I as u,_ as b}from"./index-Dp9sxvBv.js";const m={class:"sec"},f={class:"sec__text"},x={class:"sec__title"},h={key:0,class:"sec__sub"},k=r({__name:"SectionHead",props:{title:{},subtitle:{},actionLabel:{}},emits:["action"],setup(e,{emit:i}){const l=i;return(p,c)=>(t(),s("header",m,[o("div",f,[o("h2",x,a(e.title),1),e.subtitle?(t(),s("p",h,a(e.subtitle),1)):n("",!0)]),e.actionLabel?(t(),s("button",{key:0,type:"button",class:"sec__action",onClick:c[0]||(c[0]=N=>l("action"))},[_(a(e.actionLabel)+" ",1),d(u,{icon:"vuesax-linear:arrow-left-2",size:"15"})])):n("",!0)]))}}),y=b(k,[["__scopeId","data-v-e5c65b7c"]]);export{y as S};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{d as k,O as A,o as C,s as D,c as i,p as _,z as f,J as w,j as L,m as E,e as m,g as u,f as l,F as y,b as s,r as N,I as g,t as o,i as p,B as M,_ as O}from"./index-BWyw3RAx.js";import{P}from"./PageBar-D5DXU6sO.js";import{S as B}from"./SectionHead-BiCEpXAe.js";import{j as R,p as U}from"./filters-DVocLz3o.js";import{D as I}from"./index-sLHXZqt3.js";import"./KeyCode-CA5MXqE7.js";import"./classCallCheck-Ou87VTNv.js";import"./raf-nDFcuFT6.js";const V={class:"tx"},$={class:"tx__list"},z=["onClick"],Y={class:"tx__main"},j={class:"tx__head"},F={class:"tx__kind"},G={key:0,class:"tx__note"},H={class:"tx__foot"},J={class:"tx__date"},K={key:1,class:"tx__empty"},Q={class:"sheet"},W={class:"sheet__specs"},q={class:"sheet__spec"},X={class:"sheet__spec"},Z={class:"sheet__spec"},ss={class:"sheet__spec"},es={class:"sheet__spec"},ts=k({__name:"TransactionsView",setup(os){const x={ORDER_STOCK:"سفارش",ORDER_PRE_ORDER:"سفارش ساخت",REPAYMENT_MELTED_GOLD:"طلای آبشده",REPAYMENT_TRANSFER:"حواله",REPAYMENT_CHEQUE:"چک",REPAYMENT_CASH:"پولی",WALLET_BONUS:"پاداش",ADJUSTMENT:"اصلاحیه"},T=A(),r=u(!1),d=u([]),v=u(!1),n=u(null),c=a=>a.direction==="CREDIT",h=a=>a.currency==="GOLD_GRAM"?`${a.amount} گرم`:`${U(a.amount)} ریال`,S=a=>{n.value=a,v.value=!0};return C(async()=>{if(T.isLoggedIn){r.value=!0;try{d.value=await D.getMyLedgerEntries()}finally{r.value=!1}}}),(a,e)=>{const b=M("container");return l(),i("div",V,[_(P,{title:"اسناد","back-to":"userPage::hasSubRoute"}),_(w,{spinning:r.value,tip:"درحال دریافت اطلاعات"},{default:f(()=>[_(b,{class:"tx__body"},{default:f(()=>[d.value.length?(l(),i(y,{key:0},[_(B,{title:"گردش حساب",subtitle:`${d.value.length} سند`},null,8,["subtitle"]),s("div",$,[(l(!0),i(y,null,N(d.value,t=>(l(),i("article",{key:t.id,class:"tx__row",onClick:ns=>S(t)},[s("span",{class:p(["tx__mark",c(t)?"is-in":"is-out"]),"aria-hidden":"true"},[_(g,{icon:c(t)?"vuesax-linear:arrow-down":"vuesax-linear:arrow-up",size:"16"},null,8,["icon"])],2),s("div",Y,[s("div",j,[s("h3",F,o(x[t.sourceType]??t.sourceType),1),s("span",{class:p(["tx__amount",c(t)?"is-in":"is-out"])},o(h(t)),3)]),t.note?(l(),i("p",G,o(t.note),1)):m("",!0),s("div",H,[s("span",J,o(E(R)(t.createdAt)),1),s("span",{class:p(["tx__state",{"is-pending":!t.confirmed}])},o(t.confirmed?"تایید شده":"در انتظار تایید"),3)])])],8,z))),128))])],64)):r.value?m("",!0):(l(),i("section",K,[_(g,{icon:"vuesax-linear:receipt-text",size:"26"}),e[1]||(e[1]=s("p",null,"سندی برای حساب شما ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["spinning"]),n.value?(l(),L(E(I),{key:0,visible:v.value,"onUpdate:visible":e[0]||(e[0]=t=>v.value=t),placement:"bottom",closable:!1,height:"420px",class:"rtl",bodyStyle:{padding:0}},{default:f(()=>[s("div",Q,[e[7]||(e[7]=s("h3",{class:"sheet__title"},"جزئیات سند",-1)),s("dl",W,[s("div",q,[e[2]||(e[2]=s("dt",null,"نوع سند",-1)),s("dd",null,o(x[n.value.sourceType]??n.value.sourceType),1)]),s("div",X,[e[3]||(e[3]=s("dt",null,"مبلغ",-1)),s("dd",{class:p(c(n.value)?"is-in":"is-out")},o(h(n.value)),3)]),s("div",Z,[e[4]||(e[4]=s("dt",null,"وضعیت",-1)),s("dd",null,o(n.value.confirmed?"تایید شده":"در انتظار تایید"),1)]),s("div",ss,[e[5]||(e[5]=s("dt",null,"تاریخ",-1)),s("dd",null,o(E(R)(n.value.createdAt)),1)]),s("div",es,[e[6]||(e[6]=s("dt",null,"توضیحات",-1)),s("dd",null,o(n.value.note||"—"),1)])])])]),_:1},8,["visible"])):m("",!0)])}}}),ps=O(ts,[["__scopeId","data-v-3923099b"]]);export{ps as default};
|
||||
import{d as k,O as A,o as C,s as D,c as i,p as _,z as f,J as w,j as L,m as E,e as m,g as u,f as l,F as y,b as s,r as N,I as g,t as o,i as p,B as M,_ as O}from"./index-Dp9sxvBv.js";import{P}from"./PageBar-El-laHqK.js";import{S as B}from"./SectionHead-DNG9ywW4.js";import{j as R,p as U}from"./filters-CUgGgSea.js";import{D as I}from"./index-UuozCFb-.js";import"./KeyCode-DAs3W-5E.js";import"./classCallCheck-BTVV3tFI.js";import"./raf-nDFcuFT6.js";const V={class:"tx"},$={class:"tx__list"},z=["onClick"],Y={class:"tx__main"},j={class:"tx__head"},F={class:"tx__kind"},G={key:0,class:"tx__note"},H={class:"tx__foot"},J={class:"tx__date"},K={key:1,class:"tx__empty"},Q={class:"sheet"},W={class:"sheet__specs"},q={class:"sheet__spec"},X={class:"sheet__spec"},Z={class:"sheet__spec"},ss={class:"sheet__spec"},es={class:"sheet__spec"},ts=k({__name:"TransactionsView",setup(os){const x={ORDER_STOCK:"سفارش",ORDER_PRE_ORDER:"سفارش ساخت",REPAYMENT_MELTED_GOLD:"طلای آبشده",REPAYMENT_TRANSFER:"حواله",REPAYMENT_CHEQUE:"چک",REPAYMENT_CASH:"پولی",WALLET_BONUS:"پاداش",ADJUSTMENT:"اصلاحیه"},T=A(),r=u(!1),d=u([]),v=u(!1),n=u(null),c=a=>a.direction==="CREDIT",h=a=>a.currency==="GOLD_GRAM"?`${a.amount} گرم`:`${U(a.amount)} ریال`,S=a=>{n.value=a,v.value=!0};return C(async()=>{if(T.isLoggedIn){r.value=!0;try{d.value=await D.getMyLedgerEntries()}finally{r.value=!1}}}),(a,e)=>{const b=M("container");return l(),i("div",V,[_(P,{title:"اسناد","back-to":"userPage::hasSubRoute"}),_(w,{spinning:r.value,tip:"درحال دریافت اطلاعات"},{default:f(()=>[_(b,{class:"tx__body"},{default:f(()=>[d.value.length?(l(),i(y,{key:0},[_(B,{title:"گردش حساب",subtitle:`${d.value.length} سند`},null,8,["subtitle"]),s("div",$,[(l(!0),i(y,null,N(d.value,t=>(l(),i("article",{key:t.id,class:"tx__row",onClick:ns=>S(t)},[s("span",{class:p(["tx__mark",c(t)?"is-in":"is-out"]),"aria-hidden":"true"},[_(g,{icon:c(t)?"vuesax-linear:arrow-down":"vuesax-linear:arrow-up",size:"16"},null,8,["icon"])],2),s("div",Y,[s("div",j,[s("h3",F,o(x[t.sourceType]??t.sourceType),1),s("span",{class:p(["tx__amount",c(t)?"is-in":"is-out"])},o(h(t)),3)]),t.note?(l(),i("p",G,o(t.note),1)):m("",!0),s("div",H,[s("span",J,o(E(R)(t.createdAt)),1),s("span",{class:p(["tx__state",{"is-pending":!t.confirmed}])},o(t.confirmed?"تایید شده":"در انتظار تایید"),3)])])],8,z))),128))])],64)):r.value?m("",!0):(l(),i("section",K,[_(g,{icon:"vuesax-linear:receipt-text",size:"26"}),e[1]||(e[1]=s("p",null,"سندی برای حساب شما ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["spinning"]),n.value?(l(),L(E(I),{key:0,visible:v.value,"onUpdate:visible":e[0]||(e[0]=t=>v.value=t),placement:"bottom",closable:!1,height:"420px",class:"rtl",bodyStyle:{padding:0}},{default:f(()=>[s("div",Q,[e[7]||(e[7]=s("h3",{class:"sheet__title"},"جزئیات سند",-1)),s("dl",W,[s("div",q,[e[2]||(e[2]=s("dt",null,"نوع سند",-1)),s("dd",null,o(x[n.value.sourceType]??n.value.sourceType),1)]),s("div",X,[e[3]||(e[3]=s("dt",null,"مبلغ",-1)),s("dd",{class:p(c(n.value)?"is-in":"is-out")},o(h(n.value)),3)]),s("div",Z,[e[4]||(e[4]=s("dt",null,"وضعیت",-1)),s("dd",null,o(n.value.confirmed?"تایید شده":"در انتظار تایید"),1)]),s("div",ss,[e[5]||(e[5]=s("dt",null,"تاریخ",-1)),s("dd",null,o(E(R)(n.value.createdAt)),1)]),s("div",es,[e[6]||(e[6]=s("dt",null,"توضیحات",-1)),s("dd",null,o(n.value.note||"—"),1)])])])]),_:1},8,["visible"])):m("",!0)])}}}),ps=O(ts,[["__scopeId","data-v-3923099b"]]);export{ps as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{a0 as n}from"./index-BWyw3RAx.js";function l(a,t){for(var r=n({},a),e=0;e<t.length;e+=1){var o=t[e];delete r[o]}return r}function c(a,t,r){return Object.defineProperty(a,"prototype",{writable:!1}),a}function f(a,t){if(!(a instanceof t))throw new TypeError("Cannot call a class as a function")}export{c as _,f as a,l as o};
|
||||
import{a0 as n}from"./index-Dp9sxvBv.js";function l(a,t){for(var r=n({},a),e=0;e<t.length;e+=1){var o=t[e];delete r[o]}return r}function c(a,t,r){return Object.defineProperty(a,"prototype",{writable:!1}),a}function f(a,t){if(!(a instanceof t))throw new TypeError("Cannot call a class as a function")}export{c as _,f as a,l as o};
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{d as H,b6 as v,p as n,b7 as w,b8 as A,b9 as P,h as B,ba as C,bb as L,bc as _,bd as N,Q as U,be as M,g as D,K as R,bf as Y,x as F,bg as S,bh as G,bi as K}from"./index-BWyw3RAx.js";const[Q,h]=P("loading"),X=Array(12).fill(null).map((t,l)=>n("i",{class:h("line",String(l+1))},null)),j=n("svg",{class:h("circular"),viewBox:"25 25 50 50"},[n("circle",{cx:"50",cy:"50",r:"20",fill:"none"},null)]),q={size:v,type:A("circular"),color:String,vertical:Boolean,textSize:v,textColor:String};var J=H({name:Q,props:q,setup(t,{slots:l}){const o=B(()=>C({color:t.color},L(t.size))),c=()=>{const i=t.type==="spinner"?X:j;return n("span",{class:h("spinner",t.type),style:o.value},[l.icon?l.icon():i])},g=()=>{var i;if(l.default)return n("span",{class:h("text"),style:{fontSize:w(t.textSize),color:(i=t.textColor)!=null?i:t.color}},[l.default()])};return()=>{const{type:i,vertical:m}=t;return n("div",{class:h([i,{vertical:m}]),"aria-live":"polite","aria-busy":!0},[c(),g()])}}});const O=_(J),[W,f,Z]=P("pull-refresh"),k=50,ee=["pulling","loosing","success"],te={disabled:Boolean,modelValue:Boolean,headHeight:S(k),successText:String,pullingText:String,loosingText:String,loadingText:String,pullDistance:v,successDuration:S(500),animationDuration:S(300)};var ae=H({name:W,props:te,emits:["change","refresh","update:modelValue"],setup(t,{emit:l,slots:o}){let c;const g=D(),i=D(),m=N(g),a=R({status:"normal",distance:0,duration:0}),r=K(),p=()=>{if(t.headHeight!==k)return{height:`${t.headHeight}px`}},T=()=>a.status!=="loading"&&a.status!=="success"&&!t.disabled,$=e=>{const s=+(t.pullDistance||t.headHeight);return e>s&&(e<s*2?e=s+(e-s)/2:e=s*1.5+(e-s*2)/4),Math.round(e)},u=(e,s)=>{const d=+(t.pullDistance||t.headHeight);a.distance=e,s?a.status="loading":e===0?a.status="normal":e<d?a.status="pulling":a.status="loosing",l("change",{status:a.status,distance:e})},x=()=>{const{status:e}=a;return e==="normal"?"":t[`${e}Text`]||Z(e)},z=()=>{const{status:e,distance:s}=a;if(o[e])return o[e]({distance:s});const d=[];return ee.includes(e)&&d.push(n("div",{class:f("text")},[x()])),e==="loading"&&d.push(n(O,{class:f("loading")},{default:x})),d},E=()=>{a.status="success",setTimeout(()=>{u(0)},+t.successDuration)},b=e=>{c=G(m.value)===0,c&&(a.duration=0,r.start(e))},I=e=>{T()&&b(e)},V=e=>{if(T()){c||b(e);const{deltaY:s}=r;r.move(e),c&&s.value>=0&&r.isVertical()&&(Y(e),u($(s.value)))}},y=()=>{c&&r.deltaY.value&&T()&&(a.duration=+t.animationDuration,a.status==="loosing"?(u(+t.headHeight,!0),l("update:modelValue",!0),F(()=>l("refresh"))):u(0))};return U(()=>t.modelValue,e=>{a.duration=+t.animationDuration,e?u(+t.headHeight,!0):o.success||t.successText?E():u(0,!1)}),M("touchmove",V,{target:i}),()=>{var e;const s={transitionDuration:`${a.duration}ms`,transform:a.distance?`translate3d(0,${a.distance}px, 0)`:""};return n("div",{ref:g,class:f()},[n("div",{ref:i,class:f("track"),style:s,onTouchstartPassive:I,onTouchend:y,onTouchcancel:y},[n("div",{class:f("head"),style:p()},[z()]),(e=o.default)==null?void 0:e.call(o)])])}}});const ne=_(ae);export{O as L,ne as P};
|
||||
import{d as H,b6 as v,p as n,b7 as w,b8 as A,b9 as P,h as B,ba as C,bb as L,bc as _,bd as N,Q as U,be as M,g as D,K as R,bf as Y,x as F,bg as S,bh as G,bi as K}from"./index-Dp9sxvBv.js";const[Q,h]=P("loading"),X=Array(12).fill(null).map((t,l)=>n("i",{class:h("line",String(l+1))},null)),j=n("svg",{class:h("circular"),viewBox:"25 25 50 50"},[n("circle",{cx:"50",cy:"50",r:"20",fill:"none"},null)]),q={size:v,type:A("circular"),color:String,vertical:Boolean,textSize:v,textColor:String};var J=H({name:Q,props:q,setup(t,{slots:l}){const o=B(()=>C({color:t.color},L(t.size))),c=()=>{const i=t.type==="spinner"?X:j;return n("span",{class:h("spinner",t.type),style:o.value},[l.icon?l.icon():i])},g=()=>{var i;if(l.default)return n("span",{class:h("text"),style:{fontSize:w(t.textSize),color:(i=t.textColor)!=null?i:t.color}},[l.default()])};return()=>{const{type:i,vertical:m}=t;return n("div",{class:h([i,{vertical:m}]),"aria-live":"polite","aria-busy":!0},[c(),g()])}}});const O=_(J),[W,f,Z]=P("pull-refresh"),k=50,ee=["pulling","loosing","success"],te={disabled:Boolean,modelValue:Boolean,headHeight:S(k),successText:String,pullingText:String,loosingText:String,loadingText:String,pullDistance:v,successDuration:S(500),animationDuration:S(300)};var ae=H({name:W,props:te,emits:["change","refresh","update:modelValue"],setup(t,{emit:l,slots:o}){let c;const g=D(),i=D(),m=N(g),a=R({status:"normal",distance:0,duration:0}),r=K(),p=()=>{if(t.headHeight!==k)return{height:`${t.headHeight}px`}},T=()=>a.status!=="loading"&&a.status!=="success"&&!t.disabled,$=e=>{const s=+(t.pullDistance||t.headHeight);return e>s&&(e<s*2?e=s+(e-s)/2:e=s*1.5+(e-s*2)/4),Math.round(e)},u=(e,s)=>{const d=+(t.pullDistance||t.headHeight);a.distance=e,s?a.status="loading":e===0?a.status="normal":e<d?a.status="pulling":a.status="loosing",l("change",{status:a.status,distance:e})},x=()=>{const{status:e}=a;return e==="normal"?"":t[`${e}Text`]||Z(e)},z=()=>{const{status:e,distance:s}=a;if(o[e])return o[e]({distance:s});const d=[];return ee.includes(e)&&d.push(n("div",{class:f("text")},[x()])),e==="loading"&&d.push(n(O,{class:f("loading")},{default:x})),d},E=()=>{a.status="success",setTimeout(()=>{u(0)},+t.successDuration)},b=e=>{c=G(m.value)===0,c&&(a.duration=0,r.start(e))},I=e=>{T()&&b(e)},V=e=>{if(T()){c||b(e);const{deltaY:s}=r;r.move(e),c&&s.value>=0&&r.isVertical()&&(Y(e),u($(s.value)))}},y=()=>{c&&r.deltaY.value&&T()&&(a.duration=+t.animationDuration,a.status==="loosing"?(u(+t.headHeight,!0),l("update:modelValue",!0),F(()=>l("refresh"))):u(0))};return U(()=>t.modelValue,e=>{a.duration=+t.animationDuration,e?u(+t.headHeight,!0):o.success||t.successText?E():u(0,!1)}),M("touchmove",V,{target:i}),()=>{var e;const s={transitionDuration:`${a.duration}ms`,transform:a.distance?`translate3d(0,${a.distance}px, 0)`:""};return n("div",{ref:g,class:f()},[n("div",{ref:i,class:f("track"),style:s,onTouchstartPassive:I,onTouchend:y,onTouchcancel:y},[n("div",{class:f("head"),style:p()},[z()]),(e=o.default)==null?void 0:e.call(o)])])}}});const ne=_(ae);export{O as L,ne as P};
|
||||
Vendored
+23
-23
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user