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:
2026-08-10 16:47:29 +03:30
co-authored by Cursor
parent 71a48c9c08
commit f421fcb83f
9 changed files with 515 additions and 9 deletions
+281
View File
@@ -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;
}
}
@@ -95,6 +95,11 @@ class ProductDisplayPriceService
continue;
}
if (FormulaPriceService::isEnabled($product) && $product->reading_method && is_array($zaarboxFeed)) {
$this->applyFormulaFeedPrice($product, $zaarboxFeed, $zaarbox);
continue;
}
if ($this->usesAppPrice($product)) {
$base = $this->productUsesLastPriceIfZero($product)
? $this->resolveAppBasePrice()
@@ -308,6 +313,45 @@ class ProductDisplayPriceService
return $this->extractBaseFromPayload($cached);
}
/** @param array<int, array<string, mixed>> $feed */
private function applyFormulaFeedPrice(Product $product, array $feed, ZaarboxPriceService $zaarbox): void
{
$useLast = $this->productUsesLastPriceIfZero($product);
$row = $zaarbox->findFeedRow($feed, (string) $product->reading_method);
if ($row === null) {
$product->setAttribute('reference_provider_active', $useLast);
$this->applyPriceOrFallback($product, 0, $useLast);
return;
}
$parts = $zaarbox->resolveReferenceBaseProfit($row);
$providerActive = $useLast || $parts['price'] > 0;
$product->setAttribute('reference_provider_active', $providerActive);
if (! $useLast && $parts['price'] <= 0) {
$this->zeroProductPrice($product);
return;
}
$computed = FormulaPriceService::calculateForProduct($product, [
'price' => $parts['price'],
'active' => true,
]);
if ($computed === null || $computed <= 0) {
$this->applyPriceOrFallback($product, 0, $useLast);
return;
}
$product->setAttribute('price', $computed);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
}
/** @param array<int, array<string, mixed>> $feed */
private function applyReferenceFeedPrice(Product $product, array $feed, ZaarboxPriceService $zaarbox): void
{
@@ -410,6 +454,10 @@ class ProductDisplayPriceService
}
}
if (FormulaPriceService::isEnabled($product)) {
return (int) (($product->price ?? 0) + ($product->price_profit ?? 0));
}
if ((int) ($product->direct_price ?? 0) === 1) {
return (int) (($product->price ?? 0) + ($product->price_profit ?? 0));
}
+39
View File
@@ -78,6 +78,45 @@ class ProductPriceService
return null;
}
if (FormulaPriceService::isEnabled($product)) {
$computed = FormulaPriceService::calculateForProduct($product, [
'price' => $basePrice,
'active' => true,
]);
if ($computed === null || $computed <= 0) {
if (
Product::hasUseLastPriceIfZeroColumn()
&& ($product->use_last_price_if_zero ?? false)
) {
return null;
}
$computed = 0;
}
$oldPrice = (int) ($product->price ?? 0);
$data = ['price' => $computed];
if (\Illuminate\Support\Facades\Schema::hasColumn($product->getTable(), 'price_profit')) {
$data['price_profit'] = 0;
}
Product::where('id', $product->id)->update($data);
return [
'product_id' => $product->id,
'product_title' => $product->title,
'reading_method' => $product->reading_method,
'source_name' => $sourceName ?? $product->reading_method,
'base_price' => $basePrice,
'base_profit' => $baseProfit,
'offset_percent' => 0.0,
'old_price' => $oldPrice,
'new_price' => $computed,
'changed' => $oldPrice !== $computed,
'price_formula' => true,
];
}
$offsetPercent = self::offsetPercent($product);
$adjustedPrice = self::adjustedTotal($basePrice, $baseProfit, $offsetPercent);
$oldPrice = (int) ($product->price ?? 0);
+7
View File
@@ -739,6 +739,13 @@ class ZaarboxPriceService
return 0;
}
if (FormulaPriceService::isEnabled($product)) {
return FormulaPriceService::calculateForProduct($product, [
'price' => $parts['price'],
'active' => true,
]) ?? 0;
}
return ProductPriceService::adjustedTotal(
$parts['price'],
$parts['profit'],