Files
boursecu_user/src/utils/productDealMethod.ts
T
safavimohadeseh78andCursor 9356a2ab5e Port modular price, product, and PWA fixes into boursecu
Sync live quote handling, SSE product images, store hours, file URL normalization, PWA icon cache, TV monitor, about software, SuperAdmin menu gates, price alerts, and related UX from modular while preserving boursecu branding and config constraints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 08:26:52 -04:00

753 lines
22 KiB
TypeScript

import type { SelectItem } from '@/models/SelectItem'
import type { Product } from '@/models/products'
import type { WalletInventoryCard } from '@/models/walletAssets'
import { useSettingsStore } from '@/store/settingsStore'
import {
isLegacyMaftolGroup,
isMeltedTradeGroup,
LEGACY_GROUP_KATOD,
LEGACY_GROUP_MAFTOL,
normalizeProductGroupType,
PRODUCT_CATEGORY_ABSHODE,
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
export const BOURSECU_GROUP_KATOD = LEGACY_GROUP_KATOD
export const BOURSECU_GROUP_MAFTOL = LEGACY_GROUP_MAFTOL
export const BOURSECU_WALLET_LABEL_GENERIC = 'موجودی کیف'
export function isBoursecuDealProductGroup(groupType?: string | null) {
const group = normalizeProductGroupType(groupType)
return group === BOURSECU_GROUP_MAFTOL
|| group === BOURSECU_GROUP_KATOD
|| group === BOURSECU_GROUP_ABSHODE
|| group === 'مس'
}
export function isMaftolMesGroup(groupType?: string | null) {
const group = normalizeProductGroupType(groupType)
return group === BOURSECU_GROUP_MAFTOL || group === 'مس'
}
export function isKatodMesGroup(groupType?: string | null) {
return normalizeProductGroupType(groupType) === BOURSECU_GROUP_KATOD
}
export function isShamshMesGroup(groupType?: string | null, title?: string | null) {
const group = normalizeProductGroupType(groupType)
if (group === 'شمش') {
return true
}
return (title ?? '').includes('شمش')
}
export function isGoldOnlyDealGroup(groupType?: string | null) {
return isMeltedTradeGroup(groupType)
}
export interface ProductUnitSource {
unit?: string | null
direct_price?: number | null
group_type?: string | null
amount?: number | null
trade_by_quantity?: boolean | number | null
price_formula_enabled?: boolean | number | null
}
export function isWeightBasedProductUnit(unit?: string | null) {
const value = (unit ?? '').trim()
return value === 'کیلوگرم' || value === 'گرم'
}
const COUNT_BASED_GROUP_TYPES = ['سکه', 'گوی', 'مکعب', 'شمش', 'سکه پارسیان']
/** مطابق ادمین: وزن «دارد» => amount > 0 ؛ «ندارد» => amount = 0 */
export function hasProductUnitWeight(product?: ProductUnitSource | null) {
if (isMeltedTradeGroup(product?.group_type)) {
return false
}
const amount = Number(product?.amount ?? 0)
return amount > 0
}
/** ضریب قیمت — فقط «متغیر» بدون مرجع اتوماتیک: قیمت هر گرم × وزن */
export function productPriceAmountFactor(product?: ProductUnitSource | null) {
// قیمت فرمولی = قیمت واحد هر عدد (وزن داخل فرمول است)
if (product?.price_formula_enabled) {
return 1
}
if (product?.reading_method) {
return 1
}
if (isCountBasedProduct(product)) {
return 1
}
if (Number(product?.direct_price ?? 0) !== 0) {
return 1
}
if (hasProductUnitWeight(product)) {
const amount = Number(product?.amount ?? 0)
return amount > 0 ? amount : 1
}
return 1
}
export function getProductWeightUnit(product?: ProductUnitSource | null) {
const unit = product?.unit?.trim()
if (unit && isWeightBasedProductUnit(unit)) {
return unit
}
const group = normalizeProductGroupType(product?.group_type)
if (group === BOURSECU_GROUP_MAFTOL || group === BOURSECU_GROUP_KATOD || group === BOURSECU_GROUP_ABSHODE || group === 'مس' || isMeltedTradeGroup(group)) {
return getWeightUnit()
}
if (!isCountBasedProduct(product) || isMeltedTradeGroup(product?.group_type)) {
return getWeightUnit()
}
return 'گرم'
}
export function formatProductWeightAmount(amount?: number | null, product?: ProductUnitSource | null) {
const value = Number(amount ?? 0)
const weightUnit = getProductWeightUnit(product)
if (weightUnit === 'گرم' && value > 0 && value < 1) {
return `${value * 1000} سوت`
}
return `${value} ${weightUnit}`
}
/** فقط محصول تعدادی (مثل سکه): «هر عدد X گرم» — معامله مقداری با trade_by_quantity نیست */
export function shouldShowPerUnitWeightHint(product?: ProductUnitSource | null) {
if (!product || !hasProductUnitWeight(product) || usesQuantityTrade(product)) {
return false
}
return isCountBasedProduct(product)
}
export function getProductUnitWeightLabel(product?: ProductUnitSource | null) {
if (!shouldShowPerUnitWeightHint(product)) {
return null
}
return formatProductWeightAmount(product?.amount, product)
}
export function getProductTotalWeightLabel(count?: number | null, product?: ProductUnitSource | null) {
if (!hasProductUnitWeight(product)) {
return null
}
const quantity = Number(count ?? 0)
return formatProductWeightAmount(quantity * Number(product?.amount ?? 0), product)
}
export function shouldShowProductTotalWeight(product?: ProductUnitSource | null) {
if (!product) {
return false
}
if (!isCountBasedProduct(product)) {
return true
}
return hasProductUnitWeight(product)
}
export function formatProductInventoryBalance(product?: (ProductUnitSource & { wallet?: { gold?: number | null } }) | null) {
const count = product?.wallet?.gold ?? 0
const quantityPart = `${count} ${getProductQuantityUnit(product)}`
const unitWeight = getProductUnitWeightLabel(product)
if (!unitWeight) {
return quantityPart
}
return `${quantityPart} · هر عدد ${unitWeight}`
}
/** محصول تعدادی: موجودی wallet.gold = تعداد قلم، نه وزن — معامله مقداری با trade_by_quantity نیست */
export function isCountBasedProduct(product?: ProductUnitSource | null) {
if (!product) {
return false
}
if (usesQuantityTrade(product)) {
return false
}
if (isWeightBasedProductUnit(product.unit)) {
return false
}
if (product.direct_price != null && product.direct_price !== 0) {
return true
}
const group = normalizeProductGroupType(product.group_type)
if (group === BOURSECU_GROUP_MAFTOL || isLegacyMaftolGroup(group) || group === 'مس') {
return true
}
return COUNT_BASED_GROUP_TYPES.includes(group)
}
export function getProductQuantityUnit(product?: ProductUnitSource | null) {
const unit = product?.unit?.trim()
if (unit) {
return unit
}
if (isCountBasedProduct(product)) {
return 'عدد'
}
return getWeightUnit()
}
export const MELTED_DEAL_METHOD_PREFIX = 'gold:'
export interface ProductDealMethodContext {
tradedProduct?: Product | null
productMetalId?: number | null
inventoryCards?: WalletInventoryCard[]
catalog?: Product[]
}
/** سلکت نحوه معامله فقط وقتی محصول مورد معامله وزن دارد */
export function shouldShowProductDealMethodSelector(product?: Product | null) {
return hasProductUnitWeight(product)
}
export function shouldShowProductDealMethodSelect(
product?: Product | null,
options?: SelectItem[] | null,
) {
return shouldShowProductDealMethodSelector(product) && (options?.length ?? 0) > 1
}
export function buildMeltedDealMethodKey(productId: number) {
return `${MELTED_DEAL_METHOD_PREFIX}${productId}`
}
export function parseMeltedDealMethodProductId(method?: string | null): number | null {
if (!method?.startsWith(MELTED_DEAL_METHOD_PREFIX)) {
return null
}
const productId = Number(method.slice(MELTED_DEAL_METHOD_PREFIX.length))
return productId > 0 ? productId : null
}
export function isMeltedProductDealMethod(method?: string | null) {
return parseMeltedDealMethodProductId(method) != null
}
function isQuantityTradeCatalogProduct(
product?: Product | null,
metalId?: number | null,
excludeProductId?: number | null,
) {
if (!product || !usesQuantityTrade(product)) {
return false
}
if (excludeProductId && Number(product.id) === Number(excludeProductId)) {
return false
}
return Number(product.metal_id) === Number(metalId ?? 0)
}
function findInventoryCardForProduct(
cards: WalletInventoryCard[],
productId?: number | null,
) {
if (!productId) {
return undefined
}
return cards.find((card) => Number(card.product_id) === Number(productId))
}
export function getQuantityTradePaymentDealOptions(
context?: ProductDealMethodContext | null,
isBuy = true,
): SelectItem[] {
const metalId = Number(context?.productMetalId ?? 0)
const catalog = context?.catalog ?? []
const cards = context?.inventoryCards ?? []
const tradedProductId = context?.tradedProduct?.id ?? null
if (!metalId || !catalog.length) {
return []
}
const matchingProducts = catalog.filter((product) =>
isQuantityTradeCatalogProduct(product, metalId, tradedProductId)
)
if (isBuy) {
const options: SelectItem[] = []
for (const product of matchingProducts) {
const card = findInventoryCardForProduct(cards, product.id)
if (!card || Number(card.quantity ?? 0) <= 0) {
continue
}
options.push({
key: buildMeltedDealMethodKey(product.id),
value: `${product.title} — ${formatInventoryCardValue(card)}`,
})
}
return options
}
return matchingProducts.map((product) => {
const card = findInventoryCardForProduct(cards, product.id)
const inventorySuffix = card ? ` — ${formatInventoryCardValue(card)}` : ''
return {
key: buildMeltedDealMethodKey(product.id),
value: `دریافت ${product.title}${inventorySuffix}`,
}
})
}
/** @deprecated از getQuantityTradePaymentDealOptions استفاده کنید */
export function getMeltedPaymentDealOptions(
context?: ProductDealMethodContext | null,
isBuy = true,
) {
return getQuantityTradePaymentDealOptions(context, isBuy)
}
export function findMeltedDealMethodCard(
method?: string | null,
cards: WalletInventoryCard[] = [],
) {
return findInventoryCardForProduct(cards, parseMeltedDealMethodProductId(method))
}
export function findProductDealMethodProduct(
method?: string | null,
catalog: Product[] = [],
) {
const productId = parseMeltedDealMethodProductId(method)
if (!productId) {
return null
}
return catalog.find((product) => Number(product.id) === productId) ?? null
}
export function resolveProductTradeBuyUnitPrice(product?: Product | null) {
if (!product) {
return 0
}
const basePrice = (product.price ?? 0) + resolveProductCashFeeBuyRial(product, product.price ?? 0)
return basePrice * productPriceAmountFactor(product)
}
export function resolveProductTradeSellUnitPrice(product?: Product | null) {
if (!product) {
return 0
}
const basePrice = (product.price ?? 0) - resolveProductCashFeeSellRial(product, product.price ?? 0)
return basePrice * productPriceAmountFactor(product)
}
function getDefaultTypeDealOptions(context?: ProductDealMethodContext | null, isBuy = true): SelectItem[] {
const cashOption: SelectItem = { value: 'نقدی', key: 'cash' }
if (!shouldShowProductDealMethodSelector(context?.tradedProduct ?? null)) {
return [cashOption]
}
const quantityOptions = getQuantityTradePaymentDealOptions(context, isBuy)
if (quantityOptions.length) {
return [cashOption, ...quantityOptions]
}
return [cashOption]
}
export function getProductDealMethodOptions(
groupType?: string | null,
isBuy = true,
context?: ProductDealMethodContext | null,
): SelectItem[] {
const settingsStore = useSettingsStore()
if (isMaftolMesGroup(groupType)) {
if (isBuy) {
return [
{ value: `پرداخت با ${settingsStore.metalTypeLabel}`, key: 'katod' },
{ value: 'نقدی', key: 'cash' },
]
}
return [
{ value: 'نقدی', key: 'cash' },
{ value: `دریافت ${settingsStore.metalTypeLabel}`, key: 'katod' },
]
}
if (isKatodMesGroup(groupType)) {
return [{ value: 'نقدی', key: 'cash' }]
}
if (isMeltedTradeGroup(groupType)) {
return [{ value: settingsStore.meltedGoldLabel, key: 'gold' }]
}
return getDefaultTypeDealOptions(context, isBuy)
}
export function getDefaultProductDealMethod(
groupType?: string | null,
isBuy = true,
context?: ProductDealMethodContext | null,
) {
return getProductDealMethodOptions(groupType, isBuy, context)[0]?.key ?? 'cash'
}
export function getProductDealMethodPlaceholder(isBuy = true) {
return isBuy ? 'نحوه خرید را انتخاب کنید' : 'نحوه دریافت را انتخاب کنید'
}
export function formatMainMetalReceiveText(weight?: number | null) {
return `${useSettingsStore().metalTypeLabel}: ${weight ?? 0}${getWeightUnit()}`
}
export function calcSellReceivedMetalWeight(
count?: number | null,
product?: ProductUnitSource | null,
) {
const quantity = Number(count ?? 0)
const unitAmount = Number(product?.amount ?? 0)
if (!quantity || !unitAmount) {
return 0
}
// کارمزد محصول تعدادی از ریال است — وزن دریافتی فلز کم نمی‌شود
return quantity * unitAmount
}
export function isCashLikeDealMethod(method?: string | null) {
return method === 'cash'
}
export function isMainMetalWalletDealMethod(method?: string | null) {
return method === 'gold' || method === 'katod' || isMeltedProductDealMethod(method)
}
export function getMeltedDealMethodBalance(
method?: string | null,
cards: WalletInventoryCard[] = [],
mainWalletGold?: number | null,
) {
const card = findMeltedDealMethodCard(method, cards)
if (card) {
return Number(card.quantity ?? 0)
}
if (method === 'gold' || method === 'katod') {
return Number(mainWalletGold ?? 0)
}
return 0
}
/** برچسب کیف فلز اصلی (main wallet gold) — از setting metal_type */
export function getMainMetalWalletLabel() {
return `موجودی کیف ${useSettingsStore().metalTypeLabel}`
}
/** برچسب کیف آبشده — از setting metal_type */
export function getMeltedGoldWalletLabel() {
return `موجودی کیف ${useSettingsStore().meltedGoldLabel}`
}
export function getBoursecuMetalWalletLabel(groupType?: string | null) {
const group = normalizeProductGroupType(groupType)
if (group === BOURSECU_GROUP_MAFTOL) {
return BOURSECU_GROUP_MAFTOL
}
if (group === BOURSECU_GROUP_KATOD) {
return BOURSECU_GROUP_KATOD
}
if (group === BOURSECU_GROUP_ABSHODE) {
return useSettingsStore().meltedGoldLabel
}
return useSettingsStore().metalTypeLabel
}
export function getGoldWalletLabel(groupType?: string | null) {
const group = normalizeProductGroupType(groupType)
if (group === BOURSECU_GROUP_MAFTOL
|| group === BOURSECU_GROUP_KATOD
|| group === BOURSECU_GROUP_ABSHODE) {
return `موجودی کیف ${getBoursecuMetalWalletLabel(groupType)}`
}
if (!group) {
return getMainMetalWalletLabel()
}
return BOURSECU_WALLET_LABEL_GENERIC
}
/** پرداخت نقدی همیشه از کیف پول ریالی — مستقل از نوع محصول */
export function getCashWalletLabel(_groupType?: string | null) {
return 'موجودی کیف پول'
}
export function getMetalAssetLabel() {
return `دارایی ${useSettingsStore().metalTypeLabel}`
}
export function getMeltedGoldAssetLabel() {
return `دارایی ${useSettingsStore().meltedGoldLabel}`
}
export function getMaftolAssetLabel() {
return `دارایی ${BOURSECU_GROUP_MAFTOL}`
}
export function getWeightUnit() {
return useSettingsStore().tradeWeightUnit
}
export function formatBoursecuAssetAmount(amount?: number | null) {
return `${amount ?? 0}${getWeightUnit()}`
}
export function formatMainMetalWalletBalanceText(gold: number) {
return `${getMainMetalWalletLabel()}: ${gold} ${getWeightUnit()}`
}
export function formatGoldWalletBalanceText(gold: number, groupType?: string | null) {
return `${getGoldWalletLabel(groupType)}: ${gold} ${getWeightUnit()}`
}
export function formatCashWalletBalanceText(cashFormatted: string | number, groupType?: string | null) {
return `${getCashWalletLabel(groupType)}: ${cashFormatted} ریال`
}
/** برچسب کیف فلزی متناسب با نحوه پرداخت (مثلاً کاتد مس برای خرید مفتول با کیف کاتد) */
export function getMetalWalletLabelForDealMethod(method?: string | null, groupType?: string | null) {
if (method === 'katod') {
return getMainMetalWalletLabel()
}
return getGoldWalletLabel(groupType)
}
export function getInsufficientGoldWalletMessage(groupType?: string | null, method?: string | null) {
return `${getMetalWalletLabelForDealMethod(method, groupType)} ناکافی است`
}
export function getInsufficientCashWalletMessage(_groupType?: string | null) {
return `${getCashWalletLabel()} ناکافی است`
}
export function getInsufficientMainMetalWalletMessage() {
return `${getMainMetalWalletLabel()} ناکافی است`
}
export function getMeltedGoldTradeLabel() {
return useSettingsStore().meltedGoldLabel
}
export function getMetalTypeTradeLabel() {
return useSettingsStore().metalTypeLabel
}
export function getTradePriceUnitLabel() {
return useSettingsStore().metalTypeLabel
}
export type TransactionTradeProductSource = {
product?: (ProductUnitSource & { title?: string | null }) | null
wallet?: { product?: (ProductUnitSource & { title?: string | null }) | null } | null
title?: string | null
order?: unknown
}
/** محصول معامله‌شده — نه لزوماً همان کیف پول (مثلاً خرید مفتول با کیف کاتد) */
export function resolveTransactionTradeProduct(
item?: TransactionTradeProductSource | null,
): (ProductUnitSource & { title?: string | null }) | null {
if (!item) {
return null
}
const fromTransaction = item.product
if ((fromTransaction?.title ?? '').trim()) {
return fromTransaction ?? null
}
const title = (item.title ?? '').trim()
const match = title.match(/^(?:خرید|فروش)\s+(.+)$/u)
if (match?.[1]?.trim()) {
return {
...(item.wallet?.product ?? {}),
title: match[1].trim(),
}
}
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) {
return `قیمت هر ${getWeightUnit()} ${getTradePriceUnitLabel()}`
}
const title = (product.title ?? '').trim()
if (usesQuantityTrade(product)) {
const unit = getProductQuantityUnit(product)
return title ? `قیمت هر ${unit} ${title}` : `قیمت هر ${unit}`
}
if (title) {
return `قیمت هر ${getWeightUnit()} ${title}`
}
return `قیمت هر ${getWeightUnit()} ${getTradePriceUnitLabel()}`
}
/** برچسب «موجودی کیف …» برای سطر موجودی فلز در جزئیات معامله */
export function getTransactionMetalWalletBalanceLabel(
item?: TransactionTradeProductSource | null,
): string {
if (!item) {
return getGoldWalletLabel()
}
const tradeProduct = item.order ? resolveTransactionTradeProduct(item) : null
const walletProduct = item.wallet?.product
const tradeTitle = (tradeProduct?.title ?? '').trim()
const walletTitle = (walletProduct?.title ?? '').trim()
if (tradeTitle) {
if (!walletTitle) {
return `موجودی کیف ${tradeTitle}`
}
return `موجودی ${tradeTitle}`
}
if (walletTitle) {
return `موجودی ${walletTitle}`
}
return getGoldWalletLabel(item.wallet?.product?.group_type)
}
/** واحد نمایش موجودی فلز در جزئیات معامله */
export function getTransactionMetalWalletBalanceUnit(
item?: TransactionTradeProductSource | null,
): string {
const tradeProduct = item?.order ? resolveTransactionTradeProduct(item) : null
const walletProduct = item?.wallet?.product
const product = tradeProduct?.title ? tradeProduct : walletProduct
if (product && usesQuantityTrade(product)) {
return getProductQuantityUnit(product)
}
if (product?.title) {
return getWeightUnit()
}
return getWeightUnit()
}
/** وزن کل زیر مقدار مبادله‌شده — فقط محصول تعدادی (نه trade_by_quantity) */
export function shouldShowTransactionTotalWeightLine(product?: ProductUnitSource | null) {
if (!product || usesQuantityTrade(product) || !hasProductUnitWeight(product)) {
return false
}
return isCountBasedProduct(product)
}