Files
boursecu_server/app/Http/Controllers/Admin/ProductController.php
T
mohmdsdgandCursor e4e9c74872 fix(product): separate list and TV visibility toggles
Product active toggle now drives show_in_list; TV has its own endpoint and no longer requires active.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 10:16:35 +03:30

1020 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Controllers\SuperController;
use App\Models\Cnt;
use App\Models\Product;
use App\Models\Setting;
use App\DB\Te\TableEngine;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
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;
class ProductController extends SuperController
{
public function __construct()
{
$this->with = ['file'/*, 'price', 'default', 'sub'*/];
// $this->with = [];
parent::__construct(Product::class, $this->with, ['*'], null, null, ['title']);
}
public function tableProduct(Request $request)
{
$data = $request->all();
if (isset($data["select"]) && is_null($data["select"])){
$data["select"] = [];
}
if (is_null($this->query)){
$query_table = $this->model::with($this->with);
//*******کداضافه شده
// ->where('active', '<=', now()) // فیلتر محصولات فعال
// ->get();
//*********
}else{
$query_table = $this->query;
// $query_table = $query_table->where('active', '<=', now());
}
TableEngine::create($query_table, $data)
->initSearch($this->search)
->select($this->select)
->export($query_table);
$this->enrichAdminTablePrices($query_table['rows'] ?? null);
if (Schema::hasColumn((new Product)->getTable(), 'auto_open_at')) {
$query_table['auto_open_at'] = Product::selectRaw(
'SEC_TO_TIME(AVG(TIME_TO_SEC(TIME(auto_open_at)))) as avg_time'
)->value('avg_time');
}
if (Schema::hasColumn((new Product)->getTable(), 'auto_close_at')) {
$query_table['auto_close_at'] = Product::selectRaw(
'SEC_TO_TIME(AVG(TIME_TO_SEC(TIME(auto_close_at)))) as avg_time'
)->value('avg_time');
}
// $query_table['11'] = $avgDuration;
// $query_table['11'] = Product::avg('auto_open_at');
// $query_table['112'] = Product::avg('auto_close_at');
return $query_table;
}
public function archiveTable(Request $request)
{
$data = $request->all();
if (isset($data['select']) && is_null($data['select'])) {
$data['select'] = [];
}
$query_table = Product::onlyTrashed()->with($this->with);
TableEngine::create($query_table, $data)
->initSearch($this->search)
->select($this->select)
->export($query_table);
$this->enrichAdminTablePrices($query_table['rows'] ?? null);
return $query_table;
}
public function archive($id)
{
$product = Product::onlyTrashed()->findOrFail($id);
$product->restore();
$this->broadcastPricesAfterResponse();
return response()->json(true);
}
public function editProducts(Request $request)
{
if (isset($request->auto_open_at, $request->auto_close_at)) {
Product::query()->update([
'auto_open_at' => $request->auto_open_at,
'auto_close_at' => $request->auto_close_at,
]);
return response()->json(['message' => 'Products updated successfully.']);
}
return response()->json(['error' => 'auto_open_at and auto_close_at must be set'], 400);
}
public function store(Request $request)
{
$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);
}
public function update(Request $request, $id)
{
// return 1;
$data = $this->filterProductData($request->all());
$clearingReadingMethod = array_key_exists('reading_method', $data)
&& ($data['reading_method'] === null || $data['reading_method'] === '');
if ($clearingReadingMethod) {
$data['reading_method'] = null;
}
// قیمت دستی ⇒ تعدیل مرجع بی‌معناست و صفر می‌شود.
// قیمت اپ در filterProductData عمداً price=0 می‌گذارد؛ نباید تعدیل را پاک کند.
// قیمت فرمولی 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;
}
}
if (isset($data["auto_close_at"]) and isset($data["auto_close_at"])){
if ($data["auto_close_at"] == $data["auto_open_at"]){
unset($data["auto_open_at"]);
unset($data["auto_close_at"]);
}
}
// return $data['price'];
// if(!isset($data['default'])){
// throw new UnprocessableEntityHttpException('مقادیر اعتبارات را وارد کنید');
// }
// $data['auto_open_at'] = Carbon::parse($data['auto_open_at'])->format('Y-m-d H:i:s');
// $data['auto_close_at'] = Carbon::parse($data['auto_close_at'])->format('Y-m-d H:i:s');
$product = Product::findOrFail($id);
$oldOffsetPercent = (float) ($product->price_offset_percent ?? 0);
unset($data['trade_by_quantity']);
if (
Product::hasPriceOffsetColumn()
&& $product->reading_method
&& array_key_exists('price_offset_percent', $data)
&& !$clearingReadingMethod
&& ! $formulaEnabled
) {
ProductPriceService::reapplyOffsetPercent(
$product,
(float) $data['price_offset_percent'],
$oldOffsetPercent
);
unset($data['price'], $data['price_profit']);
}
$product->update($data);
$product->refresh();
if (FormulaPriceService::isEnabled($product) && $product->reading_method) {
app(ZaarboxPriceService::class)->syncProductFromApi($product);
}
$this->broadcastPricesAfterResponse(true);
return response()->json(true);
}
public function destroy($id)
{
$product = Product::findOrFail($id);
$product->update([
'active' => 0,
'show' => 0,
'open_buy' => 0,
'open_sell' => 0,
]);
$product->delete();
$this->broadcastPricesAfterResponse();
return response()->json(true);
}
public function listProduct()
{
if (app(ConfigService::class)->isStore()) {
// اپ مشتری: فقط DB — sync با HTTP فقط cron/admin (جلوگیری از pending طولانی list)
if (! $this->isCustomerApiRequest()) {
app(ProductPriceSyncService::class)->syncStorePrices(true);
$this->auto_close_open();
}
return response()->json($this->customerStoreProductListPayload());
}
// آبشده خانگی — کران قیمت را آپدیت می‌کند؛ list فقط از DB می‌خواند
$query = Product::with(['file']);
if (Product::hasShowInListColumn()) {
$query->where('show_in_list', 1);
} else {
$query->where('active', 1);
}
$products = $query->get();
if ($this->isCustomerApiRequest()) {
$products = $this->filterProductsForCustomerList($products);
$this->applyCustomerShow($products);
}
if (Product::hasPriceOffsetColumn() && $this->isCustomerApiRequest()) {
$products->each(function (Product $product) {
if ($product->reading_method) {
$product->makeHidden(['price_offset_percent']);
}
});
}
return response()->json($products);
}
/**
* تابلو /tv — بدون لاگین؛ فقط محصولات تیک‌خورده.
*/
public function listTvProducts()
{
if (! Product::hasShowOnTvColumn()) {
return response()->json([]);
}
$products = Product::with(['file'])
->where('show_on_tv', true)
->get();
if (app(ConfigService::class)->isStore()) {
$categoryService = app(CategoryService::class);
$products = $categoryService->filterProductsByVisibleCategories($products);
$products = $categoryService->sortProductsForStore($products);
}
$this->applyCustomerShow($products);
$products = $products->filter(function (Product $product) {
return (bool) $product->show_on_tv;
});
if (Product::hasPriceOffsetColumn()) {
$products->each(function (Product $product) {
if ($product->reading_method) {
$product->makeHidden(['price_offset_percent']);
}
});
}
$this->applyTvFormulaOverrides($products);
return response()->json($products->values()->all());
}
public function ProductById($id)
{
if (! $this->isCustomerApiRequest()) {
app(ProductPriceSyncService::class)->syncStorePrices(true);
$this->auto_close_open();
}
$product = Product::with(['file'])
->where('id', $id)
->first();
if ($product) {
if ($this->isCustomerApiRequest()) {
$this->applyCustomerShow(collect([$product]));
}
$product->similar = Product::with(['file'])
->where('group_type', $product->group_type)
->where('id', '!=', $id)
->get();
if ($this->isCustomerApiRequest()) {
$this->applyCustomerShow($product->similar);
}
} else {
return response()->json(false);
}
return response()->json($product);
}
public function getPrice()
{
if (app(ConfigService::class)->isStore()) {
app(ProductPriceSyncService::class)->syncStorePrices(true);
} else {
app(ProductPriceSyncService::class)->syncAll(true);
}
dispatch(function () {
app(PriceBroadcastService::class)->broadcastAfterPriceUpdate();
app(\App\Services\PriceChannelBroadcastService::class)->broadcastIfEligible();
})->afterResponse();
return response()->json(true);
}
public function auto_close_open()
{
$config = app(ConfigService::class);
$products = Product::with([])->get();
foreach ($products as $product) {
Product::where('id', $product->id)->update([
'show' => $config->isProductWithinTradingHours($product),
]);
}
return response()->json(true);
}
public function getProduct($id)
{
$product = Product::with($this->with)->where('id', $id)->first();
return response()->json($product);
}
/**
* نمایش در لیست محصولات مشتری — با active/show هم‌گام می‌ماند (وضعیت باز/بسته جدول).
* تابلو TV جداست و با این متد عوض نمی‌شود.
*/
public function active($id)
{
$product = Product::findOrFail($id);
$on = ! $this->isProductShownInList($product);
$payload = [
'active' => $on,
'show' => $on,
];
if (Product::hasShowInListColumn()) {
$payload['show_in_list'] = $on;
}
$product->update($payload);
$this->broadcastPricesAfterResponse(true);
return response()->json(true);
}
/**
* نمایش در تابلو TV — مستقل از لیست محصولات و active.
*/
public function showOnTv($id)
{
if (! Product::hasShowOnTvColumn()) {
return response()->json(['message' => 'ستون show_on_tv موجود نیست'], 404);
}
$product = Product::findOrFail($id);
$product->update([
'show_on_tv' => ! (bool) $product->show_on_tv,
]);
$this->broadcastPricesAfterResponse(true);
return response()->json(true);
}
public function hide($id)
{
$query = Product::findOrFail($id);
$query->update([
'show' => !$query->show
]);
return response()->json(true);
}
public function openSell($id)
{
return $this->toggleBooleanStatus($id, 'open_sell');
}
public function openBuy($id)
{
return $this->toggleBooleanStatus($id, 'open_buy');
}
public function auto_buy($id)
{
return $this->toggleBooleanStatus($id, 'auto_buy');
}
public function auto_sell($id)
{
return $this->toggleBooleanStatus($id, 'auto_sell');
}
private function toggleBooleanStatus($id, $field)
{
$product = Product::findOrFail($id);
$product->$field = !$product->$field;
$product->save();
if (!$product->open_buy and !$product->open_sell) {
$product->show = false;
$product->active = false;
} else {
$product->show = true;
$product->active = true;
}
$product->save();
return response()->json($product->$field);
}
public function auto_group($action, $method)
{
// Define valid actions and methods
$validActions = ['sell' => 'auto_sell', 'buy' => 'auto_buy', 'all' => ['auto_buy', 'auto_sell']];
$validMethods = ['true' => 1, 'false' => 0];
// Validate action and method
if (!isset($validActions[$action]) || !array_key_exists($method, $validMethods)) {
return response()->json(['error' => 'Invalid action or method'], 400);
}
// Get fields to update and value to set
$fields = $validActions[$action];
$value = $validMethods[$method];
// Update products
Product::query()->update(array_fill_keys((array)$fields, $value));
return response()->json(true);
}
private const ACTIVATION_SNAPSHOT_KEY = 'products_activation_snapshot';
public function activationSnapshotStatus()
{
$row = Cnt::where('key', self::ACTIVATION_SNAPSHOT_KEY)->first();
$payload = $row?->jsonPayload() ?? [];
$items = $payload['items'] ?? [];
return response()->json([
'has_snapshot' => ! empty($items),
'saved_at' => $payload['saved_at'] ?? null,
'count' => count($items),
]);
}
public function deactivateAllProducts()
{
$products = Product::query()
->where(function ($query) {
$query->where('active', 1)
->orWhere('open_buy', 1)
->orWhere('open_sell', 1);
})
->get(['id', 'active', 'show', 'open_buy', 'open_sell']);
$items = $products->map(static fn (Product $product) => [
'id' => $product->id,
'active' => (bool) $product->active,
'show' => (bool) $product->show,
'open_buy' => (bool) $product->open_buy,
'open_sell' => (bool) $product->open_sell,
])->values()->all();
Cnt::saveJsonPayload(
self::ACTIVATION_SNAPSHOT_KEY,
[
'saved_at' => now()->timezone('Asia/Tehran')->format('Y-m-d H:i:s'),
'items' => $items,
],
'ذخیره وضعیت محصولات'
);
$affected = Product::query()->update([
'active' => 0,
'show' => 0,
'open_buy' => 0,
'open_sell' => 0,
]);
$this->broadcastPricesAfterResponse();
return response()->json([
'message' => 'همه محصولات بسته شد.',
'snapshot_count' => count($items),
'affected' => $affected,
'has_snapshot' => ! empty($items),
]);
}
public function restoreActivationSnapshot()
{
$row = Cnt::where('key', self::ACTIVATION_SNAPSHOT_KEY)->first();
$items = $row?->jsonPayload()['items'] ?? [];
if (empty($items)) {
return response()->json(['error' => 'اسنAPSHOTی برای بازگردانی وجود ندارد.'], 422);
}
$restored = 0;
foreach ($items as $item) {
$updated = Product::where('id', $item['id'] ?? 0)->update([
'active' => ! empty($item['active']) ? 1 : 0,
'show' => ! empty($item['show']) ? 1 : 0,
'open_buy' => ! empty($item['open_buy']) ? 1 : 0,
'open_sell' => ! empty($item['open_sell']) ? 1 : 0,
]);
if ($updated) {
$restored++;
}
}
$this->auto_close_open();
$this->broadcastPricesAfterResponse();
return response()->json([
'message' => 'وضعیت قبلی محصولات بازگردانی شد.',
'restored' => $restored,
'has_snapshot' => true,
]);
}
public function closeAllBuy()
{
Product::query()->update(['open_buy' => 0]);
$inactive = Product::where('open_buy', 0)->where('open_sell', 0)->update([
'active' => 0,
'show' => 0,
]);
$this->broadcastPricesAfterResponse();
return response()->json([
'message' => 'خرید همه محصولات بسته شد.',
'inactive' => $inactive,
]);
}
public function closeAllSell()
{
Product::query()->update(['open_sell' => 0]);
$inactive = Product::where('open_buy', 0)->where('open_sell', 0)->update([
'active' => 0,
'show' => 0,
]);
$this->broadcastPricesAfterResponse();
return response()->json([
'message' => 'فروش همه محصولات بسته شد.',
'inactive' => $inactive,
]);
}
public function syncProductShow()
{
$this->auto_close_open();
$this->broadcastPricesAfterResponse();
return response()->json(['message' => 'نمایش محصولات طبق ساعات به‌روز شد.']);
}
private function broadcastPricesAfterResponse(bool $force = false): void
{
dispatch(function () use ($force) {
app(PriceBroadcastService::class)->broadcastAfterPriceUpdate(null, $force);
})->afterResponse();
}
public function updatePrice(Request $request){
}
private function isCustomerApiRequest(): bool
{
$path = trim((string) request()->path(), '/');
return str_starts_with($path, 'customer/')
|| str_contains($path, '/customer/');
}
/**
* لیست محصولات فروشگاه برای اپ/SSE — فقط DB، بدون sync خارجی.
*
* @return array<int, mixed>
*/
public function customerStoreProductListPayload(): array
{
$query = Product::with(['file']);
if (Product::hasShowInListColumn()) {
$query->where('show_in_list', 1);
} else {
$query->where('active', 1);
}
$products = $query->get();
$products = $this->filterProductsForCustomerList($products);
$categoryService = app(CategoryService::class);
$products = $categoryService->filterProductsByVisibleCategories($products);
$products = $categoryService->sortProductsForStore($products);
$this->applyCustomerShow($products);
return $products->values()->all();
}
/**
* @param \Illuminate\Support\Collection<int, Product>|iterable<Product> $products
* @return \Illuminate\Support\Collection<int, Product>
*/
/**
* اگر محصول فرمول TV فعال دارد، قیمت را با فرمول TV جایگزین می‌کند.
* این فقط روی خروجی TV اعمال می‌شود و قیمت عادی محصول را تغییر نمی‌دهد.
*/
private function applyTvFormulaOverrides($products): void
{
if (! Product::hasTvPriceFormulaColumns()) {
return;
}
$zaarbox = app(ZaarboxPriceService::class);
$feed = $zaarbox->cachedPriceList(false);
foreach ($products as $product) {
if (! ($product instanceof Product) || ! FormulaPriceService::isTvEnabled($product)) {
continue;
}
$mazanehRow = null;
if ($product->reading_method && is_array($feed)) {
$row = $zaarbox->findFeedRow($feed, (string) $product->reading_method);
if ($row) {
$parts = $zaarbox->resolveReferenceBaseProfit($row);
$mazanehRow = ['price' => $parts['price'], 'active' => true];
}
}
if ($mazanehRow === null) {
$livePrice = app(ProductDisplayPriceService::class)->resolveAppBasePrice();
if ($livePrice > 0) {
$mazanehRow = ['price' => $livePrice, 'active' => true];
}
}
$computed = FormulaPriceService::calculateTvForProduct($product, $mazanehRow);
if ($computed !== null && $computed > 0) {
$product->setAttribute('price', $computed);
$product->setAttribute('price_profit', 0);
}
}
}
private function filterProductsForCustomerList($products)
{
$collection = $products instanceof \Illuminate\Support\Collection
? $products
: collect($products);
if (! Product::hasShowInListColumn()) {
return $collection;
}
return $collection->filter(function (Product $product) {
return $this->isProductShownInList($product);
})->values();
}
private function isProductShownInList(Product $product): bool
{
if (Product::hasShowInListColumn()) {
return (bool) $product->show_in_list;
}
return (bool) $product->active;
}
private function applyCustomerShow($products): void
{
$products = $products instanceof \Illuminate\Support\Collection
? $products
: collect($products);
if ($products->isEmpty()) {
return;
}
// فروشگاه و خانگی: همان enrich قیمت لحظه‌ای (آبشده/قیمت اپ/مرجع) — بدون UPDATE دیتابیس
$displayPriceService = app(ProductDisplayPriceService::class);
$displayPriceService->warmLivePriceCache();
$displayPriceService->enrichForApi($products);
$products->each(function (Product $product) use ($displayPriceService) {
$product->setAttribute('show', $displayPriceService->resolveCustomerShow($product));
});
$customerId = (int) (auth('customer')->id() ?? 0);
if ($customerId > 0) {
app(\App\Services\CustomerSpecialPricingService::class)->applyToProducts($products, $customerId);
}
}
/**
* جدول ادمین — قیمت لحظه‌ای روی مدل (بدون UPDATE دیتابیس)، مثل فرم ویرایش و اپ مشتری.
*
* @param iterable<Product>|null $rows
*/
private function enrichAdminTablePrices($rows): void
{
if ($rows === null) {
return;
}
$products = collect($rows)->filter(fn ($row) => $row instanceof Product);
if ($products->isEmpty()) {
return;
}
$displayPriceService = app(ProductDisplayPriceService::class);
$displayPriceService->warmLivePriceCache();
$displayPriceService->enrichForApi($products);
}
private function filterProductData(array $data): array
{
if (!Product::hasPriceOffsetColumn()) {
unset($data['price_offset_percent']);
}
if (!Product::hasOrderLimitColumns()) {
$requestedLimit = filter_var($data['order_limit_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN)
|| $this->normalizeOrderLimitCash($data['min_order_cash'] ?? null) !== null
|| $this->normalizeOrderLimitCash($data['max_order_cash'] ?? null) !== null;
if ($requestedLimit) {
throw new UnprocessableEntityHttpException(
'ستون‌های محدودیت مبلغ سفارش در دیتابیس وجود ندارد. مایگریشن را اجرا کنید: php artisan migrate — یا اسکریپت database/sql/add_product_order_limit_columns.sql'
);
}
unset($data['order_limit_enabled'], $data['min_order_cash'], $data['max_order_cash']);
} else {
$min = $this->normalizeOrderLimitCash($data['min_order_cash'] ?? null);
$max = $this->normalizeOrderLimitCash($data['max_order_cash'] ?? null);
$orderLimitEnabled = filter_var($data['order_limit_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN)
|| $min !== null
|| $max !== null;
if (!$orderLimitEnabled) {
$data['order_limit_enabled'] = false;
$data['min_order_cash'] = null;
$data['max_order_cash'] = null;
} else {
if ($min === null && $max === null) {
throw new UnprocessableEntityHttpException(
'با فعال بودن محدودیت مبلغ سفارش، حداقل یکی از حداقل یا حداکثر مبلغ باید وارد شود'
);
}
if ($min !== null && $max !== null && $min > $max) {
throw new UnprocessableEntityHttpException('حداقل مبلغ نمی‌تواند بیشتر از حداکثر باشد');
}
$data['order_limit_enabled'] = true;
$data['min_order_cash'] = $min;
$data['max_order_cash'] = $max;
}
}
if (!Product::hasShowInAssetsColumn()) {
unset($data['show_in_assets']);
} elseif (array_key_exists('show_in_assets', $data)) {
$data['show_in_assets'] = !empty($data['show_in_assets']);
}
if (!Product::hasShowInListColumn()) {
unset($data['show_in_list']);
} elseif (array_key_exists('show_in_list', $data)) {
$data['show_in_list'] = !empty($data['show_in_list']);
// لیست و وضعیت باز/بسته یکی‌اند؛ TV جدا می‌ماند
$data['active'] = $data['show_in_list'];
$data['show'] = $data['show_in_list'];
}
if (!Product::hasShowOnTvColumn()) {
unset($data['show_on_tv']);
} elseif (array_key_exists('show_on_tv', $data)) {
$data['show_on_tv'] = !empty($data['show_on_tv']);
}
if (!Product::hasUseLastPriceIfZeroColumn()) {
unset($data['use_last_price_if_zero']);
} elseif (array_key_exists('use_last_price_if_zero', $data)) {
$data['use_last_price_if_zero'] = !empty($data['use_last_price_if_zero']);
}
if (!Product::hasTradeByQuantityColumn()) {
unset($data['trade_by_quantity']);
} elseif (array_key_exists('trade_by_quantity', $data)) {
$data['trade_by_quantity'] = !empty($data['trade_by_quantity']);
}
if (!Product::hasDirectPriceColumn()) {
unset($data['direct_price']);
}
if (!Product::hasAppPriceColumn()) {
unset($data['app_price']);
} elseif (array_key_exists('app_price', $data)) {
$data['app_price'] = filter_var($data['app_price'], FILTER_VALIDATE_BOOLEAN);
// قیمت اپ: مرجع خارجی ندارد، متغیر است (هر گرم × وزن) و قیمت دستی نگه داشته نمی‌شود
if ($data['app_price']) {
$data['reading_method'] = null;
if (Product::hasDirectPriceColumn()) {
$data['direct_price'] = 0;
}
$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;
}
}
if (!Product::hasTvPriceFormulaColumns()) {
unset($data['tv_price_formula_enabled'], $data['tv_price_formula']);
} else {
$tvFormulaEnabled = filter_var($data['tv_price_formula_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$tvFormula = trim((string) ($data['tv_price_formula'] ?? ''));
$showOnTv = !empty($data['show_on_tv'] ?? false);
$data['tv_price_formula_enabled'] = $tvFormulaEnabled && $showOnTv;
$data['tv_price_formula'] = $tvFormulaEnabled && $tvFormula !== ''
? $tvFormula
: ($tvFormulaEnabled ? FormulaPriceService::DEFAULT_FORMULA : ($tvFormula !== '' ? $tvFormula : null));
}
if (!Product::hasInventoryColumn()) {
unset($data['inventory']);
}
if (!Product::hasDescriptionColumn()) {
unset($data['description']);
} elseif (array_key_exists('description', $data)) {
$desc = is_string($data['description']) ? trim($data['description']) : '';
$data['description'] = $desc === '' ? null : $desc;
}
if (!Product::hasKaratColumn()) {
unset($data['karat']);
} elseif (array_key_exists('karat', $data)) {
$karat = is_string($data['karat']) || is_numeric($data['karat'])
? trim((string) $data['karat'])
: '';
$data['karat'] = $karat === '' ? null : mb_substr($karat, 0, 32);
}
if (!Product::hasOverrideTradingHoursColumn()) {
unset($data['override_trading_hours']);
} elseif (array_key_exists('override_trading_hours', $data)) {
$data['override_trading_hours'] = !empty($data['override_trading_hours']);
}
if (!Product::hasPhysicalDeliveryColumns()) {
unset(
$data['has_physical_delivery'],
$data['min_physical_delivery_quantity'],
$data['min_physical_delivery_grams']
);
} else {
// سازگاری با نسخهٔ قبلی که فیلد را min_physical_delivery_grams می‌فرستاد
if (!array_key_exists('min_physical_delivery_quantity', $data)
&& array_key_exists('min_physical_delivery_grams', $data)) {
$data['min_physical_delivery_quantity'] = $data['min_physical_delivery_grams'];
}
unset($data['min_physical_delivery_grams']);
$hasDelivery = array_key_exists('has_physical_delivery', $data)
? filter_var($data['has_physical_delivery'], FILTER_VALIDATE_BOOLEAN)
: false;
$data['has_physical_delivery'] = $hasDelivery;
if (!$hasDelivery) {
$data['min_physical_delivery_quantity'] = null;
} else {
$rawMin = $data['min_physical_delivery_quantity'] ?? null;
$min = $this->normalizeCashFeeValue($rawMin);
if ($min <= 0) {
throw new UnprocessableEntityHttpException(
'برای فعال کردن تحویل فیزیکی باید حداقل مقدار معتبر وارد شود'
);
}
$data['min_physical_delivery_quantity'] = $min;
}
}
$data['cash_fee_buy'] = $this->normalizeCashFeeValue($data['cash_fee_buy'] ?? 0);
$data['cash_fee_sell'] = $this->normalizeCashFeeValue($data['cash_fee_sell'] ?? 0);
if (Product::hasCashFeePercentModeColumns()) {
$data['cash_fee_buy_is_percent'] = filter_var(
$data['cash_fee_buy_is_percent'] ?? false,
FILTER_VALIDATE_BOOLEAN
);
$data['cash_fee_sell_is_percent'] = filter_var(
$data['cash_fee_sell_is_percent'] ?? false,
FILTER_VALIDATE_BOOLEAN
);
} else {
unset($data['cash_fee_buy_is_percent'], $data['cash_fee_sell_is_percent']);
}
return $data;
}
private function normalizeCashFeeValue(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
if (is_string($value)) {
$value = trim(str_replace([' ', '٬'], '', $value));
if (str_contains($value, ',') && ! str_contains($value, '.')) {
$value = str_replace(',', '.', $value);
} else {
$value = str_replace(',', '', $value);
}
}
return (float) $value;
}
private function normalizeOrderLimitCash(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (is_int($value)) {
return $value >= 0 ? $value : null;
}
if (is_float($value)) {
return $value >= 0 ? (int) $value : null;
}
if (! is_string($value)) {
return null;
}
$value = $this->normalizeAsciiDigits(trim(str_replace([' ', ',', '٬'], '', $value)));
if ($value === '' || ! ctype_digit($value)) {
return null;
}
return (int) $value;
}
private function normalizeAsciiDigits(string $value): string
{
$persian = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
$arabic = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
$latin = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
return str_replace(array_merge($persian, $arabic), array_merge($latin, $latin), $value);
}
}