Merge remote-tracking branch 'origin/modular' into modular

This commit is contained in:
2026-07-05 11:26:04 +02:00
28 changed files with 1314 additions and 446 deletions
+46 -2
View File
@@ -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 {
+76
View File
@@ -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} ریال`
}
+56 -2
View File
@@ -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) {
+3 -2
View File
@@ -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
+115 -13
View File
@@ -6,6 +6,31 @@ export const serviceFeePercentage = ref<number>(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) {
+49 -2
View File
@@ -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<number, string> = {
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) */