diff --git a/src/model/product.ts b/src/model/product.ts
index 3607cac..2850840 100644
--- a/src/model/product.ts
+++ b/src/model/product.ts
@@ -37,6 +37,8 @@ export interface Product{
prices: MethodePrices[]
direct_price: number
app_price?: boolean
+ price_formula_enabled?: boolean
+ price_formula?: string | null
price: number
price_profit?: number
price_offset_percent?: number
diff --git a/src/pages/menuPages/products/base/components/FormulaPriceSection.vue b/src/pages/menuPages/products/base/components/FormulaPriceSection.vue
new file mode 100644
index 0000000..4881b20
--- /dev/null
+++ b/src/pages/menuPages/products/base/components/FormulaPriceSection.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+ فرمول قیمت محصول
+
+
+
+
+
+ قیمت محصول (محاسبهشده)
+ {{ usePrice(computedProductPrice) }}
+
+
+
+
+
+
+
+
diff --git a/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue b/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue
index 2a69ef8..3869f61 100644
--- a/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue
+++ b/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue
@@ -94,7 +94,7 @@
قیمت صفحه اصلی اپلیکیشن
-
+
@@ -128,7 +128,7 @@
v-model:value="form.reading_method"
/>
-
+
@@ -141,7 +141,7 @@
-
+
@@ -160,6 +160,14 @@
قیمت هر واحد این محصول = «قیمت هر گرم اپ» × «وزن محصول به گرم». مثلاً برای وزن ۱٫۵ گرم، قیمت = ۱٫۵ برابر قیمت هر گرم اپ. برای فعال بودن، «وزن محصول» را «دارد» بگذارید.
+
+
@@ -177,7 +185,7 @@
class="truncate text-xs text-gray-500"
>
مبنا: {{ livePricePreview.source }}
-
+
— تعدیل {{ form.price_offset_percent }}٪
@@ -500,10 +508,11 @@ import type { last } from '@/model/price'
import type { MethodePrices } from '@/model/product'
import { usePrice } from '@core/utils/usePrice'
import { isMeltedTradeGroup, resolveProductTradeTypeLabel, usesMeltedPriceEditor } from '@/utils/productCategories'
-import { computeProductEditorPricePreview } from '@/utils/productEditorPrice'
+import { computeProductEditorPricePreview, findMethodPriceEntry, normalizeReferenceBase } from '@/utils/productEditorPrice'
import { resolveTradeWeightUnit, weightFieldLabelForUnit } from '@/utils/weightUnit'
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
import DashboardLabelWithHelp from '@/pages/dashboard/components/DashboardLabelWithHelp.vue'
+import FormulaPriceSection from './FormulaPriceSection.vue'
import dayjs from 'dayjs'
import { computed, onMounted, ref, watch } from 'vue'
@@ -691,6 +700,33 @@ const livePricePreview = computed(() =>
})
)
+const formulaReferenceMiddlePrice = computed(() => {
+ const entry = findMethodPriceEntry(props.pricesData, props.form.reading_method)
+ if (!entry) {
+ return 0
+ }
+ // مثل همکار: فقط قیمت وسط مرجع (بدون profit ارائهدهنده) — بعد از نرمالایز گلددات
+ const { base } = normalizeReferenceBase(entry)
+ return base > 0 ? base : 0
+})
+
+function onFormulaFormPatch(next: Record) {
+ props.form.price_formula_enabled = !!next.price_formula_enabled
+ props.form.price_formula = next.price_formula ?? props.form.price_formula
+}
+
+function onFormulaEnabledChange(enabled: boolean) {
+ if (enabled) {
+ emit('update:methodOption', '2')
+ props.form.app_price = false
+ props.form.direct_price = 1
+ props.form.price_offset_percent = 0
+ if (!props.form.price_formula) {
+ props.form.price_formula = '((P)/MG)*W+100000'
+ }
+ }
+}
+
watch(
() => props.methodOption,
(option) => {
@@ -702,6 +738,7 @@ watch(
props.form.reading_method = null
props.form.direct_price = 0
props.form.app_price = true
+ props.form.price_formula_enabled = false
} else {
props.form.app_price = false
}
@@ -786,7 +823,7 @@ function onPhysicalDeliveryChange(checked: boolean) {
}
}
-defineEmits<{
+const emit = defineEmits<{
(e: 'addPrice'): void
(e: 'minusPrice'): void
(e: 'update:methodOption', value: string): void
diff --git a/src/pages/menuPages/products/base/index.vue b/src/pages/menuPages/products/base/index.vue
index d29c9e3..4e313cf 100644
--- a/src/pages/menuPages/products/base/index.vue
+++ b/src/pages/menuPages/products/base/index.vue
@@ -313,9 +313,11 @@ async function edit(record: Product) {
productData = { ...productData, ...detailResult }
}
- methodOption.value = productData.app_price
- ? '3'
- : (productData.reading_method ? '2' : '1')
+ methodOption.value = productData.price_formula_enabled
+ ? '2'
+ : productData.app_price
+ ? '3'
+ : (productData.reading_method ? '2' : '1')
meltedPriceOption.value = resolveMeltedPriceSource(productData)
if (usesMeltedPriceEditor(productData)) {
methodOption.value = '1'
@@ -345,6 +347,8 @@ async function edit(record: Product) {
show_in_assets: productData.show_in_assets ?? false,
trade_by_quantity: productData.trade_by_quantity ?? false,
app_price: Boolean(productData.app_price),
+ price_formula_enabled: Boolean(productData.price_formula_enabled),
+ price_formula: productData.price_formula ?? '',
override_trading_hours: productData.override_trading_hours ?? false,
has_physical_delivery: productData.has_physical_delivery ?? false,
min_physical_delivery_quantity:
@@ -410,6 +414,8 @@ function resetData() {
metal_id: 1,
direct_price: 0,
app_price: false,
+ price_formula_enabled: false,
+ price_formula: '',
file_id: undefined,
file: undefined,
order_limit_enabled: false,
@@ -438,6 +444,8 @@ const PRICING_FIELDS = new Set([
'reading_method',
'price_offset_percent',
'use_last_price_if_zero',
+ 'price_formula',
+ 'price_formula_enabled',
])
const DETAILS_FIELDS = new Set([
'title',
@@ -525,6 +533,8 @@ async function saveProduct() {
if (usesMeltedPriceEditor(payload)) {
// آبشده/کاتد: بدون پرچم app_price؛ شمش مقداری با وزن + منبع اپ: app_price برای × وزن در اپ
+ payload.price_formula_enabled = false
+ payload.price_formula = null
if (isMeltedTradeGroup(payload.group_type)) {
payload.app_price = false
payload.amount = 1
@@ -549,21 +559,30 @@ async function saveProduct() {
delete payload.price
payload.price_offset_percent = Number(payload.price_offset_percent ?? 0)
}
+ } else if (payload.price_formula_enabled) {
+ payload.app_price = false
+ payload.direct_price = 1
+ payload.price_offset_percent = 0
+ payload.price_formula = String(payload.price_formula ?? '').trim() || '((P)/MG)*W+100000'
+ delete payload.price
} else if (methodOption.value === '1') {
payload.reading_method = null
payload.price_offset_percent = 0
payload.app_price = false
+ payload.price_formula_enabled = false
} else if (methodOption.value === '3') {
// قیمت اپ: بدون مرجع خارجی، متغیر (هر گرم × وزن)، قیمت دستی نگه نمیدارد
payload.reading_method = null
payload.app_price = true
payload.direct_price = 0
+ payload.price_formula_enabled = false
delete payload.price
payload.price_offset_percent = Number(payload.price_offset_percent ?? 0)
} else {
delete payload.price
payload.price_offset_percent = Number(payload.price_offset_percent ?? 0)
payload.app_price = false
+ payload.price_formula_enabled = false
}
if (weightOption.value === '2') {
payload.amount = 0
diff --git a/src/pages/menuPages/products/base/productFormValidation.ts b/src/pages/menuPages/products/base/productFormValidation.ts
index 4ad6751..edcbb59 100644
--- a/src/pages/menuPages/products/base/productFormValidation.ts
+++ b/src/pages/menuPages/products/base/productFormValidation.ts
@@ -56,6 +56,13 @@ export function validateProductSubmitInput(input: {
if (meltedPriceOption === 'reference' && !String(form.reading_method ?? '').trim()) {
return 'مرجع قیمت را انتخاب کنید'
}
+ } else if (form.price_formula_enabled) {
+ if (!String(form.reading_method ?? '').trim()) {
+ return 'برای قیمت فرمولی، مرجع قیمت را انتخاب کنید'
+ }
+ if (!String(form.price_formula ?? '').trim()) {
+ return 'فرمول قیمت محصول را وارد کنید'
+ }
} else if (methodOption === '1') {
if (isMissingNumber(form.price)) {
return 'قیمت دستی را وارد کنید'
diff --git a/src/utils/formulaPrice.ts b/src/utils/formulaPrice.ts
new file mode 100644
index 0000000..ff3563d
--- /dev/null
+++ b/src/utils/formulaPrice.ts
@@ -0,0 +1,209 @@
+/** ضریب تبدیل مثقال به گرم — در فرمول با MG */
+export const FORMULA_MG = 4.331802
+
+export const DEFAULT_PRICE_FORMULA = '((P)/MG)*W+100000'
+
+/** اگر وزن ۰ یا نامعتبر باشد، ۱ در نظر گرفته میشود */
+export function resolveFormulaWeight(amount: number | null | undefined): number {
+ const w = Number(amount)
+ return !Number.isFinite(w) || w === 0 ? 1 : w
+}
+
+export const FORMULA_VARIABLE_HELP_BASE = [
+ { symbol: 'P', label: 'قیمت وسط مرجع (فقط مظنه انتخابشده، بدون تفاوت ارائهدهنده)' },
+ { symbol: 'MG', label: `ضریب مثقال به گرم (${FORMULA_MG})` }
+] as const
+
+export const FORMULA_FUNCTION_HELP = [
+ { symbol: 'ROUND(x)', label: 'رند به نزدیکترین عدد صحیح' },
+ { symbol: 'ROUND(x, -3)', label: 'رند به هزار (مثل اکسل؛ -4 دههزار، -5 صدهزار)' },
+ { symbol: 'FLOOR(x)', label: 'گرد به پایین' },
+ { symbol: 'CEIL(x)', label: 'گرد به بالا' }
+] as const
+
+export function getFormulaVariableHelp(productAmount: number) {
+ const w = resolveFormulaWeight(productAmount)
+ return [
+ FORMULA_VARIABLE_HELP_BASE[0],
+ {
+ symbol: 'W',
+ label: `وزن محصول (${w})`
+ },
+ FORMULA_VARIABLE_HELP_BASE[1],
+ ...FORMULA_FUNCTION_HELP
+ ]
+}
+
+type FormulaToken =
+ | { type: 'number'; value: string }
+ | { type: 'ident'; value: string }
+ | { type: 'op'; value: string }
+
+function tokenize(expression: string): FormulaToken[] {
+ const normalized = expression.replace(/×/g, '*').replace(/÷/g, '/').replace(/\s+/g, '')
+
+ if (!normalized || !/^[0-9A-Za-z_.,+\-*/()]+$/.test(normalized)) {
+ throw new Error('Invalid formula characters')
+ }
+
+ const parts = normalized.match(/([0-9]+(?:\.[0-9]+)?|[A-Za-z_][A-Za-z0-9_]*|[+\-*/(),])/g)
+ if (!parts || parts.join('') !== normalized) {
+ throw new Error('Invalid formula syntax')
+ }
+
+ return parts.map((part) => {
+ if (/^[0-9]/.test(part)) return { type: 'number', value: part }
+ if (/^[A-Za-z_]/.test(part)) return { type: 'ident', value: part.toUpperCase() }
+ return { type: 'op', value: part }
+ })
+}
+
+function roundPrecision(value: number, precision = 0): number {
+ const p = Number(precision) || 0
+ const factor = 10 ** p
+ return Math.round(value * factor) / factor
+}
+
+function floorPrecision(value: number, precision = 0): number {
+ const p = Number(precision) || 0
+ const factor = 10 ** p
+ return Math.floor(value * factor) / factor
+}
+
+function ceilPrecision(value: number, precision = 0): number {
+ const p = Number(precision) || 0
+ const factor = 10 ** p
+ return Math.ceil(value * factor) / factor
+}
+
+function applyFunction(name: string, args: number[]): number {
+ if (args.length < 1) throw new Error(`Invalid arguments for ${name}`)
+
+ switch (name) {
+ case 'ROUND':
+ return roundPrecision(args[0], args[1] ?? 0)
+ case 'FLOOR':
+ return floorPrecision(args[0], args[1] ?? 0)
+ case 'CEIL':
+ case 'CEILING':
+ return ceilPrecision(args[0], args[1] ?? 0)
+ default:
+ throw new Error(`Unknown function: ${name}`)
+ }
+}
+
+function parseFactor(tokens: FormulaToken[], position: { i: number }, variables: Record): number {
+ const token = tokens[position.i]
+ if (!token) throw new Error('Unexpected end of formula')
+
+ if (token.type === 'number') {
+ position.i++
+ return Number(token.value)
+ }
+
+ if (token.type === 'ident') {
+ position.i++
+ const name = token.value
+ const next = tokens[position.i]
+ if (next && next.type === 'op' && next.value === '(') {
+ position.i++
+ const args: number[] = []
+ if (!(tokens[position.i] && tokens[position.i].type === 'op' && tokens[position.i].value === ')')) {
+ args.push(parseExpression(tokens, position, variables))
+ while (tokens[position.i] && tokens[position.i].type === 'op' && tokens[position.i].value === ',') {
+ position.i++
+ args.push(parseExpression(tokens, position, variables))
+ }
+ }
+ if (!tokens[position.i] || tokens[position.i].type !== 'op' || tokens[position.i].value !== ')') {
+ throw new Error('Missing closing parenthesis')
+ }
+ position.i++
+ return applyFunction(name, args)
+ }
+
+ if (!(name in variables)) throw new Error(`Unknown variable: ${name}`)
+ return variables[name]
+ }
+
+ if (token.type === 'op' && token.value === '(') {
+ position.i++
+ const value = parseExpression(tokens, position, variables)
+ if (!tokens[position.i] || tokens[position.i].type !== 'op' || tokens[position.i].value !== ')') {
+ throw new Error('Missing closing parenthesis')
+ }
+ position.i++
+ return value
+ }
+
+ if (token.type === 'op' && token.value === '-') {
+ position.i++
+ return -parseFactor(tokens, position, variables)
+ }
+
+ if (token.type === 'op' && token.value === '+') {
+ position.i++
+ return parseFactor(tokens, position, variables)
+ }
+
+ throw new Error('Invalid formula syntax')
+}
+
+function parseTerm(tokens: FormulaToken[], position: { i: number }, variables: Record): number {
+ let value = parseFactor(tokens, position, variables)
+
+ while (position.i < tokens.length && tokens[position.i].type === 'op' && ['*', '/'].includes(tokens[position.i].value)) {
+ const operator = tokens[position.i].value
+ position.i++
+ const right = parseFactor(tokens, position, variables)
+ value = operator === '/' ? value / right : value * right
+ }
+
+ return value
+}
+
+function parseExpression(tokens: FormulaToken[], position: { i: number }, variables: Record): number {
+ let value = parseTerm(tokens, position, variables)
+
+ while (position.i < tokens.length && tokens[position.i].type === 'op' && ['+', '-'].includes(tokens[position.i].value)) {
+ const operator = tokens[position.i].value
+ position.i++
+ const right = parseTerm(tokens, position, variables)
+ value = operator === '+' ? value + right : value - right
+ }
+
+ return value
+}
+
+export function evaluatePriceFormula(formula: string, p: number, w: number): number | null {
+ const trimmed = (formula || '').trim()
+ if (!trimmed) return null
+
+ try {
+ const tokens = tokenize(trimmed)
+ const position = { i: 0 }
+ const result = parseExpression(tokens, position, {
+ P: Number(p) || 0,
+ W: Number(w) || 0,
+ MG: FORMULA_MG
+ })
+
+ if (position.i < tokens.length || !Number.isFinite(result)) {
+ return null
+ }
+
+ return Math.round(result)
+ } catch {
+ return null
+ }
+}
+
+/** P = قیمت وسط مرجع (بدون تفاوت ارائهدهنده) */
+export function computeFormulaProductPrice(
+ referenceMiddlePrice: number,
+ formula: string | undefined | null,
+ weight: number
+): number {
+ const expression = (formula || '').trim() || DEFAULT_PRICE_FORMULA
+ return evaluatePriceFormula(expression, referenceMiddlePrice, resolveFormulaWeight(weight)) ?? 0
+}
diff --git a/src/utils/productEditorPrice.ts b/src/utils/productEditorPrice.ts
index 837f9dc..aa7818c 100644
--- a/src/utils/productEditorPrice.ts
+++ b/src/utils/productEditorPrice.ts
@@ -2,6 +2,7 @@ import type { last } from '@/model/price'
import type { MethodePrices } from '@/model/product'
import type { MeltedPriceSource } from '@/utils/productCategories'
import { usesMeltedPriceEditor } from '@/utils/productCategories'
+import { computeFormulaProductPrice } from '@/utils/formulaPrice'
const GOLD_DOT_REFERENCE_NAME = 'گلد دات ای از آبشده - رسمی - نقد فردا'
@@ -99,6 +100,8 @@ export function computeProductEditorPricePreview(input: {
reading_method?: string | null
price_offset_percent?: number | null
use_last_price_if_zero?: boolean | null
+ price_formula_enabled?: boolean | null
+ price_formula?: string | null
}
methodOption: string
meltedPriceOption: MeltedPriceSource
@@ -108,6 +111,35 @@ export function computeProductEditorPricePreview(input: {
const offsetPercent = Number(input.form.price_offset_percent ?? 0)
const melted = usesMeltedPriceEditor(input.form)
+ if (!melted && input.form.price_formula_enabled) {
+ if (!input.form.reading_method) {
+ return { status: 'empty', message: 'مرجع قیمت را انتخاب کنید' }
+ }
+ const entry = findMethodPriceEntry(input.pricesData, input.form.reading_method)
+ if (!entry) {
+ return { status: 'empty', message: '' }
+ }
+ const { base } = normalizeReferenceBase(entry)
+ if (base <= 0) {
+ const fallback = resolveStoredFallbackPrice(
+ input.form.price,
+ input.form.use_last_price_if_zero ?? false,
+ input.appLastPrice?.last_historical
+ )
+ return fallback > 0
+ ? { status: 'ready', amount: fallback, source: 'آخرین قیمت ذخیرهشده' }
+ : { status: 'empty', message: 'قیمت مرجع صفر است' }
+ }
+ const amount = computeFormulaProductPrice(
+ base,
+ input.form.price_formula,
+ Number(input.form.amount) || 0
+ )
+ return amount > 0
+ ? { status: 'ready', amount, source: `فرمولی — ${entry.name}` }
+ : { status: 'empty', message: 'فرمول قابل محاسبه نیست' }
+ }
+
if (melted) {
if (input.meltedPriceOption === 'fixed') {
const amount = Number(input.form.price) || 0