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

- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
+11
View File
@@ -279,6 +279,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'),
/**
@@ -307,6 +316,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'),
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
+8
View File
@@ -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';
@@ -9,6 +11,12 @@ import { useAppearanceStore } from './stores/appearanceStore';
import Container from './components/ui/Container.vue';
import Vector from './assets/img/Vector.svg';
// 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);
// Global styles, ported from legacy henza_pwa/src/main.ts — same import order
// (ant-design-vue's own theme first, then our Less overrides on top). Legacy
// imported 'vant/lib/index.css' twice (a dead duplicate) — imported once here.
@@ -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'
@@ -291,7 +291,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;