Add formula price controls to the product editor.
Admins can enable a reference-based formula (P/W/MG) with live calculated unit price preview, matching the colleague-app pricing flow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 rounded-lg border border-solid border-amber-200 bg-amber-50 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<DashboardLabelWithHelp label="قیمت فرمولی" :help="formulaHelp" size="sm" />
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="!!modelValue.price_formula_enabled"
|
||||
checked-children="فعال"
|
||||
un-checked-children="خاموش"
|
||||
@change="onToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="modelValue.price_formula_enabled">
|
||||
<a-form-item class="!mb-0" name="price_formula">
|
||||
<template #label>
|
||||
<span class="text-sm">فرمول قیمت محصول</span>
|
||||
</template>
|
||||
<a-input
|
||||
v-model:value="localFormula"
|
||||
dir="ltr"
|
||||
class="formula-price-input font-mono text-sm"
|
||||
style="direction: ltr; text-align: left"
|
||||
placeholder="مثال: ROUND(((P)/MG)*W+100000, -3)"
|
||||
@focus="isEditingFormula = true"
|
||||
@blur="onFormulaBlur"
|
||||
@update:value="onFormulaInput"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 rounded-md bg-white px-2 py-1 text-sm">
|
||||
<span class="text-gray-600">قیمت محصول (محاسبهشده)</span>
|
||||
<span class="font-bold text-amber-800">{{ usePrice(computedProductPrice) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
DEFAULT_PRICE_FORMULA,
|
||||
getFormulaVariableHelp,
|
||||
resolveFormulaWeight,
|
||||
computeFormulaProductPrice
|
||||
} from '@/utils/formulaPrice'
|
||||
import { usePrice } from '@core/utils/usePrice'
|
||||
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
|
||||
import DashboardLabelWithHelp from '@/pages/dashboard/components/DashboardLabelWithHelp.vue'
|
||||
|
||||
export type FormulaPriceForm = {
|
||||
price_formula_enabled?: boolean
|
||||
price_formula?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: FormulaPriceForm
|
||||
referenceMiddlePrice: number
|
||||
productAmount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: FormulaPriceForm): void
|
||||
(e: 'enabled-change', enabled: boolean): void
|
||||
}>()
|
||||
|
||||
const isEditingFormula = ref(false)
|
||||
const localFormula = ref(props.modelValue.price_formula || DEFAULT_PRICE_FORMULA)
|
||||
|
||||
watch(
|
||||
() => props.modelValue.price_formula,
|
||||
(value) => {
|
||||
if (!isEditingFormula.value) {
|
||||
localFormula.value = value || DEFAULT_PRICE_FORMULA
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const formulaWeight = computed(() => resolveFormulaWeight(props.productAmount))
|
||||
|
||||
const formulaHelp = computed((): DashboardHelp => {
|
||||
const lines = getFormulaVariableHelp(formulaWeight.value)
|
||||
.map((item) => `${item.symbol} = ${item.label}`)
|
||||
.join(' | ')
|
||||
return {
|
||||
description:
|
||||
'قیمت واحد از فرمول و مظنه مرجع محاسبه میشود. تفاوت ارائهدهنده در فرمول اثر ندارد و فقط قیمت وسط مرجع (P) استفاده میشود.',
|
||||
formula: `${lines}. مثال: ROUND(((P)/MG)*W+100000, -3)`,
|
||||
}
|
||||
})
|
||||
|
||||
const computedProductPrice = computed(() =>
|
||||
computeFormulaProductPrice(
|
||||
props.referenceMiddlePrice,
|
||||
localFormula.value,
|
||||
formulaWeight.value
|
||||
)
|
||||
)
|
||||
|
||||
function patch(partial: Partial<FormulaPriceForm>) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...partial })
|
||||
}
|
||||
|
||||
function onFormulaInput(value: string) {
|
||||
localFormula.value = value
|
||||
patch({ price_formula: value })
|
||||
}
|
||||
|
||||
function onFormulaBlur() {
|
||||
isEditingFormula.value = false
|
||||
patch({ price_formula: localFormula.value })
|
||||
}
|
||||
|
||||
function onToggle(checked: boolean) {
|
||||
const nextFormula = checked
|
||||
? localFormula.value || props.modelValue.price_formula || DEFAULT_PRICE_FORMULA
|
||||
: props.modelValue.price_formula
|
||||
localFormula.value = nextFormula || DEFAULT_PRICE_FORMULA
|
||||
patch({
|
||||
price_formula_enabled: checked,
|
||||
price_formula: nextFormula,
|
||||
})
|
||||
emit('enabled-change', checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.formula-price-input :deep(input) {
|
||||
direction: ltr !important;
|
||||
text-align: left !important;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
</style>
|
||||
@@ -94,7 +94,7 @@
|
||||
<a-radio value="3">قیمت صفحه اصلی اپلیکیشن</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div v-if="methodOption === '1'" class="grid gap-4 md:grid-cols-3">
|
||||
<div v-if="methodOption === '1' && !form.price_formula_enabled" class="grid gap-4 md:grid-cols-3">
|
||||
<a-form-item name="price" class="!mb-0 md:col-span-2">
|
||||
<template #label>
|
||||
<DashboardLabelWithHelp label="قیمت وسط" :help="manualPriceHelp" size="sm" />
|
||||
@@ -110,7 +110,7 @@
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div v-if="methodOption === '2'" class="grid gap-4 md:grid-cols-3">
|
||||
<div v-if="methodOption === '2' || form.price_formula_enabled" class="grid gap-4 md:grid-cols-3">
|
||||
<a-form-item name="reading_method" class="!mb-0 md:col-span-2">
|
||||
<template #label>
|
||||
<DashboardLabelWithHelp label="مرجع قیمت" :help="priceReferenceHelp" size="sm" />
|
||||
@@ -128,7 +128,7 @@
|
||||
v-model:value="form.reading_method"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item name="price_offset_percent" class="!mb-0">
|
||||
<a-form-item v-if="!form.price_formula_enabled" name="price_offset_percent" class="!mb-0">
|
||||
<template #label>
|
||||
<DashboardLabelWithHelp label="تعدیل مرجع (٪)" :help="referencePriceOffsetHelp" size="sm" />
|
||||
</template>
|
||||
@@ -141,7 +141,7 @@
|
||||
<a-switch v-model:checked="form.use_last_price_if_zero" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div v-if="methodOption === '3'" class="flex flex-col gap-3">
|
||||
<div v-if="methodOption === '3' && !form.price_formula_enabled" class="flex flex-col gap-3">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<a-form-item name="price_offset_percent" class="!mb-0">
|
||||
<template #label>
|
||||
@@ -160,6 +160,14 @@
|
||||
قیمت هر واحد این محصول = «قیمت هر گرم اپ» × «وزن محصول به گرم». مثلاً برای وزن ۱٫۵ گرم، قیمت = ۱٫۵ برابر قیمت هر گرم اپ. برای فعال بودن، «وزن محصول» را «دارد» بگذارید.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormulaPriceSection
|
||||
:model-value="form"
|
||||
:reference-middle-price="formulaReferenceMiddlePrice"
|
||||
:product-amount="Number(form.amount) || 0"
|
||||
@update:model-value="onFormulaFormPatch"
|
||||
@enabled-change="onFormulaEnabledChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-solid border-blue-200 bg-blue-50 px-3 py-2">
|
||||
@@ -177,7 +185,7 @@
|
||||
class="truncate text-xs text-gray-500"
|
||||
>
|
||||
مبنا: {{ livePricePreview.source }}
|
||||
<span v-if="Number(form.price_offset_percent) !== 0">
|
||||
<span v-if="!form.price_formula_enabled && Number(form.price_offset_percent) !== 0">
|
||||
— تعدیل {{ form.price_offset_percent }}٪
|
||||
</span>
|
||||
</span>
|
||||
@@ -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<string, unknown>) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 'قیمت دستی را وارد کنید'
|
||||
|
||||
@@ -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<string, number>): 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<string, number>): 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<string, number>): 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user