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
+209
View File
@@ -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
}
+32
View File
@@ -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