diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index f0aae1c..14ad250 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -15,11 +15,13 @@ use Illuminate\Support\Facades\Http; use \App\Http\Controllers\Customer\FundController; use App\Http\Controllers\Site\ReportController; use App\Services\ConfigService; +use App\Services\FormulaPriceService; use App\Services\ProductDisplayPriceService; use App\Services\ProductPriceService; use App\Services\ProductPriceSyncService; use App\Services\PriceBroadcastService; use App\Services\CategoryService; +use App\Services\ZaarboxPriceService; use Illuminate\Support\Facades\Schema; @@ -117,6 +119,11 @@ class ProductController extends SuperController $data = $this->filterProductData($request->all()); $product = Product::create($data); + + if (FormulaPriceService::isEnabled($product) && $product->reading_method) { + app(ZaarboxPriceService::class)->syncProductFromApi($product); + } + $this->broadcastPricesAfterResponse(true); return $this->resultCreate($product->id); @@ -134,7 +141,10 @@ class ProductController extends SuperController } // قیمت دستی ⇒ تعدیل مرجع بی‌معناست و صفر می‌شود. // قیمت اپ در filterProductData عمداً price=0 می‌گذارد؛ نباید تعدیل را پاک کند. - if (isset($data['price']) && empty($data['app_price'])) { + // قیمت فرمولی reading_method می‌خواهد — با وجود price ذخیره‌شده مرجع را پاک نکن. + $formulaEnabled = Product::hasPriceFormulaColumns() + && filter_var($data['price_formula_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + if (isset($data['price']) && empty($data['app_price']) && ! $formulaEnabled) { $data['reading_method'] = null; if (Product::hasPriceOffsetColumn()) { $data['price_offset_percent'] = 0; @@ -166,6 +176,7 @@ class ProductController extends SuperController && $product->reading_method && array_key_exists('price_offset_percent', $data) && !$clearingReadingMethod + && ! $formulaEnabled ) { ProductPriceService::reapplyOffsetPercent( $product, @@ -176,6 +187,12 @@ class ProductController extends SuperController } $product->update($data); + $product->refresh(); + + if (FormulaPriceService::isEnabled($product) && $product->reading_method) { + app(ZaarboxPriceService::class)->syncProductFromApi($product); + } + $this->broadcastPricesAfterResponse(true); return response()->json(true); @@ -659,6 +676,35 @@ class ProductController extends SuperController } $data['price'] = 0; $data['price_profit'] = 0; + if (Product::hasPriceFormulaColumns()) { + $data['price_formula_enabled'] = false; + } + } + } + + if (!Product::hasPriceFormulaColumns()) { + unset($data['price_formula_enabled'], $data['price_formula']); + } else { + $formulaEnabled = filter_var($data['price_formula_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + $data['price_formula_enabled'] = $formulaEnabled; + $formula = trim((string) ($data['price_formula'] ?? '')); + if ($formulaEnabled) { + if ($formula === '') { + $formula = FormulaPriceService::DEFAULT_FORMULA; + } + $data['price_formula'] = $formula; + $data['app_price'] = false; + if (Product::hasDirectPriceColumn()) { + $data['direct_price'] = 1; + } + if (Product::hasPriceOffsetColumn()) { + $data['price_offset_percent'] = 0; + } + if (empty($data['reading_method'])) { + throw new UnprocessableEntityHttpException('برای قیمت فرمولی، یک مرجع قیمت انتخاب کنید'); + } + } else { + $data['price_formula'] = $formula !== '' ? $formula : null; } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 3c948f6..5dd8cdd 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -773,8 +773,14 @@ class Controller extends BaseController 'direct_price' => is_object($product) ? ($product->direct_price ?? null) : null, 'group_type' => is_object($product) ? ($product->group_type ?? null) : null, 'reading_method' => is_object($product) ? ($product->reading_method ?? null) : null, + 'price_formula_enabled' => is_object($product) ? ($product->price_formula_enabled ?? null) : null, ]; + // قیمت فرمولی = قیمت واحد هر عدد (وزن داخل فرمول است) + if (! empty($payload['price_formula_enabled'])) { + return 1.0; + } + if (! empty($payload['reading_method'])) { return 1.0; } diff --git a/app/Http/Controllers/Single/SingleOrderController.php b/app/Http/Controllers/Single/SingleOrderController.php index 26f8574..8656128 100644 --- a/app/Http/Controllers/Single/SingleOrderController.php +++ b/app/Http/Controllers/Single/SingleOrderController.php @@ -27,6 +27,7 @@ use App\Services\MontzarService; use App\Services\ConfigService; use App\Services\ProductPriceService; use App\Services\ProductDisplayPriceService; +use App\Services\FormulaPriceService; use App\Services\ReferralGiftService; use App\Services\WalletBroadcastService; @@ -127,7 +128,8 @@ class SingleOrderController extends Controller $data['price_original'] = $this->resolveProductUnitBasePrice($data['product']); $this->applyCashPriceDiff($config, $data['product'], 1, $data, (float) $data['price_original']); $data['total_fee'] = $data['number'] * $data['fee']; - $data['total_price_original'] = $data['product']['amount'] * ($data['price_original'] ?? 0); + $data['total_price_original'] = $this->resolveProductOrderPricingAmount($data['product'], 1.0) + * ($data['price_original'] ?? 0); // Check if the method is cash if ($data['method'] == "cash") { @@ -147,7 +149,8 @@ class SingleOrderController extends Controller $data['price_original'] = $this->resolveProductUnitBasePrice($data['product']); $this->applyCashPriceDiff($config, $data['product'], 2, $data, (float) $data['price_original']); $data['total_fee'] = $data['number'] * $data['fee']; - $data['total_price_original'] = $data['product']['amount'] * ($data['price_original'] ?? 0); + $data['total_price_original'] = $this->resolveProductOrderPricingAmount($data['product'], 1.0) + * ($data['price_original'] ?? 0); $this->assertMinimumSellCash($data); @@ -394,7 +397,7 @@ class SingleOrderController extends Controller { // همان قیمت پایهٔ سفارش (price_original / قیمت اپ) — نه ستون کهنهٔ products.price $data['price'] = $this->resolveProductOrderUnitPrice($data) + (float) ($data['difference'] ?? 0); - $data['amount'] = $data['number'] * $data['product']['amount']; + $data['amount'] = $this->resolveProductOrderPricingAmount($data['product'], (float) $data['number']); // محصول تعدادی: کارمزد فقط از ریال (fee_service_charge_percentage) $feeRate = isset($data['fee_free']) && $data['fee_free'] === true ? 0.0 @@ -452,7 +455,7 @@ class SingleOrderController extends Controller { $data['price'] += $data['difference']; - $data['amount'] = $data['number'] * $data['product']['amount']; + $data['amount'] = $this->resolveProductOrderPricingAmount($data['product'], (float) $data['number']); // محصول تعدادی: کارمزد از ریال (fee_service_charge_percentage) $feeRate = isset($data['fee_free']) && $data['fee_free'] === true ? 0.0 @@ -670,7 +673,7 @@ class SingleOrderController extends Controller { // همان قیمت پایهٔ سفارش (price_original / قیمت اپ) — نه ستون کهنهٔ products.price $data['price'] = max(0.0, $this->resolveProductOrderUnitPrice($data) - (float) ($data['difference'] ?? 0)); - $data['amount'] = $data['number'] * $data['product']['amount']; + $data['amount'] = $this->resolveProductOrderPricingAmount($data['product'], (float) $data['number']); // محصول تعدادی: کارمزد فقط از ریال $feeRate = isset($data['fee_free']) && $data['fee_free'] === true ? 0.0 @@ -723,7 +726,7 @@ class SingleOrderController extends Controller private function processSellTransactionProductByGold(&$data, $wallet, $wallet_interface) { $data['price'] -= $data['difference']; - $data['amount'] = $data['number'] * $data['product']['amount']; + $data['amount'] = $this->resolveProductOrderPricingAmount($data['product'], (float) $data['number']); // محصول تعدادی: کارمزد فقط از ریال $feeRate = isset($data['fee_free']) && $data['fee_free'] === true ? 0.0 @@ -1100,7 +1103,7 @@ class SingleOrderController extends Controller if (!empty($data['product_id']) && isset($data['number'])) { $product = $data['product'] ?? Product::find($data['product_id']); if ($product) { - return (float) $data['number'] * (float) $product->amount; + return $this->resolveProductOrderPricingAmount($product, (float) $data['number']); } } @@ -1161,7 +1164,7 @@ class SingleOrderController extends Controller $product = $data['product'] ?? Product::find($data['product_id']); if ($product) { $difference = (float) ($data['difference'] ?? 0); - $amount = (float) $data['number'] * (float) $product->amount; + $amount = $this->resolveProductOrderPricingAmount($product, (float) $data['number']); $sellUnitPrice = max(0.0, $this->resolveProductOrderUnitPrice($data) - $difference); return $amount * $sellUnitPrice; diff --git a/app/Models/Product.php b/app/Models/Product.php index db5c5b2..3f1b3d3 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -28,6 +28,7 @@ class Product extends Model private static ?bool $cashFeePercentModeColumnsExist = null; private static ?bool $physicalDeliveryColumnsExist = null; private static ?bool $descriptionColumnExists = null; + private static ?bool $priceFormulaColumnsExist = null; protected static function booted(): void { @@ -78,9 +79,32 @@ class Product extends Model $product->attributes['min_physical_delivery_quantity'] ); } + if (!static::hasPriceFormulaColumns()) { + unset( + $product->attributes['price_formula_enabled'], + $product->attributes['price_formula'] + ); + } }); } + public static function hasPriceFormulaColumns(): bool + { + if (static::$priceFormulaColumnsExist !== null) { + return static::$priceFormulaColumnsExist; + } + + try { + $table = (new static)->getTable(); + static::$priceFormulaColumnsExist = Schema::hasColumn($table, 'price_formula_enabled') + && Schema::hasColumn($table, 'price_formula'); + } catch (\Throwable) { + static::$priceFormulaColumnsExist = false; + } + + return static::$priceFormulaColumnsExist; + } + public static function hasPhysicalDeliveryColumns(): bool { if (static::$physicalDeliveryColumnsExist !== null) { @@ -418,6 +442,8 @@ class Product extends Model 'description', 'direct_price', 'app_price', // روشن: قیمت واحد از قیمت زندهٔ اپ هر گرم × وزن محاسبه می‌شود + 'price_formula_enabled', + 'price_formula', ]; protected $attributes = [ @@ -427,6 +453,7 @@ class Product extends Model 'cash_fee_buy_is_percent' => false, 'cash_fee_sell_is_percent' => false, 'app_price' => false, + 'price_formula_enabled' => false, ]; protected $casts = [ @@ -457,6 +484,7 @@ class Product extends Model 'price_offset_percent' => 'float', 'price_profit' => 'int', 'app_price' => 'boolean', + 'price_formula_enabled' => 'boolean', ]; static $METAL_ID = [ diff --git a/app/Services/FormulaPriceService.php b/app/Services/FormulaPriceService.php new file mode 100644 index 0000000..96cdc18 --- /dev/null +++ b/app/Services/FormulaPriceService.php @@ -0,0 +1,281 @@ +price_formula_enabled); + } + + /** + * P = قیمت وسط مرجع (بدون تفاوت ارائه‌دهنده / بدون price_offset_percent) + * + * @param array|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 $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 + */ + 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 $tokens + * @param array $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 $tokens + * @param array $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 $tokens + * @param array $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 $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; + } +} diff --git a/app/Services/ProductDisplayPriceService.php b/app/Services/ProductDisplayPriceService.php index 7820485..54b5469 100644 --- a/app/Services/ProductDisplayPriceService.php +++ b/app/Services/ProductDisplayPriceService.php @@ -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> $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> $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)); } diff --git a/app/Services/ProductPriceService.php b/app/Services/ProductPriceService.php index 9ca5026..bae7028 100644 --- a/app/Services/ProductPriceService.php +++ b/app/Services/ProductPriceService.php @@ -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); diff --git a/app/Services/ZaarboxPriceService.php b/app/Services/ZaarboxPriceService.php index 3e18912..3367d80 100644 --- a/app/Services/ZaarboxPriceService.php +++ b/app/Services/ZaarboxPriceService.php @@ -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'], diff --git a/database/migrations/2026_08_10_000001_add_price_formula_fields_to_products_table.php b/database/migrations/2026_08_10_000001_add_price_formula_fields_to_products_table.php new file mode 100644 index 0000000..88053c5 --- /dev/null +++ b/database/migrations/2026_08_10_000001_add_price_formula_fields_to_products_table.php @@ -0,0 +1,48 @@ +boolean('price_formula_enabled')->default(false)->after('app_price'); + } else { + $table->boolean('price_formula_enabled')->default(false); + } + } + if (! Schema::hasColumn('products', 'price_formula')) { + $table->text('price_formula')->nullable()->after('price_formula_enabled'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('products')) { + return; + } + + Schema::table('products', function (Blueprint $table) { + $drop = []; + if (Schema::hasColumn('products', 'price_formula_enabled')) { + $drop[] = 'price_formula_enabled'; + } + if (Schema::hasColumn('products', 'price_formula')) { + $drop[] = 'price_formula'; + } + if ($drop !== []) { + $table->dropColumn($drop); + } + }); + } +};