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
@@ -294,6 +294,15 @@ export interface ReportBalance {
|
||||
arz: string;
|
||||
mandeh: string;
|
||||
}
|
||||
/** GET /customer/me/accounting-balance. Signed like the app: positive = owes. */
|
||||
export interface AccountingBalance {
|
||||
source: 'accounting' | 'stored' | 'none'
|
||||
gold: string | null
|
||||
rial: string | null
|
||||
at: string | null
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
|
||||
@@ -323,6 +332,8 @@ export const api = {
|
||||
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
|
||||
// the URL and let anyone read anyone (SEC-08).
|
||||
getMe: () => request<CustomerDetail>('/customer/me'),
|
||||
/** D27 §12 — this customer's balance, read from the accounting software on every call. */
|
||||
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
||||
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
||||
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import VueLazyLoad from 'vue3-lazyload';
|
||||
import { Locale } from 'vant';
|
||||
import faIR from 'vant/es/locale/lang/fa-IR';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import { setUnauthorizedHandler } from './api/client';
|
||||
@@ -23,6 +25,12 @@ import 'ant-design-vue/dist/antd.less';
|
||||
import './assets/styles/style.less';
|
||||
import 'vant/lib/index.css';
|
||||
|
||||
// Vant defaults to zh-CN. Every view in this app is Persian (RTL), so any
|
||||
// Vant component rendering its own built-in text (PullRefresh's
|
||||
// pulling/loosing labels, etc.) would otherwise show untranslated Chinese —
|
||||
// switch to Vant's own fa-IR locale before anything mounts.
|
||||
Locale.use('fa-IR', faIR);
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
|
||||
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
|
||||
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<template #pulling><Loading size="18" /></template>
|
||||
<template #loosing><Loading size="18" /></template>
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<container class="cho__body">
|
||||
<template v-if="rows.length">
|
||||
<div class="cho__list">
|
||||
@@ -142,7 +146,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { PullRefresh } from 'vant'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
|
||||
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
|
||||
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<template #pulling><Loading size="18" /></template>
|
||||
<template #loosing><Loading size="18" /></template>
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<container class="cch__body">
|
||||
<div v-if="rows.length" class="cch__list">
|
||||
<article
|
||||
@@ -132,7 +136,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { PullRefresh } from 'vant'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
|
||||
<Spinner :spinning="loading && !rows.length && !details.length" tip="درحال دریافت اطلاعات">
|
||||
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<template #pulling><Loading size="18" /></template>
|
||||
<template #loosing><Loading size="18" /></template>
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<container class="rep__body">
|
||||
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
|
||||
<template v-if="activeKey === '1'">
|
||||
@@ -147,7 +151,7 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { PullRefresh } from 'vant'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
|
||||
@@ -245,7 +245,15 @@ function logOut() {
|
||||
|
||||
async function loadDashboard() {
|
||||
if (session.isLoggedIn) {
|
||||
customer.value = await api.getMe()
|
||||
// Not wrapped before: a failure here (expired token, network blip) left
|
||||
// the exception uncaught, so onRefresh's refreshing.value = false never
|
||||
// ran and PullRefresh stayed stuck in its loading state — a working pull
|
||||
// gesture never dismissed it again until a full page reload.
|
||||
try {
|
||||
customer.value = await api.getMe()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
try {
|
||||
goldPrice.value = await api.getLatestGoldPrice()
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
|
||||
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
|
||||
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<template #pulling><Loading size="18" /></template>
|
||||
<template #loosing><Loading size="18" /></template>
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<container class="ord__body">
|
||||
<template v-if="filteredOrders.length">
|
||||
<div class="ord__list">
|
||||
@@ -145,7 +149,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { PullRefresh } from 'vant'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
|
||||
@@ -56,6 +56,25 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- D27 §12: the balance in the accounting software, re-read on every visit. -->
|
||||
<section v-if="balance && balance.source !== 'none'" class="prof__block">
|
||||
<SectionHead title="ماندهی حساب" :subtitle="balanceSubtitle" />
|
||||
<dl class="prof__specs">
|
||||
<div class="prof__spec">
|
||||
<dt>ماندهی طلا</dt>
|
||||
<dd :class="sideClass(balance.gold)">{{ grams(balance.gold) }} <small>{{ sideLabel(balance.gold) }}</small></dd>
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>ماندهی ریالی</dt>
|
||||
<dd :class="sideClass(balance.rial)">{{ rials(balance.rial) }} <small>{{ sideLabel(balance.rial) }}</small></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section v-else-if="balanceLoading" class="prof__block">
|
||||
<SectionHead title="ماندهی حساب" subtitle="در حال دریافت از حسابداری…" />
|
||||
<div class="prof__specs prof__skeleton" />
|
||||
</section>
|
||||
|
||||
<section v-else-if="!loading" class="prof__empty">
|
||||
<Icon icon="vuesax-linear:user" size="26" />
|
||||
<p>اطلاعات حساب در دسترس نیست.</p>
|
||||
@@ -85,7 +104,7 @@ import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { api, type CustomerDetail } from '../../api/client'
|
||||
import { api, type AccountingBalance, type CustomerDetail } from '../../api/client'
|
||||
|
||||
const VERIFICATION_LABELS: Record<string, string> = {
|
||||
UNVERIFIED: 'احراز نشده',
|
||||
@@ -112,8 +131,38 @@ const canCompleteProfile = computed(() => {
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// D27 §12 — fetched on its own, every time the page opens, so a slow
|
||||
// accounting server never holds up the profile itself.
|
||||
const balance = ref<AccountingBalance | null>(null)
|
||||
const balanceLoading = ref(false)
|
||||
|
||||
const num = (v: string | null) => Number(v ?? 0)
|
||||
const sideLabel = (v: string | null) => (num(v) > 0 ? 'بدهکار' : num(v) < 0 ? 'بستانکار' : '')
|
||||
const sideClass = (v: string | null) => (num(v) > 0 ? 'is-debt' : num(v) < 0 ? 'is-credit' : '')
|
||||
const grams = (v: string | null) =>
|
||||
`${Math.abs(num(v)).toLocaleString('fa-IR', { maximumFractionDigits: 3 })} گرم`
|
||||
const rials = (v: string | null) => `${Math.abs(num(v)).toLocaleString('fa-IR')} ریال`
|
||||
const balanceSubtitle = computed(() => {
|
||||
const b = balance.value
|
||||
if (!b?.at) return ''
|
||||
const when = new Date(b.at).toLocaleString('fa-IR', { dateStyle: 'short', timeStyle: 'short' })
|
||||
return b.source === 'stored' ? `آخرین ماندهی ثبتشده — ${when}` : `از ${b.provider ?? 'حسابداری'} — ${when}`
|
||||
})
|
||||
|
||||
async function loadBalance() {
|
||||
balanceLoading.value = true
|
||||
try {
|
||||
balance.value = await api.getAccountingBalance()
|
||||
} catch {
|
||||
balance.value = null
|
||||
} finally {
|
||||
balanceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!session.isLoggedIn) return
|
||||
void loadBalance()
|
||||
loading.value = true
|
||||
try {
|
||||
customer.value = await api.getMe()
|
||||
@@ -225,6 +274,24 @@ onMounted(async () => {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prof__spec dd.is-debt {
|
||||
color: @wine;
|
||||
}
|
||||
|
||||
.prof__spec dd.is-credit {
|
||||
color: @green;
|
||||
}
|
||||
|
||||
.prof__spec dd small {
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.prof__skeleton {
|
||||
height: 92px;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.prof__empty {
|
||||
margin-top: 26px;
|
||||
padding: 34px 20px;
|
||||
|
||||
Reference in New Issue
Block a user