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
@@ -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;