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:
@@ -15,11 +15,13 @@ use Illuminate\Support\Facades\Http;
|
|||||||
use \App\Http\Controllers\Customer\FundController;
|
use \App\Http\Controllers\Customer\FundController;
|
||||||
use App\Http\Controllers\Site\ReportController;
|
use App\Http\Controllers\Site\ReportController;
|
||||||
use App\Services\ConfigService;
|
use App\Services\ConfigService;
|
||||||
|
use App\Services\FormulaPriceService;
|
||||||
use App\Services\ProductDisplayPriceService;
|
use App\Services\ProductDisplayPriceService;
|
||||||
use App\Services\ProductPriceService;
|
use App\Services\ProductPriceService;
|
||||||
use App\Services\ProductPriceSyncService;
|
use App\Services\ProductPriceSyncService;
|
||||||
use App\Services\PriceBroadcastService;
|
use App\Services\PriceBroadcastService;
|
||||||
use App\Services\CategoryService;
|
use App\Services\CategoryService;
|
||||||
|
use App\Services\ZaarboxPriceService;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +119,11 @@ class ProductController extends SuperController
|
|||||||
$data = $this->filterProductData($request->all());
|
$data = $this->filterProductData($request->all());
|
||||||
|
|
||||||
$product = Product::create($data);
|
$product = Product::create($data);
|
||||||
|
|
||||||
|
if (FormulaPriceService::isEnabled($product) && $product->reading_method) {
|
||||||
|
app(ZaarboxPriceService::class)->syncProductFromApi($product);
|
||||||
|
}
|
||||||
|
|
||||||
$this->broadcastPricesAfterResponse(true);
|
$this->broadcastPricesAfterResponse(true);
|
||||||
|
|
||||||
return $this->resultCreate($product->id);
|
return $this->resultCreate($product->id);
|
||||||
@@ -134,7 +141,10 @@ class ProductController extends SuperController
|
|||||||
}
|
}
|
||||||
// قیمت دستی ⇒ تعدیل مرجع بیمعناست و صفر میشود.
|
// قیمت دستی ⇒ تعدیل مرجع بیمعناست و صفر میشود.
|
||||||
// قیمت اپ در filterProductData عمداً price=0 میگذارد؛ نباید تعدیل را پاک کند.
|
// قیمت اپ در 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;
|
$data['reading_method'] = null;
|
||||||
if (Product::hasPriceOffsetColumn()) {
|
if (Product::hasPriceOffsetColumn()) {
|
||||||
$data['price_offset_percent'] = 0;
|
$data['price_offset_percent'] = 0;
|
||||||
@@ -166,6 +176,7 @@ class ProductController extends SuperController
|
|||||||
&& $product->reading_method
|
&& $product->reading_method
|
||||||
&& array_key_exists('price_offset_percent', $data)
|
&& array_key_exists('price_offset_percent', $data)
|
||||||
&& !$clearingReadingMethod
|
&& !$clearingReadingMethod
|
||||||
|
&& ! $formulaEnabled
|
||||||
) {
|
) {
|
||||||
ProductPriceService::reapplyOffsetPercent(
|
ProductPriceService::reapplyOffsetPercent(
|
||||||
$product,
|
$product,
|
||||||
@@ -176,6 +187,12 @@ class ProductController extends SuperController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$product->update($data);
|
$product->update($data);
|
||||||
|
$product->refresh();
|
||||||
|
|
||||||
|
if (FormulaPriceService::isEnabled($product) && $product->reading_method) {
|
||||||
|
app(ZaarboxPriceService::class)->syncProductFromApi($product);
|
||||||
|
}
|
||||||
|
|
||||||
$this->broadcastPricesAfterResponse(true);
|
$this->broadcastPricesAfterResponse(true);
|
||||||
|
|
||||||
return response()->json(true);
|
return response()->json(true);
|
||||||
@@ -659,6 +676,35 @@ class ProductController extends SuperController
|
|||||||
}
|
}
|
||||||
$data['price'] = 0;
|
$data['price'] = 0;
|
||||||
$data['price_profit'] = 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -773,8 +773,14 @@ class Controller extends BaseController
|
|||||||
'direct_price' => is_object($product) ? ($product->direct_price ?? null) : null,
|
'direct_price' => is_object($product) ? ($product->direct_price ?? null) : null,
|
||||||
'group_type' => is_object($product) ? ($product->group_type ?? null) : null,
|
'group_type' => is_object($product) ? ($product->group_type ?? null) : null,
|
||||||
'reading_method' => is_object($product) ? ($product->reading_method ?? 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'])) {
|
if (! empty($payload['reading_method'])) {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use App\Services\MontzarService;
|
|||||||
use App\Services\ConfigService;
|
use App\Services\ConfigService;
|
||||||
use App\Services\ProductPriceService;
|
use App\Services\ProductPriceService;
|
||||||
use App\Services\ProductDisplayPriceService;
|
use App\Services\ProductDisplayPriceService;
|
||||||
|
use App\Services\FormulaPriceService;
|
||||||
use App\Services\ReferralGiftService;
|
use App\Services\ReferralGiftService;
|
||||||
use App\Services\WalletBroadcastService;
|
use App\Services\WalletBroadcastService;
|
||||||
|
|
||||||
@@ -127,7 +128,8 @@ class SingleOrderController extends Controller
|
|||||||
$data['price_original'] = $this->resolveProductUnitBasePrice($data['product']);
|
$data['price_original'] = $this->resolveProductUnitBasePrice($data['product']);
|
||||||
$this->applyCashPriceDiff($config, $data['product'], 1, $data, (float) $data['price_original']);
|
$this->applyCashPriceDiff($config, $data['product'], 1, $data, (float) $data['price_original']);
|
||||||
$data['total_fee'] = $data['number'] * $data['fee'];
|
$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
|
// Check if the method is cash
|
||||||
if ($data['method'] == "cash") {
|
if ($data['method'] == "cash") {
|
||||||
@@ -147,7 +149,8 @@ class SingleOrderController extends Controller
|
|||||||
$data['price_original'] = $this->resolveProductUnitBasePrice($data['product']);
|
$data['price_original'] = $this->resolveProductUnitBasePrice($data['product']);
|
||||||
$this->applyCashPriceDiff($config, $data['product'], 2, $data, (float) $data['price_original']);
|
$this->applyCashPriceDiff($config, $data['product'], 2, $data, (float) $data['price_original']);
|
||||||
$data['total_fee'] = $data['number'] * $data['fee'];
|
$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);
|
$this->assertMinimumSellCash($data);
|
||||||
|
|
||||||
@@ -394,7 +397,7 @@ class SingleOrderController extends Controller
|
|||||||
{
|
{
|
||||||
// همان قیمت پایهٔ سفارش (price_original / قیمت اپ) — نه ستون کهنهٔ products.price
|
// همان قیمت پایهٔ سفارش (price_original / قیمت اپ) — نه ستون کهنهٔ products.price
|
||||||
$data['price'] = $this->resolveProductOrderUnitPrice($data) + (float) ($data['difference'] ?? 0);
|
$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)
|
// محصول تعدادی: کارمزد فقط از ریال (fee_service_charge_percentage)
|
||||||
$feeRate = isset($data['fee_free']) && $data['fee_free'] === true
|
$feeRate = isset($data['fee_free']) && $data['fee_free'] === true
|
||||||
? 0.0
|
? 0.0
|
||||||
@@ -452,7 +455,7 @@ class SingleOrderController extends Controller
|
|||||||
{
|
{
|
||||||
|
|
||||||
$data['price'] += $data['difference'];
|
$data['price'] += $data['difference'];
|
||||||
$data['amount'] = $data['number'] * $data['product']['amount'];
|
$data['amount'] = $this->resolveProductOrderPricingAmount($data['product'], (float) $data['number']);
|
||||||
// محصول تعدادی: کارمزد از ریال (fee_service_charge_percentage)
|
// محصول تعدادی: کارمزد از ریال (fee_service_charge_percentage)
|
||||||
$feeRate = isset($data['fee_free']) && $data['fee_free'] === true
|
$feeRate = isset($data['fee_free']) && $data['fee_free'] === true
|
||||||
? 0.0
|
? 0.0
|
||||||
@@ -670,7 +673,7 @@ class SingleOrderController extends Controller
|
|||||||
{
|
{
|
||||||
// همان قیمت پایهٔ سفارش (price_original / قیمت اپ) — نه ستون کهنهٔ products.price
|
// همان قیمت پایهٔ سفارش (price_original / قیمت اپ) — نه ستون کهنهٔ products.price
|
||||||
$data['price'] = max(0.0, $this->resolveProductOrderUnitPrice($data) - (float) ($data['difference'] ?? 0));
|
$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
|
$feeRate = isset($data['fee_free']) && $data['fee_free'] === true
|
||||||
? 0.0
|
? 0.0
|
||||||
@@ -723,7 +726,7 @@ class SingleOrderController extends Controller
|
|||||||
private function processSellTransactionProductByGold(&$data, $wallet, $wallet_interface)
|
private function processSellTransactionProductByGold(&$data, $wallet, $wallet_interface)
|
||||||
{
|
{
|
||||||
$data['price'] -= $data['difference'];
|
$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
|
$feeRate = isset($data['fee_free']) && $data['fee_free'] === true
|
||||||
? 0.0
|
? 0.0
|
||||||
@@ -1100,7 +1103,7 @@ class SingleOrderController extends Controller
|
|||||||
if (!empty($data['product_id']) && isset($data['number'])) {
|
if (!empty($data['product_id']) && isset($data['number'])) {
|
||||||
$product = $data['product'] ?? Product::find($data['product_id']);
|
$product = $data['product'] ?? Product::find($data['product_id']);
|
||||||
if ($product) {
|
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']);
|
$product = $data['product'] ?? Product::find($data['product_id']);
|
||||||
if ($product) {
|
if ($product) {
|
||||||
$difference = (float) ($data['difference'] ?? 0);
|
$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);
|
$sellUnitPrice = max(0.0, $this->resolveProductOrderUnitPrice($data) - $difference);
|
||||||
|
|
||||||
return $amount * $sellUnitPrice;
|
return $amount * $sellUnitPrice;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Product extends Model
|
|||||||
private static ?bool $cashFeePercentModeColumnsExist = null;
|
private static ?bool $cashFeePercentModeColumnsExist = null;
|
||||||
private static ?bool $physicalDeliveryColumnsExist = null;
|
private static ?bool $physicalDeliveryColumnsExist = null;
|
||||||
private static ?bool $descriptionColumnExists = null;
|
private static ?bool $descriptionColumnExists = null;
|
||||||
|
private static ?bool $priceFormulaColumnsExist = null;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
@@ -78,9 +79,32 @@ class Product extends Model
|
|||||||
$product->attributes['min_physical_delivery_quantity']
|
$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
|
public static function hasPhysicalDeliveryColumns(): bool
|
||||||
{
|
{
|
||||||
if (static::$physicalDeliveryColumnsExist !== null) {
|
if (static::$physicalDeliveryColumnsExist !== null) {
|
||||||
@@ -418,6 +442,8 @@ class Product extends Model
|
|||||||
'description',
|
'description',
|
||||||
'direct_price',
|
'direct_price',
|
||||||
'app_price', // روشن: قیمت واحد از قیمت زندهٔ اپ هر گرم × وزن محاسبه میشود
|
'app_price', // روشن: قیمت واحد از قیمت زندهٔ اپ هر گرم × وزن محاسبه میشود
|
||||||
|
'price_formula_enabled',
|
||||||
|
'price_formula',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
@@ -427,6 +453,7 @@ class Product extends Model
|
|||||||
'cash_fee_buy_is_percent' => false,
|
'cash_fee_buy_is_percent' => false,
|
||||||
'cash_fee_sell_is_percent' => false,
|
'cash_fee_sell_is_percent' => false,
|
||||||
'app_price' => false,
|
'app_price' => false,
|
||||||
|
'price_formula_enabled' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -457,6 +484,7 @@ class Product extends Model
|
|||||||
'price_offset_percent' => 'float',
|
'price_offset_percent' => 'float',
|
||||||
'price_profit' => 'int',
|
'price_profit' => 'int',
|
||||||
'app_price' => 'boolean',
|
'app_price' => 'boolean',
|
||||||
|
'price_formula_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
static $METAL_ID = [
|
static $METAL_ID = [
|
||||||
|
|||||||
@@ -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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (FormulaPriceService::isEnabled($product) && $product->reading_method && is_array($zaarboxFeed)) {
|
||||||
|
$this->applyFormulaFeedPrice($product, $zaarboxFeed, $zaarbox);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->usesAppPrice($product)) {
|
if ($this->usesAppPrice($product)) {
|
||||||
$base = $this->productUsesLastPriceIfZero($product)
|
$base = $this->productUsesLastPriceIfZero($product)
|
||||||
? $this->resolveAppBasePrice()
|
? $this->resolveAppBasePrice()
|
||||||
@@ -308,6 +313,45 @@ class ProductDisplayPriceService
|
|||||||
return $this->extractBaseFromPayload($cached);
|
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 */
|
/** @param array<int, array<string, mixed>> $feed */
|
||||||
private function applyReferenceFeedPrice(Product $product, array $feed, ZaarboxPriceService $zaarbox): void
|
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) {
|
if ((int) ($product->direct_price ?? 0) === 1) {
|
||||||
return (int) (($product->price ?? 0) + ($product->price_profit ?? 0));
|
return (int) (($product->price ?? 0) + ($product->price_profit ?? 0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,45 @@ class ProductPriceService
|
|||||||
return null;
|
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);
|
$offsetPercent = self::offsetPercent($product);
|
||||||
$adjustedPrice = self::adjustedTotal($basePrice, $baseProfit, $offsetPercent);
|
$adjustedPrice = self::adjustedTotal($basePrice, $baseProfit, $offsetPercent);
|
||||||
$oldPrice = (int) ($product->price ?? 0);
|
$oldPrice = (int) ($product->price ?? 0);
|
||||||
|
|||||||
@@ -739,6 +739,13 @@ class ZaarboxPriceService
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (FormulaPriceService::isEnabled($product)) {
|
||||||
|
return FormulaPriceService::calculateForProduct($product, [
|
||||||
|
'price' => $parts['price'],
|
||||||
|
'active' => true,
|
||||||
|
]) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
return ProductPriceService::adjustedTotal(
|
return ProductPriceService::adjustedTotal(
|
||||||
$parts['price'],
|
$parts['price'],
|
||||||
$parts['profit'],
|
$parts['profit'],
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('products')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('products', 'price_formula_enabled')) {
|
||||||
|
if (Schema::hasColumn('products', 'app_price')) {
|
||||||
|
$table->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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user