Add formula-based product pricing (P/W/MG) for Parsian-style unit prices.
Port FormulaPriceService from colleague app so admins can compute a single unit price from reference mid, weight, and wage instead of offset coefficients. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class FormulaPriceService
|
||||
{
|
||||
/** ضریب تبدیل مثقال به گرم — قابل استفاده در فرمول با MG */
|
||||
public const MG = 4.331802;
|
||||
|
||||
public const DEFAULT_FORMULA = '((P)/MG)*W+100000';
|
||||
|
||||
public static function isEnabled(?Product $product): bool
|
||||
{
|
||||
if (! Product::hasPriceFormulaColumns()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($product?->price_formula_enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* P = قیمت وسط مرجع (بدون تفاوت ارائهدهنده / بدون price_offset_percent)
|
||||
*
|
||||
* @param array<string, mixed>|null $mazanehRow
|
||||
*/
|
||||
public static function calculateForProduct(Product $product, ?array $mazanehRow): ?int
|
||||
{
|
||||
if (! self::isEnabled($product) || $mazanehRow === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (array_key_exists('active', $mazanehRow) && $mazanehRow['active'] == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$p = (float) ($mazanehRow['price'] ?? 0);
|
||||
if ($p <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$formula = trim((string) ($product->price_formula ?? ''));
|
||||
if ($formula === '') {
|
||||
$formula = self::DEFAULT_FORMULA;
|
||||
}
|
||||
|
||||
return self::evaluate($formula, $p, self::resolveWeight($product->amount ?? 0));
|
||||
}
|
||||
|
||||
public static function resolveWeight(float $amount): float
|
||||
{
|
||||
return $amount == 0.0 ? 1.0 : (float) $amount;
|
||||
}
|
||||
|
||||
public static function evaluate(string $formula, float $p, float $w): ?int
|
||||
{
|
||||
$formula = trim($formula);
|
||||
if ($formula === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = self::evaluateExpression($formula, [
|
||||
'P' => $p,
|
||||
'W' => $w,
|
||||
'MG' => self::MG,
|
||||
]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_finite($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) round($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, float> $variables
|
||||
*/
|
||||
private static function evaluateExpression(string $expression, array $variables): float
|
||||
{
|
||||
$tokens = self::tokenize($expression);
|
||||
$position = 0;
|
||||
$result = self::parseExpression($tokens, $position, $variables);
|
||||
|
||||
if ($position < count($tokens)) {
|
||||
throw new InvalidArgumentException('Invalid formula syntax');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{type: string, value: string}>
|
||||
*/
|
||||
private static function tokenize(string $expression): array
|
||||
{
|
||||
$expression = str_replace(['×', '÷'], ['*', '/'], $expression);
|
||||
$expression = preg_replace('/\s+/', '', $expression) ?? '';
|
||||
|
||||
if ($expression === '' || ! preg_match('/^[0-9A-Za-z_.,+\-*\/()]+$/', $expression)) {
|
||||
throw new InvalidArgumentException('Invalid formula characters');
|
||||
}
|
||||
|
||||
preg_match_all('/([0-9]+(?:\.[0-9]+)?|[A-Za-z_][A-Za-z0-9_]*|[+\-*\/(),])/', $expression, $matches);
|
||||
|
||||
if (($matches[0] ?? []) === [] || implode('', $matches[0]) !== $expression) {
|
||||
throw new InvalidArgumentException('Invalid formula syntax');
|
||||
}
|
||||
|
||||
$tokens = [];
|
||||
foreach ($matches[0] as $part) {
|
||||
if (preg_match('/^[0-9]/', $part)) {
|
||||
$tokens[] = ['type' => 'number', 'value' => $part];
|
||||
} elseif (preg_match('/^[A-Za-z_]/', $part)) {
|
||||
$tokens[] = ['type' => 'ident', 'value' => strtoupper($part)];
|
||||
} else {
|
||||
$tokens[] = ['type' => 'op', 'value' => $part];
|
||||
}
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{type: string, value: string}> $tokens
|
||||
* @param array<string, float> $variables
|
||||
*/
|
||||
private static function parseExpression(array $tokens, int &$position, array $variables): float
|
||||
{
|
||||
$value = self::parseTerm($tokens, $position, $variables);
|
||||
|
||||
while ($position < count($tokens) && in_array($tokens[$position]['value'], ['+', '-'], true)) {
|
||||
$operator = $tokens[$position]['value'];
|
||||
$position++;
|
||||
$right = self::parseTerm($tokens, $position, $variables);
|
||||
$value = $operator === '+' ? $value + $right : $value - $right;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{type: string, value: string}> $tokens
|
||||
* @param array<string, float> $variables
|
||||
*/
|
||||
private static function parseTerm(array $tokens, int &$position, array $variables): float
|
||||
{
|
||||
$value = self::parseFactor($tokens, $position, $variables);
|
||||
|
||||
while ($position < count($tokens) && in_array($tokens[$position]['value'], ['*', '/'], true)) {
|
||||
$operator = $tokens[$position]['value'];
|
||||
$position++;
|
||||
$right = self::parseFactor($tokens, $position, $variables);
|
||||
if ($operator === '/') {
|
||||
if (abs($right) < 1e-12) {
|
||||
throw new InvalidArgumentException('Division by zero');
|
||||
}
|
||||
$value /= $right;
|
||||
} else {
|
||||
$value *= $right;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{type: string, value: string}> $tokens
|
||||
* @param array<string, float> $variables
|
||||
*/
|
||||
private static function parseFactor(array $tokens, int &$position, array $variables): float
|
||||
{
|
||||
if ($position >= count($tokens)) {
|
||||
throw new InvalidArgumentException('Unexpected end of formula');
|
||||
}
|
||||
|
||||
$token = $tokens[$position];
|
||||
|
||||
if ($token['type'] === 'number') {
|
||||
$position++;
|
||||
|
||||
return (float) $token['value'];
|
||||
}
|
||||
|
||||
if ($token['type'] === 'ident') {
|
||||
$position++;
|
||||
$name = $token['value'];
|
||||
|
||||
if ($position < count($tokens) && $tokens[$position]['value'] === '(') {
|
||||
$position++;
|
||||
$args = [];
|
||||
|
||||
if ($position >= count($tokens) || $tokens[$position]['value'] !== ')') {
|
||||
$args[] = self::parseExpression($tokens, $position, $variables);
|
||||
|
||||
while ($position < count($tokens) && $tokens[$position]['value'] === ',') {
|
||||
$position++;
|
||||
$args[] = self::parseExpression($tokens, $position, $variables);
|
||||
}
|
||||
}
|
||||
|
||||
if ($position >= count($tokens) || $tokens[$position]['value'] !== ')') {
|
||||
throw new InvalidArgumentException('Missing closing parenthesis');
|
||||
}
|
||||
$position++;
|
||||
|
||||
return self::applyFunction($name, $args);
|
||||
}
|
||||
|
||||
if (! array_key_exists($name, $variables)) {
|
||||
throw new InvalidArgumentException("Unknown variable: {$name}");
|
||||
}
|
||||
|
||||
return (float) $variables[$name];
|
||||
}
|
||||
|
||||
if ($token['value'] === '(') {
|
||||
$position++;
|
||||
$value = self::parseExpression($tokens, $position, $variables);
|
||||
if ($position >= count($tokens) || $tokens[$position]['value'] !== ')') {
|
||||
throw new InvalidArgumentException('Missing closing parenthesis');
|
||||
}
|
||||
$position++;
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($token['value'] === '-') {
|
||||
$position++;
|
||||
|
||||
return -self::parseFactor($tokens, $position, $variables);
|
||||
}
|
||||
|
||||
if ($token['value'] === '+') {
|
||||
$position++;
|
||||
|
||||
return self::parseFactor($tokens, $position, $variables);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Invalid formula syntax');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $args
|
||||
*/
|
||||
private static function applyFunction(string $name, array $args): float
|
||||
{
|
||||
if ($args === []) {
|
||||
throw new InvalidArgumentException("Invalid arguments for {$name}");
|
||||
}
|
||||
|
||||
$value = (float) $args[0];
|
||||
$precision = isset($args[1]) ? (int) $args[1] : 0;
|
||||
|
||||
return match ($name) {
|
||||
'ROUND' => (float) round($value, $precision),
|
||||
'FLOOR' => self::floorPrecision($value, $precision),
|
||||
'CEIL', 'CEILING' => self::ceilPrecision($value, $precision),
|
||||
default => throw new InvalidArgumentException("Unknown function: {$name}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static function floorPrecision(float $value, int $precision): float
|
||||
{
|
||||
$factor = 10 ** $precision;
|
||||
|
||||
return floor($value * $factor) / $factor;
|
||||
}
|
||||
|
||||
private static function ceilPrecision(float $value, int $precision): float
|
||||
{
|
||||
$factor = 10 ** $precision;
|
||||
|
||||
return ceil($value * $factor) / $factor;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user