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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user