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:
2026-08-10 16:47:39 +03:30
co-authored by Cursor
parent 7e043b24be
commit 4dde172ed7
7 changed files with 450 additions and 10 deletions
@@ -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
+22 -3
View File
@@ -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 'قیمت دستی را وارد کنید'