Files
boursecu_server/app/Http/Controllers/Admin/ProductController.php
T
2026-07-13 13:31:44 +03:30

778 lines
26 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\ProductDisplayPriceService;
use App\Services\ProductPriceService;
use App\Services\ProductPriceSyncService;
use App\Services\PriceBroadcastService;
use App\Services\CategoryService;
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);
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;
}
if (isset($data["price"])) {
$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
) {
ProductPriceService::reapplyOffsetPercent(
$product,
(float) $data['price_offset_percent'],
$oldOffsetPercent
);
unset($data['price'], $data['price_profit']);
}
$product->update($data);
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 می‌خواند
$products = Product::with(['file'])->where('active', 1)->get();
if ($this->isCustomerApiRequest()) {
$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);
}
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);
}
/**
* فعال/غیرفعال محصول — show باید با active هم‌گام باشد (اپ کاربر از show استفاده می‌کند).
*/
public function active($id)
{
$product = Product::findOrFail($id);
$active = !$product->active;
$product->update([
'active' => $active,
'show' => $active,
]);
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(): void
{
dispatch(function () {
app(PriceBroadcastService::class)->broadcastAfterPriceUpdate();
})->afterResponse();
}
public function updatePrice(Request $request){
}
/** فروشگاه — نمایش در اپ کاربر: فعال + ساعات + قیمت ذخیره‌شده یا محصول وزنی */
private function storeProductShowForCustomer(Product $product): bool
{
if (! $product->active) {
return false;
}
if (! app(ConfigService::class)->isProductWithinTradingHours($product)) {
return false;
}
$storedPrice = (int) ($product->price ?? 0) + (int) ($product->price_profit ?? 0);
if ($storedPrice > 0) {
return true;
}
if ((int) ($product->direct_price ?? 0) === 0 && (float) ($product->amount ?? 0) > 0) {
return true;
}
return false;
}
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
{
$products = Product::with(['file'])->where('active', 1)->get();
$categoryService = app(CategoryService::class);
$products = $categoryService->filterProductsByVisibleCategories($products);
$products = $categoryService->sortProductsForStore($products);
$this->applyCustomerShow($products);
return $products->values()->all();
}
private function applyCustomerShow($products): void
{
$products = $products instanceof \Illuminate\Support\Collection
? $products
: collect($products);
if ($products->isEmpty()) {
return;
}
if (app(ConfigService::class)->isStore()) {
$products->each(function (Product $product) {
$product->setAttribute('show', $this->storeProductShowForCustomer($product));
});
return;
}
$displayPriceService = app(ProductDisplayPriceService::class);
$displayPriceService->warmLivePriceCache();
$displayPriceService->enrichForApi($products);
$products->each(function (Product $product) use ($displayPriceService) {
$product->setAttribute('show', $displayPriceService->resolveCustomerShow($product));
});
}
/**
* جدول ادمین — قیمت لحظه‌ای روی مدل (بدون UPDATE دیتابیس)، مثل فرم ویرایش و اپ مشتری.
*
* @param iterable<Product>|null $rows
*/
private function enrichAdminTablePrices($rows): void
{
if ($rows === null || app(ConfigService::class)->isStore()) {
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::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::hasInventoryColumn()) {
unset($data['inventory']);
}
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_grams']);
} else {
$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_grams'] = null;
} else {
$rawMin = $data['min_physical_delivery_grams'] ?? null;
$min = $this->normalizeCashFeeValue($rawMin);
if ($min <= 0) {
throw new UnprocessableEntityHttpException(
'برای فعال کردن تحویل فیزیکی باید حداقل گرم معتبر وارد شود'
);
}
$data['min_physical_delivery_grams'] = $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);
}
}