diff --git a/README.md b/README.md index c49bce2..5b56f06 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,280 @@ -# customer +# Khanegi — اپ مشتری (User Panel) -This template should help get you started developing with Vue 3 in Vite. +پنل وب/PWA برای **مشتریان خانگی** پلتفرم معاملات طلا و آبشده. این اپ بخش front-end مشتری در monorepo پروژه Khanegi است. -## Recommended IDE Setup +| مورد | مقدار | +|------|--------| +| نام پکیج | `customer` | +| نسخه | `1.0.8` | +| فریم‌ورک | Vue 3 + TypeScript | +| بیلد | Vite 4 | +| State | Pinia | +| UI | Ant Design Vue 3 + Vant 4 (موبایل) | +| پورت dev | `3100` | -[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). +--- -## Type Support for `.vue` Imports in TS +## نقش در معماری Khanegi -TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. +``` +Khanegi/ +├── userModular/ ← این پروژه (پنل مشتری) +├── admin/ ← پنل مدیریت +└── server/ ← API اصلی (Laravel) +``` -If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: +| اتصال | پروتکل | آدرس | +|--------|--------|------| +| API اصلی | HTTPS + JWT | `{node_server}/customer/*`, `/auth/*` | +| قیمت لحظه‌ای | SSE | `{node_server}/api/price/stream` | +| تنظیمات tenant | HTTPS | `/get_user` (قبل از لود اپ) | -1. Disable the built-in TypeScript Extension - 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette - 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` -2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. +--- -## Customize configuration +## پیش‌نیازها -See [Vite Configuration Reference](https://vitejs.dev/config/). +- Node.js 18+ +- npm، yarn یا pnpm -## Project Setup +--- -```sh +## نصب و اجرا + +```bash +cd userModular npm install +npm run dev # http://localhost:3100 +npm run build # vite build +npm run preview # پیش‌نمایش production +npm run type-check # بررسی TypeScript +npm run lint # ESLint +npm run format # Prettier ``` -### Compile and Hot-Reload for Development +--- -```sh -npm run dev +## ساختار پروژه + +``` +userModular/ +├── src/ +│ ├── views/ # صفحات اصلی +│ │ ├── login/ # ورود / ثبت‌نام +│ │ └── base/ # صفحات اپ (mobile/ + desktop/) +│ ├── api/ # لایه REST (18 ماژول) +│ ├── router/ # مسیرها + guard +│ ├── store/ # Pinia (user, wallet, fund, settings, config) +│ ├── config/ # بارگذاری tenant config +│ ├── app/ # bootstrap.ts, createApp.ts, setupRouter +│ ├── utils/ # priceSocket (SSE), authToken, useAxios +│ ├── modules/ # PWA manifest +│ ├── models/ # TypeScript types +│ ├── layouts/ # Layout موبایل/دسکتاپ +│ └── sw.ts # Service Worker (PWA) +├── public/ +├── index.html +├── vite.config.ts +├── uno.config.ts +├── themeConfig.ts +└── src/mainConfig.ts ``` -### Type-Check, Compile and Minify for Production +--- -```sh -npm run build -``` +## قابلیت‌های اصلی -### Lint with [ESLint](https://eslint.org/) +### احراز هویت +- ورود با رمز عبور یا SMS +- تأیید OTP (`POST /auth/verify`) +- ثبت‌نام (`POST /auth/register`) — کد معرف، احراز Jibit (اختیاری) +- Provider: `customer` -```sh -npm run lint -``` +### صفحه اصلی و معاملات +- نمودار قیمت و صفحه home +- خرید/فروش طلا (`/gold`) +- خرید محصول (`/buyProduct`) +- قیمت لحظه‌ای از SSE با fallback polling + +### کیف پول و تراکنش‌ها +- مانده ریال و طلا (`/wallet`) +- تاریخچه تراکنش‌ها (`/transactions`) +- برداشت نقدی و فیزیکی +- شارژ از درگاه PayPing + +### صندوق (Fund) +- لیست صندوق‌ها (`/fund`) +- جزئیات صندوق (`/fund/:id`) +- واریز/برداشت صندوق + +### سایر +- پروفایل کاربر (`/user`) +- آدرس (`/address`) +- دریافت فیزیکی طلا (`/physicalReceive`) +- هشدار قیمت (`/notifyTrigger`) +- کسب درآمد / معرف (`/earnMoney`) +- کارت هدیه (`/giftCard`) +- قوانین و مجوزها (`/rules`, `/permissions`) +- آموزش (`/educate`) +- درباره ما (`/aboutUs`) + +### حالت فروشگاهی (`is_store`) +- سبد خرید (`/api/cart/*`) +- حواله (`/customer/havale`) +- checkout (`/customer/cart/checkout`) + +--- + +## مسیرها (Routes) + +| مسیر | نام | توضیح | +|------|-----|--------| +| `/login` | login | ورود | +| `/home` | home | صفحه اصلی / نمودار | +| `/gold` | gold | معامله طلا | +| `/buyProduct` | buyProduct | خرید محصول | +| `/wallet` | wallet | کیف پول | +| `/transactions` | transactions | تراکنش‌ها | +| `/user` | user | پروفایل | +| `/notifyTrigger` | notifyTrigger | هشدار قیمت | +| `/address` | address | آدرس | +| `/physicalReceive` | physicalReceive | دریافت فیزیکی | +| `/physicalReceive/new` | new | درخواست جدید | +| `/earnMoney` | earnMoney | کسب درآمد / معرف | +| `/giftCard` | giftCard | کارت هدیه | +| `/fund` | fund | صندوق‌ها | +| `/fund/:id` | fund_info | جزئیات صندوق | +| `/fund/add` | fund_add | صندوق جدید | +| `/rules` | rules | قوانین | +| `/permissions` | permissions | مجوزها | +| `/educate` | educate | آموزش | +| `/aboutUs` | aboutUs | درباره ما | + +**ساختار UI:** هر صفحه معمولاً `mobile/` و `desktop/` دارد. + +**Guard:** بدون token به `/login` هدایت می‌شود. + +--- + +## اتصال به Backend + +### Bootstrap + +1. `index.html` — loader +2. `bootstrap.ts` → `loadAppConfig()` — fetch config tenant +3. `setAppConfig()` + `applyAppConfig()` — نگاشت `node_server` +4. `startPwaManifest()` — PWA +5. mount Vue؛ اگر token باشد: `getUserInfo()` + `initPriceRealtimeChannel()` + +### REST API + +- **Base URL:** `getConfigApp('node_server')` ← فیلد `node_server` در JSON tenant +- **Token:** `customer_session_token` (ثابت) یا `{name}_app_token` (legacy) +- **Header:** `Authorization: Bearer ` +- **Axios:** `src/utils/useAxios.ts` — 401 → logout؛ verify با `/customer/me` + +| پیشوند | کاربرد | +|--------|--------| +| `/auth/*` | login, verify, register | +| `/customer/*` | me, order, wallet, fund, address | +| `/api/*` | candle, price/last, city, get_settings | + +### قیمت لحظه‌ای (SSE) + +- **فایل:** `src/utils/priceSocket.ts` +- **Endpoint:** `GET /api/price/stream` (EventSource) +- **Fallback:** polling هر 10 ثانیه اگر SSE قطع شود +- **Reconnection:** exponential backoff (3s → 30s) +- در حالت non-store نیاز به JWT customer دارد + +### Multi-tenant Config + +URLهای failover برای `/get_user`: + +- `https://nerkh.gold/get_user` +- `https://tablo.talasevin.ir/get_user` +- `https://abshode-service.montgit.ir/server/get_user` +- `http://212.23.201.173:8381/get_user` +- `http://91.212.174.137:4040/server/get_user` + +| فیلد JSON | معادل اپ | +|-----------|----------| +| `node_server` | API base | +| `name`, `name_fa`, `avatar` | برند | +| `color`, `background_color` | تم | + +Cache: `localStorage.CONFIG_Success_URL` + cookie `APP_CONFIG_CACHE` + +--- + +## Pinia Stores + +| Store | نقش | +|-------|-----| +| `userStore` | اطلاعات کاربر، auth | +| `walletStore` | مانده کیف پول | +| `fundStore` | صندوق‌ها | +| `settingsStore` | تنظیمات اپ | +| `configStore` | config tenant | +| `useAuthStore` | وضعیت احراز هویت | + +--- + +## API Modules + +| فایل | Endpoints | +|------|-----------| +| `login.ts` | `/auth/login`, `/auth/verify`, `/auth/register` | +| `me.ts` | `/customer/me`, `/customer/me/update` | +| `home.ts` | نمودار، قیمت | +| `order.ts` | `/customer/order` | +| `wallet.ts` | `/customer/wallet/*` | +| `fund.ts` | `/customer/fund/*` | +| `products.ts` | محصولات | +| `address.ts` | `/customer/address` | +| `giftCard.ts` | `/customer/giftcard/use` | +| `earnMoney.ts` | معرف | +| `triggerPrice.ts` | هشدار قیمت | +| `physicalreceive.ts` | دریافت فیزیکی | +| `configuration.ts` | `/api/get_settings` | + +--- + +## PWA + +- `vite-plugin-pwa` با `injectManifest` +- Service Worker: `src/sw.ts` +- Manifest: نام و آیکون از config tenant +- `display: standalone`, `orientation: portrait-primary` + +--- + +## فایل‌های Config مهم + +| فایل | نقش | +|------|-----| +| `vite.config.ts` | Vite، PWA، UnoCSS، port 3100 | +| `src/config/load-config.ts` | fetch config ریموت | +| `src/config/app-config.ts` | نگاشت tenant | +| `src/themeConfig.ts` | token keys، رنگ‌ها | +| `src/utils/useAxios.ts` | Axios + auth | +| `src/router/index.ts` | routes | +| `src/app/bootstrap.ts` | راه‌اندازی اپ | +| `src/sw.ts` | Service Worker | + +--- + +## نکات توسعه + +1. **دو لایه realtime:** REST روی Laravel؛ قیمت از SSE با fallback polling. +2. **Multi-tenant:** یک build برای چند برند؛ رفتار با `settings` و `is_store` تغییر می‌کند. +3. **Responsive:** هر view دو نسخه `mobile/` و `desktop/` دارد. +4. **Token persistence:** `customer_session_token` برای جلوگیری از گم شدن token هنگام به‌روزرسانی config. +5. **انیمیشن:** Lottie (`@lottiefiles/dotlottie-vue`)، Odometer برای اعداد. + +--- + +## مستندات مرتبط + +- [README سرور Laravel](../server/README.md) +- [README پنل ادمین](../admin/README.md) +- [README نمای کلی](../README.md) diff --git a/src/models/products.ts b/src/models/products.ts index 4268cdb..70c5aac 100644 --- a/src/models/products.ts +++ b/src/models/products.ts @@ -20,6 +20,8 @@ export interface Product { percent_fee_sell: number | undefined cash_fee_buy: number | undefined cash_fee_sell: number | undefined + cash_fee_buy_is_percent?: boolean + cash_fee_sell_is_percent?: boolean order_limit_enabled?: boolean min_order_cash?: number | null max_order_cash?: number | null @@ -56,6 +58,8 @@ export interface PInventory { percent_fee_sell: number cash_fee_buy: number cash_fee_sell: number + cash_fee_buy_is_percent?: boolean + cash_fee_sell_is_percent?: boolean reading_method: any created_at: any updated_at: any diff --git a/src/utils/productAssets.ts b/src/utils/productAssets.ts index eccd939..b2ca6b8 100644 --- a/src/utils/productAssets.ts +++ b/src/utils/productAssets.ts @@ -1,5 +1,5 @@ import type { Product } from '@/models/products' -import { formatWeightAmount, usePriceWithoutType } from '@/utils/usePrice' +import { formatGoldAmount, formatWeightAmount, usePriceWithoutType } from '@/utils/usePrice' import type { UserWalletLine } from '@/models/wallet' import type { ProductFile } from '@/utils/file' import type { WalletInventoryCard, WalletMetalTotal } from '@/models/walletAssets' @@ -426,6 +426,48 @@ export function formatPortfolioProfitPercent(percent?: number | null): string { return `${sign}${value.toFixed(2)}٪` } +/** متن توضیح مودال سود/زیان تحقق‌نیافته طلا */ +export function formatPortfolioProfitInfoText( + portfolio: { + average_buy_price?: number | null + live_gold_price?: number | null + cost_basis_rial?: number | null + gold_weight?: number | null + gold_rial_value?: number | null + unrealized_profit_rial?: number | null + unrealized_profit_percent?: number | null + }, + weightUnit = 'گرم' +): string { + const lines: string[] = [] + + if (portfolio.average_buy_price != null && portfolio.average_buy_price > 0) { + lines.push(`میانگین قیمت خرید: ${formatRialNumber(portfolio.average_buy_price)} ریال`) + } + if (portfolio.live_gold_price != null && portfolio.live_gold_price > 0) { + lines.push(`قیمت فعلی طلا: ${formatRialNumber(portfolio.live_gold_price)} ریال`) + } + if (portfolio.gold_weight != null && portfolio.gold_weight > 0) { + lines.push(`وزن طلای شما: ${formatWeightAmount(portfolio.gold_weight)} ${weightUnit}`) + } + if (portfolio.cost_basis_rial != null && portfolio.cost_basis_rial > 0) { + lines.push(`بهای تمام‌شده طلا: ${formatRialNumber(portfolio.cost_basis_rial)} ریال`) + } + if (portfolio.gold_rial_value != null && portfolio.gold_rial_value > 0) { + lines.push(`ارزش فعلی طلا: ${formatRialNumber(portfolio.gold_rial_value)} ریال`) + } + if (portfolio.unrealized_profit_rial != null) { + const pct = formatPortfolioProfitPercent(portfolio.unrealized_profit_percent) + lines.push( + `سود/زیان تحقق‌نیافته: ${formatPortfolioProfitText(portfolio.unrealized_profit_rial)}${pct ? ` (${pct})` : ''}` + ) + } + + lines.push('محاسبه بر اساس میانگین وزنی قیمت خرید و قیمت لحظه‌ای بازار است.') + + return lines.join('\n') +} + /** متن توضیح مودال دارایی ریالی */ export function formatRialAssetsInfoText( cashWallet: number, @@ -545,8 +587,10 @@ export function formatProductWalletBalanceText( const quantity = card ? Number(card.quantity ?? 0) : Number(mainWalletGold ?? 0) const unit = card?.unit ?? getProductQuantityUnit(product) const title = product?.title?.trim() || 'محصول' + const formattedQty = + unit === 'عدد' ? usePriceWithoutType(quantity, false) : formatGoldAmount(quantity) - return `موجودی کیف ${title}: ${quantity} ${unit}` + return `موجودی کیف ${title}: ${formattedQty} ${unit}` } export function isSingleMetalCatalog(metalIds: number[]): boolean { diff --git a/src/utils/productCashFee.ts b/src/utils/productCashFee.ts new file mode 100644 index 0000000..bcd259c --- /dev/null +++ b/src/utils/productCashFee.ts @@ -0,0 +1,76 @@ +export type ProductCashFeeSource = { + cash_fee_buy?: number | null + cash_fee_sell?: number | null + cash_fee_buy_is_percent?: boolean | null + cash_fee_sell_is_percent?: boolean | null +} + +/** 0.5 => 0.5% ، 5 => 5% — همیشه عدد درصد */ +export function resolveCashFeePercentRatio(percentValue?: number | null) { + const offset = Number(percentValue ?? 0) + if (!offset || !Number.isFinite(offset)) { + return 0 + } + + return offset / 100 +} + +/** @deprecated از resolveCashFeePercentRatio استفاده کنید */ +export function resolveFeePercentRatio(percentValue?: number | null) { + return resolveCashFeePercentRatio(percentValue) +} + +export function resolveProductCashFeeRial( + feeValue: number | null | undefined, + isPercent: boolean | null | undefined, + basePriceRial: number, +) { + const raw = Number(feeValue ?? 0) + if (!raw || !Number.isFinite(raw)) { + return 0 + } + + if (!isPercent) { + return Math.round(raw) + } + + const base = Number(basePriceRial) + if (!base || base <= 0) { + return 0 + } + + return Math.round(Math.abs(base * resolveCashFeePercentRatio(raw))) +} + +export function resolveProductCashFeeBuyRial(product: ProductCashFeeSource, basePriceRial: number) { + return resolveProductCashFeeRial( + product.cash_fee_buy, + product.cash_fee_buy_is_percent, + basePriceRial, + ) +} + +export function resolveProductCashFeeSellRial(product: ProductCashFeeSource, basePriceRial: number) { + return resolveProductCashFeeRial( + product.cash_fee_sell, + product.cash_fee_sell_is_percent, + basePriceRial, + ) +} + +export function formatProductCashFeeLabel( + feeValue: number | null | undefined, + isPercent?: boolean | null, +) { + const raw = Number(feeValue ?? 0) + if (!raw) { + return '۰' + } + + if (isPercent) { + const text = Number.isInteger(raw) ? String(raw) : String(parseFloat(raw.toFixed(4))) + return `${text}%` + } + + return `${raw} ریال` +} diff --git a/src/utils/productDealMethod.ts b/src/utils/productDealMethod.ts index b700d05..6b63381 100644 --- a/src/utils/productDealMethod.ts +++ b/src/utils/productDealMethod.ts @@ -13,6 +13,9 @@ import { usesQuantityTrade, } from '@/utils/productCategories' import { formatInventoryCardValue } from '@/utils/productAssets' +import { resolveProductCashFeeBuyRial, resolveProductCashFeeSellRial } from '@/utils/productCashFee' +import { TRADE_GRAM_DISPLAY_DECIMALS } from '@/utils/serviceFee' +import { formatGoldAmount } from '@/utils/usePrice' /** @deprecated از isMeltedTradeGroup استفاده کنید */ export const BOURSECU_GROUP_ABSHODE = PRODUCT_CATEGORY_ABSHODE @@ -369,7 +372,7 @@ export function resolveProductTradeBuyUnitPrice(product?: Product | null) { return 0 } - const basePrice = (product.price ?? 0) + (product.cash_fee_buy ?? 0) + const basePrice = (product.price ?? 0) + resolveProductCashFeeBuyRial(product, product.price ?? 0) return basePrice * productPriceAmountFactor(product) } @@ -378,7 +381,7 @@ export function resolveProductTradeSellUnitPrice(product?: Product | null) { return 0 } - const basePrice = (product.price ?? 0) - (product.cash_fee_sell ?? 0) + const basePrice = (product.price ?? 0) - resolveProductCashFeeSellRial(product, product.price ?? 0) return basePrice * productPriceAmountFactor(product) } @@ -635,6 +638,57 @@ export function resolveTransactionTradeProduct( return item.wallet?.product ?? null } +/** معامله آبشده / کیف طلای اصلی — نه سکه و محصول تعدادی */ +export function isMeltedGoldTransaction( + item?: (TransactionTradeProductSource & { type?: number | null; title?: string | null }) | null, +): boolean { + if (!item) { + return false + } + + const tradeProduct = resolveTransactionTradeProduct(item) + if (tradeProduct) { + if (usesQuantityTrade(tradeProduct) && !isMeltedTradeGroup(tradeProduct.group_type)) { + return false + } + if (isMeltedTradeGroup(tradeProduct.group_type)) { + return true + } + if (isCountBasedProduct(tradeProduct)) { + return false + } + } + + const title = (item.title ?? '').trim() + const meltedLabel = getMeltedGoldTradeLabel() + if (title.includes(PRODUCT_CATEGORY_ABSHODE) || (meltedLabel && title.includes(meltedLabel))) { + return true + } + + if (item.type !== 1 && item.type !== 2) { + return false + } + + if (isMeltedTradeGroup(item.wallet?.product?.group_type)) { + return true + } + + const tradeTitle = (tradeProduct?.title ?? '').trim() + if (tradeTitle === meltedLabel || tradeTitle.includes(PRODUCT_CATEGORY_ABSHODE)) { + return true + } + + return !item.wallet?.product && !tradeTitle +} + +export function formatTransactionGoldAmount( + item?: (TransactionTradeProductSource & { type?: number | null; title?: string | null }) | null, + amount?: number | null, +): string { + const decimals = isMeltedGoldTransaction(item) ? TRADE_GRAM_DISPLAY_DECIMALS : 3 + return formatGoldAmount(amount, decimals) +} + /** برچسب قیمت حین معامله — نام و واحد همان محصول معامله‌شده */ export function getTransactionTradePriceLabel(product?: (ProductUnitSource & { title?: string | null }) | null) { if (!product) { diff --git a/src/utils/productPrice.ts b/src/utils/productPrice.ts index e6fff34..1f23284 100644 --- a/src/utils/productPrice.ts +++ b/src/utils/productPrice.ts @@ -1,5 +1,6 @@ import type { Product } from '@/models/products' import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price' +import { resolveProductCashFeeBuyRial, resolveProductCashFeeSellRial } from '@/utils/productCashFee' import { isMetalCommodityGroup, isMeltedProductUsingAppPrice, isMeltedTradeGroup, usesQuantityTrade } from '@/utils/productCategories' import { productPriceAmountFactor } from '@/utils/productDealMethod' @@ -104,7 +105,7 @@ export function getProductBuyPrice(product: Product): number { return 0 } - const fee = Number(product.cash_fee_buy ?? 0) + const fee = resolveProductCashFeeBuyRial(product, base) if ( isMeltedProductUsingAppPrice(product) @@ -159,7 +160,7 @@ export function getProductSellPrice(product: Product): number { } if (isMeltedProductUsingAppPrice(product) || isMeltedTradeGroup(product.group_type) || usesQuantityTrade(product)) { - return base - Number(product.cash_fee_sell ?? 0) + return base - resolveProductCashFeeSellRial(product, base) } return base diff --git a/src/utils/serviceFee.ts b/src/utils/serviceFee.ts index c91b6aa..b95de92 100644 --- a/src/utils/serviceFee.ts +++ b/src/utils/serviceFee.ts @@ -6,6 +6,31 @@ export const serviceFeePercentage = ref(0.01) /** true = مبالغ رند: فروش → ریال دقیق (کارمزد از طلا)؛ خرید → وزن دقیق (کارمزد از ریال) */ export const feeDeductFromGold = ref(false) +/** آخرین فیلدی که کاربر مستقیم ویرایش کرده — مبنای به‌روزرسانی هنگام تغییر قیمت */ +export type TradeInputLock = 'gram' | 'rial' | null + +/** حداکثر اعشار ذخیره/ارسال به سرور (مطابق orders.amount) */ +export const TRADE_GRAM_STORAGE_DECIMALS = 10 + +/** حداکثر اعشار نمایش به کاربر در اپ */ +export const TRADE_GRAM_DISPLAY_DECIMALS = 8 + +export function normalizeOrderGoldAmount(grams?: number | null): number { + const value = Number(grams ?? 0) + if (!Number.isFinite(value) || value === 0) return 0 + return parseFloat(value.toFixed(TRADE_GRAM_STORAGE_DECIMALS)) +} + +export function formatTradeGramDisplay(grams?: number | null): number { + const value = Number(grams ?? 0) + if (!Number.isFinite(value)) return 0 + return parseFloat(value.toFixed(TRADE_GRAM_DISPLAY_DECIMALS)) +} + +function feeRate() { + return Number(serviceFeePercentage.value) +} + export async function loadServiceFeeFromSettings() { try { const settings = await getSettings() @@ -39,7 +64,58 @@ export function calcFeeInGrams(goldValue?: number | null) { /** گرم معادل برای دریافت دقیق مبلغ ریالی در فروش */ export function calcGoldAmountFromTargetRial(rialAmount: number, unitPriceRial: number) { if (!rialAmount || !unitPriceRial) return 0 - return parseFloat((rialAmount / unitPriceRial).toFixed(6)) + return normalizeOrderGoldAmount(rialAmount / unitPriceRial) +} + +/** وقتی کاربر گرم را قفل کرده — ریال نمایشی/قابل پرداخت از همان گرم */ +export function calcRialFromLockedGram(displayGram: number, unitPriceRial: number, isBuy: boolean) { + const grams = Number(displayGram) + const unit = Number(unitPriceRial) + if (!grams || !unit) return 0 + const fee = feeRate() + + if (isBuy) { + if (feeDeductFromGold.value) { + return Math.round(grams * unit * (1 + fee)) + } + return Math.round(grams * unit) + } + + if (feeDeductFromGold.value) { + return Math.round(grams * unit) + } + return Math.round(grams * unit * (1 - fee)) +} + +/** وقتی کاربر ریال را قفل کرده — گرم نمایشی از همان مبلغ */ +export function calcGramFromLockedRial(rialAmount: number, unitPriceRial: number, isBuy: boolean) { + const rial = Number(rialAmount) + const unit = Number(unitPriceRial) + if (!rial || !unit) return 0 + const fee = feeRate() + + if (isBuy) { + if (feeDeductFromGold.value) { + return rial / (unit * (1 + fee)) + } + return (rial / unit) * (1 - fee) + } + + if (feeDeductFromGold.value) { + return rial / unit + } + return rial / (unit * (1 - fee)) +} + +/** مبلغ ریالی قفل‌شده توسط کاربر — برای ارسال به سرور */ +export function calcTradeTargetCash( + goldCash?: number | null, + inputLock?: TradeInputLock, +): number | undefined { + if (inputLock !== 'rial') return undefined + const cash = Number(goldCash ?? 0) + if (cash <= 0) return undefined + return Math.round(cash) } /** مبلغ نهایی قابل پرداخت (خرید) یا دریافت (فروش) به ریال */ @@ -49,41 +125,67 @@ export function calcPayablePriceRial( isBuy: boolean, targetRialCash?: number | null ) { + if (targetRialCash != null && Number(targetRialCash) > 0) { + return Number(targetRialCash) + } + if (feeDeductFromGold.value) { if (isBuy) return finalPriceRial + feeInRial - if (targetRialCash != null && Number(targetRialCash) > 0) return Number(targetRialCash) return finalPriceRial } return isBuy ? finalPriceRial : Math.max(0, finalPriceRial - feeInRial) } -/** وزن ارسالی به سرور — فروش رند: از مبلغ ریالی هدف */ +/** وزن ارسالی به سرور */ export function calcOrderGoldAmount( goldValue?: number | null, goldCash?: number | null, unitPriceRial?: number, - isBuy?: boolean + isBuy?: boolean, + inputLock?: TradeInputLock ) { const gold = Number(goldValue ?? 0) - if (isBuy || !feeDeductFromGold.value) { - return parseFloat(gold.toFixed(6)) - } - const targetCash = calcSellTargetCash(goldCash, goldValue, unitPriceRial) + const cash = Number(goldCash ?? 0) const unit = Number(unitPriceRial ?? 0) - if (targetCash != null && unit > 0) { - return calcGoldAmountFromTargetRial(targetCash, unit) + + if (isBuy && inputLock === 'rial' && cash > 0 && unit > 0) { + if (feeDeductFromGold.value) { + const fee = feeRate() + return normalizeOrderGoldAmount(cash / (unit * (1 + fee))) + } + return normalizeOrderGoldAmount(cash / unit) } - return parseFloat(gold.toFixed(6)) + + if (!isBuy && feeDeductFromGold.value) { + if (inputLock === 'rial' && cash > 0 && unit > 0) { + return calcGoldAmountFromTargetRial(cash, unit) + } + if (inputLock !== 'rial') { + return normalizeOrderGoldAmount(gold) + } + const targetCash = calcSellTargetCash(goldCash, goldValue, unitPriceRial, inputLock) + if (targetCash != null && unit > 0) { + return calcGoldAmountFromTargetRial(targetCash, unit) + } + } + + return normalizeOrderGoldAmount(gold) } /** مبلغ ریالی دقیق دریافتی در فروش (فقط وقتی کاربر با ریال وارد کرده) */ export function calcSellTargetCash( goldCash?: number | null, goldValue?: number | null, - unitPriceRial?: number + unitPriceRial?: number, + inputLock?: TradeInputLock ) { + if (!feeDeductFromGold.value) return undefined + if (inputLock === 'gram') return undefined + const cash = Number(goldCash ?? 0) - if (!feeDeductFromGold.value || cash <= 0) return undefined + if (cash <= 0) return undefined + if (inputLock === 'rial') return Math.round(cash) + const gold = Number(goldValue ?? 0) const unit = Number(unitPriceRial ?? 0) if (gold > 0 && unit > 0) { diff --git a/src/utils/usePrice.ts b/src/utils/usePrice.ts index 8860960..f837da5 100644 --- a/src/utils/usePrice.ts +++ b/src/utils/usePrice.ts @@ -50,11 +50,26 @@ export function useFormat(num: string | number, unit = '') { type TransactionPriceSource = { cash?: number | null gold?: number | null + type?: number | null order?: { price?: number | null total_price?: number | null amount?: number | null + sts?: number | null + sts_str?: string | number | null + payment_sts?: number | null } | null + withdraw?: { + sts?: number | null + sts_str?: string | number | null + } | null + order_id?: number | null +} + +const ORDER_STATUS_LABELS: Record = { + 1: 'باز', + 2: 'موفق', + 3: 'ناموفق', } export function getTransactionTradePrice(item: TransactionPriceSource): number { @@ -79,6 +94,38 @@ export function getTransactionTradePrice(item: TransactionPriceSource): number { return 0 } +export function resolveTransactionStatusLabel(item?: TransactionPriceSource | null): string { + if (!item) return '' + + const raw = item.order?.sts_str ?? item.withdraw?.sts_str + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (trimmed && trimmed !== 'بدون مقدار') { + return trimmed + } + } + + if (typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(String(raw).trim()))) { + const mapped = ORDER_STATUS_LABELS[Number(raw)] + if (mapped) return mapped + } + + const sts = item.order?.sts ?? item.withdraw?.sts + if (sts != null && ORDER_STATUS_LABELS[Number(sts)]) { + return ORDER_STATUS_LABELS[Number(sts)] + } + + if (item.order_id && Number(item.order?.payment_sts) === 4) { + return 'موفق' + } + + if (item.order_id && (item.type === 1 || item.type === 2 || item.type === 10)) { + return 'موفق' + } + + return '' +} + export function getTransactionGoldAmount(item: TransactionPriceSource): number { const gold = Number(item.gold) || 0 if (Math.abs(gold) > 0) { @@ -96,10 +143,10 @@ export function getTransactionGoldAmount(item: TransactionPriceSource): number { return gold } -export function formatGoldAmount(amount?: number | null): string { +export function formatGoldAmount(amount?: number | null, decimals = 8): string { const num = Number(amount) if (!Number.isFinite(num)) return '0' - return formatGroupedPrice(Number(num.toFixed(3)), false) + return formatGroupedPrice(Number(num.toFixed(decimals)), false) } /** وزن فلز (گرم/کیلو) — بدون گرد کردن به بالا (برخلاف usePriceWithoutType) */ diff --git a/src/views/base/address/components/New_EditAddress.vue b/src/views/base/address/components/New_EditAddress.vue index 906396d..1ead9b8 100644 --- a/src/views/base/address/components/New_EditAddress.vue +++ b/src/views/base/address/components/New_EditAddress.vue @@ -48,7 +48,6 @@ import BasicSelect from '@/components/BasicPacks/BasicSelect.vue' import BasicButton from '@/components/BasicPacks/BasicButton.vue' import BasicInput from '@/components/BasicPacks/BasicInput.vue' import BasicTextArea from '@/components/BasicPacks/BasicTextArea.vue' -import { loading } from '@/utils' import { createAddress, getCity, updateAddress } from '@/api/address' import { FormInstance, message } from 'ant-design-vue' import { Address } from '@/models/address' @@ -130,14 +129,11 @@ const changeProvince = async (obj: any) => { return } try { - loading.value = true cityList.value = undefined address.value.city = undefined cityList.value = await getCity(obj.id) } catch (e) { message.error('خطا در دریافت شهرها') - } finally { - loading.value = false } } diff --git a/src/views/base/address/composables/useAddressPage.ts b/src/views/base/address/composables/useAddressPage.ts index 3e2731e..c722eba 100644 --- a/src/views/base/address/composables/useAddressPage.ts +++ b/src/views/base/address/composables/useAddressPage.ts @@ -98,14 +98,11 @@ export function useAddressPage() { const changeProvince = async (obj: { id: number }) => { try { - loading.value = true cityList.value = undefined address.value.city = undefined cityList.value = await getCity(obj.id) } catch (e) { message.error('خطا در دریافت شهرها') - } finally { - loading.value = false } } diff --git a/src/views/base/copper&silver/index.vue b/src/views/base/copper&silver/index.vue index 0a896b7..484f24b 100644 --- a/src/views/base/copper&silver/index.vue +++ b/src/views/base/copper&silver/index.vue @@ -232,9 +232,11 @@ import type { Product } from '@/models/products' import { useFundUserStore } from '@/store/fundStore' import {useConfigStore} from "@/store/configStore"; import { calcFeeInGrams, calcPayablePriceRial, calcSellGoldDebit, feeDeductFromGold, loadServiceFeeFromSettings } from '@/utils/serviceFee' +import { resolveProductCashFeeBuyRial, resolveProductCashFeeSellRial } from '@/utils/productCashFee' import { formatCashWalletBalanceText, formatGoldWalletBalanceText } from '@/utils/productDealMethod' import { getCashWalletBalance } from '@/utils/productAssets' import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours' +import { resetTransactionsPageData } from '@/views/base/transactions/composables/useTransactionsPageData' const infoVisible = ref(false) const walletStore = useWalletStore() @@ -282,13 +284,12 @@ const basePriceValue = computed(() => { const product = props?.ProductItem if (!product) return undefined - const { price_profit = 0, price = 0, cash_fee_buy = 0, cash_fee_sell = 0 } = product + const { price_profit = 0, price = 0 } = product const base = price_profit + price + const buyFee = resolveProductCashFeeBuyRial(product, base) + const sellFee = resolveProductCashFeeSellRial(product, base) - const rawPrice = isBuy.value - ? base + cash_fee_buy - : base - cash_fee_sell - return rawPrice + return isBuy.value ? base + buyFee : base - sellFee }) const finalPrice = computed(() => { @@ -298,15 +299,17 @@ const finalPrice = computed(() => { const product = props?.ProductItem if (!product) return 0 - const { price_profit = 0, price = 0, cash_fee_buy = 0, cash_fee_sell = 0 } = product + const { price_profit = 0, price = 0 } = product const base = price_profit + price + const buyFee = resolveProductCashFeeBuyRial(product, base) + const sellFee = resolveProductCashFeeSellRial(product, base) if (isBuy.value) { - return gold * (base + cash_fee_buy) - } else { - return gold * (base - cash_fee_sell) + return gold * (base + buyFee) } + + return gold * (base - sellFee) }) const payablePrice = computed(() => @@ -532,20 +535,30 @@ watch( } ) +function productFeeBase(product?: Product) { + return (product?.price_profit ?? 0) + (product?.price ?? 0) +} + function calculateCash(val: number) { priceErrorVisible.value = false if (!lastPrice.value) return + const product = props?.ProductItem + if (!product) return + + const base = productFeeBase(product) + const buyFee = resolveProductCashFeeBuyRial(product, base) + const sellFee = resolveProductCashFeeSellRial(product, base) const tradeBuy = getTradeBuyPrice(lastPrice.value) - form.value.goldVazn = parseFloat((val * ((props?.ProductItem?.price) + (props?.ProductItem?.cash_fee_buy))) / tradeBuy).toFixed(1) + form.value.goldVazn = parseFloat(String((val * (base + buyFee)) / tradeBuy)).toFixed(1) if (isBuy.value) { form.value.goldCash = val - ? parseFloat(String(val * ((props?.ProductItem?.price) + (props?.ProductItem?.cash_fee_buy)))) + ? parseFloat(String(val * (base + buyFee))) : 0 if (!val) form.value.goldVazn = 0 } else { form.value.goldCash = val - ? parseFloat(String((val * props?.ProductItem?.price) - (props?.ProductItem?.cash_fee_sell))) + ? parseFloat(String(val * base - sellFee)) : 0 if (!val) form.value.goldVazn = 0 } @@ -553,9 +566,16 @@ function calculateCash(val: number) { function calculateGold(val: number) { if (price.value) { + const product = props?.ProductItem + if (!product) return + + const base = productFeeBase(product) + const buyFee = resolveProductCashFeeBuyRial(product, base) + const sellFee = resolveProductCashFeeSellRial(product, base) + if (isBuy.value) { if (val) { - form.value.goldValue = toFixedDownThreeChar(parseFloat(val / (((props?.ProductItem?.price_profit) + (props?.ProductItem?.price)) + (props?.ProductItem?.cash_fee_buy)))) + form.value.goldValue = toFixedDownThreeChar(parseFloat(String(val / (base + buyFee)))) if (parseInt(finalPrice.value) > 0) priceErrorVisible.value = true if (parseInt(finalPrice.value) === 0) priceErrorVisible.value = false } @@ -570,7 +590,7 @@ function calculateGold(val: number) { } else { if (val) { - form.value.goldValue = toFixedDownThreeChar(parseFloat(val / (((props?.ProductItem?.price_profit) + (props?.ProductItem?.price)) + (props?.ProductItem?.cash_fee_sell)))) + form.value.goldValue = toFixedDownThreeChar(parseFloat(String(val / (base - sellFee)))) if (parseInt(finalPrice.value) > 0) priceErrorVisible.value = true if (parseInt(finalPrice.value) === 0) priceErrorVisible.value = false } else if (val.toString() === '') { @@ -658,6 +678,7 @@ async function ok() { }) confirmAction.value = false await walletStore.getMyWallet() + resetTransactionsPageData() await router.push({ name: 'transactions' }) form.value = { goldValue: undefined, diff --git a/src/views/base/gold/index.vue b/src/views/base/gold/index.vue index a95506f..ba2802a 100644 --- a/src/views/base/gold/index.vue +++ b/src/views/base/gold/index.vue @@ -102,20 +102,6 @@ @change="percentChange" /> - - -
- - ارزش کل وارد شده بر حسب گرم طلا محاسبه خواهد شد و قیمت وارد شده معادل {{ usePrice(parseInt(finalPrice.toFixed(0))) }} می - باشد! -
-
@@ -173,7 +159,7 @@
وزن:
-
{{ form.goldValue }}
+
{{ displayTradeGoldAmount }}
{{ weightUnit }}
@@ -195,7 +181,7 @@
طلا دریافتی:
-
{{ form.goldValue }}
+
{{ displayTradeGoldAmount }}
{{ weightUnit }} (دقیق — کارمزد از ریال)
@@ -214,7 +200,14 @@ - ثبت نهایی + + ثبت نهایی +
وزن:
-
{{ form.goldValue }}
+
{{ displayTradeGoldAmount }}
{{ weightUnit }}
@@ -250,7 +243,7 @@
طلا دریافتی:
-
{{ form.goldValue }}
+
{{ displayTradeGoldAmount }}
{{ weightUnit }} (دقیق — کارمزد از ریال)
@@ -269,7 +262,7 @@ - + ثبت نهایی