From 3801e5ee88643bcd69eb79f6d12c823cd824e4ed Mon Sep 17 00:00:00 2001 From: adonis Date: Sun, 28 Jun 2026 15:16:39 +0330 Subject: [PATCH] added some feature for accounting and price --- .../Controllers/Admin/ProductController.php | 31 ++++ .../Controllers/Finance/FinanceController.php | 166 ++++++++++++++---- .../Single/SingleOrderController.php | 11 +- .../Controllers/Site/ReportController.php | 15 +- app/Models/Product.php | 24 +++ app/Services/AccountingLedgerService.php | 59 +++++++ app/Services/ConfigService.php | 33 +++- app/Services/LivePriceCacheService.php | 4 +- ...erride_trading_hours_to_products_table.php | 38 ++++ ...ckfill_trades_into_transactions_admins.php | 80 +++++++++ routes/admin.php | 1 + 11 files changed, 423 insertions(+), 39 deletions(-) create mode 100644 app/Services/AccountingLedgerService.php create mode 100644 database/migrations/2026_06_28_000001_add_override_trading_hours_to_products_table.php create mode 100644 database/migrations/2026_06_28_000002_backfill_trades_into_transactions_admins.php diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 5526f4c..f299dfa 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -52,6 +52,8 @@ class ProductController extends SuperController ->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' @@ -83,6 +85,8 @@ class ProductController extends SuperController ->select($this->select) ->export($query_table); + $this->enrichAdminTablePrices($query_table['rows'] ?? null); + return $query_table; } @@ -557,6 +561,27 @@ class ProductController extends SuperController }); } + /** + * جدول ادمین — قیمت لحظه‌ای روی مدل (بدون UPDATE دیتابیس)، مثل فرم ویرایش و اپ مشتری. + * + * @param iterable|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()) { @@ -616,6 +641,12 @@ class ProductController extends SuperController 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']); + } + return $data; } } diff --git a/app/Http/Controllers/Finance/FinanceController.php b/app/Http/Controllers/Finance/FinanceController.php index 43f364e..2b45631 100644 --- a/app/Http/Controllers/Finance/FinanceController.php +++ b/app/Http/Controllers/Finance/FinanceController.php @@ -3,15 +3,18 @@ namespace App\Http\Controllers\Finance; use App\Http\Controllers\Controller; +use App\Http\Controllers\Admin\TahesabController; use App\Models\Customer; use App\Models\Order; use App\Models\Price; +use App\Models\Product; use App\Models\TransactionsAdmin; use App\Models\User; use App\Models\Withdraw; use App\Models\Tahesab; use App\Services\TahesabSyncService; use App\DB\Te\TableEngine; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -31,41 +34,142 @@ class FinanceController extends Controller ->export($transactions_admins); if (isset($transactions_admins['rows']) && is_array($transactions_admins['rows']) && $this->Config('send_to_tahesab') === 'true') { - $withdrawIds = collect($transactions_admins['rows']) - ->pluck('withdraw_id') - ->filter() - ->unique() - ->values() - ->all(); - - if ($withdrawIds !== []) { - $tahesabByWithdraw = Tahesab::query() - ->whereIn('withdraw_id', $withdrawIds) - ->orderByDesc('id') - ->get() - ->unique('withdraw_id') - ->keyBy('withdraw_id'); - - foreach ($transactions_admins['rows'] as &$row) { - $tahesab = $tahesabByWithdraw->get($row['withdraw_id'] ?? null); - if (! $tahesab) { - continue; - } - - $row['tahesab_record_id'] = $tahesab->id; - $row['tahesab_sync_status'] = $tahesab->sync_status - ?? (empty($tahesab->tahesab_id) ? TahesabSyncService::STATUS_PENDING : TahesabSyncService::STATUS_SUCCESS); - - if (empty($tahesab->tahesab_id) || $row['tahesab_sync_status'] === TahesabSyncService::STATUS_FAILED) { - $row['tahesab_failed_id'] = $tahesab->id; - } - } - unset($row); - } + $this->enrichRowsWithTahesab($transactions_admins['rows']); } return response()->json($transactions_admins); } + + /** + * هر ردیف دفترکل را با وضعیت سند ته‌حساب مرتبط غنی می‌کند. + * لینک: ابتدا از طریق withdraw_id (کیف‌پول/پاداش) و سپس order_id (معامله). + * + * @param array> $rows با ارجاع تغییر می‌کند + */ + private function enrichRowsWithTahesab(array &$rows): void + { + $withdrawIds = collect($rows)->pluck('withdraw_id')->filter()->unique()->values()->all(); + $orderIds = collect($rows)->pluck('order_id')->filter()->unique()->values()->all(); + + if ($withdrawIds === [] && $orderIds === []) { + return; + } + + $tahesabByWithdraw = $withdrawIds === [] + ? collect() + : Tahesab::query()->whereIn('withdraw_id', $withdrawIds) + ->orderByDesc('id')->get()->unique('withdraw_id')->keyBy('withdraw_id'); + + $tahesabByOrder = $orderIds === [] + ? collect() + : Tahesab::query()->whereIn('order_id', $orderIds) + ->orderByDesc('id')->get()->unique('order_id')->keyBy('order_id'); + + foreach ($rows as &$row) { + $tahesab = $tahesabByWithdraw->get($row['withdraw_id'] ?? null) + ?? $tahesabByOrder->get($row['order_id'] ?? null); + + if (! $tahesab) { + // ردیف دفترکل بدون سند ته‌حساب — یعنی هنوز به حسابداری ارسال نشده + $row['tahesab_record_id'] = null; + $row['tahesab_sync_status'] = TahesabSyncService::STATUS_PENDING; + $row['tahesab_failed_id'] = null; + continue; + } + + $row['tahesab_record_id'] = $tahesab->id; + $row['tahesab_sync_status'] = $tahesab->sync_status + ?? (empty($tahesab->tahesab_id) ? TahesabSyncService::STATUS_PENDING : TahesabSyncService::STATUS_SUCCESS); + + if (empty($tahesab->tahesab_id) || $row['tahesab_sync_status'] === TahesabSyncService::STATUS_FAILED) { + $row['tahesab_failed_id'] = $tahesab->id; + } + } + unset($row); + } + + /** + * سینک یک ردیف دفترکل با حسابداری (فعلاً ته‌حساب). + * اگر سند ته‌حساب مرتبط موجود باشد → retry؛ وگرنه از روی منبع (معامله) ساخته و ارسال می‌شود. + */ + public function sync(int $id) + { + if ($this->Config('send_to_tahesab') !== 'true') { + throw new UnprocessableEntityHttpException('حسابداری ته‌حساب فعال نیست'); + } + + $row = TransactionsAdmin::find($id); + if (! $row) { + throw new UnprocessableEntityHttpException('سند دفترکل یافت نشد'); + } + + $tahesab = $this->findLinkedTahesab($row); + + if ($tahesab) { + $result = app(TahesabSyncService::class)->retry($tahesab); + $status = ($result['ok'] ?? false) ? 200 : 422; + + return response()->json($result, $status); + } + + // سند ته‌حساب وجود ندارد — اگر معامله است، از روی سفارش بساز و ارسال کن + if ($row->order_id) { + return $this->syncTradeFromOrder((int) $row->order_id); + } + + throw new UnprocessableEntityHttpException( + 'برای این ردیف سند ته‌حسابی یافت نشد و امکان ساخت خودکار آن وجود ندارد' + ); + } + + private function findLinkedTahesab(TransactionsAdmin $row): ?Tahesab + { + if ($row->withdraw_id) { + $byWithdraw = Tahesab::where('withdraw_id', $row->withdraw_id)->orderByDesc('id')->first(); + if ($byWithdraw) { + return $byWithdraw; + } + } + + if ($row->order_id) { + return Tahesab::where('order_id', $row->order_id)->orderByDesc('id')->first(); + } + + return null; + } + + private function syncTradeFromOrder(int $orderId) + { + $order = Order::find($orderId); + if (! $order) { + throw new UnprocessableEntityHttpException('سفارش مرتبط یافت نشد'); + } + + $product = Product::find($order->product_id); + if (! $product) { + throw new UnprocessableEntityHttpException('محصول سفارش یافت نشد — امکان ساخت سند ته‌حساب نیست'); + } + + $data = [ + 'customer_id' => $order->customer_id, + 'direction' => (int) $order->direction, + 'price' => $order->price, + 'total_price' => $order->total_price, + 'amount' => $order->amount, + 'pay_at' => $order->pay_at ?? $order->created_at, + 'product' => $product, + 'order' => ['id' => $order->id], + ]; + + $result = app(TahesabController::class)->storeTrade($data); + $ok = $result !== false && ! is_string($result); + + return response()->json([ + 'ok' => $ok, + 'message' => $ok ? 'سند معامله در ته‌حساب ثبت شد' : (is_string($result) ? $result : 'ارسال به ته‌حساب ناموفق بود'), + ], $ok ? 200 : 422); + } + public function index() { $tallaPrice = Price::latest()->first(); diff --git a/app/Http/Controllers/Single/SingleOrderController.php b/app/Http/Controllers/Single/SingleOrderController.php index be3edfa..76e7320 100644 --- a/app/Http/Controllers/Single/SingleOrderController.php +++ b/app/Http/Controllers/Single/SingleOrderController.php @@ -44,7 +44,7 @@ class SingleOrderController extends Controller } $config = app(ConfigService::class); - $config->assertTradingOpen(); + // فیلتر ساعت معامله per-product (سراسری یا override محصول) پایین‌تر در assertProductTradingOpen اعمال می‌شود. // throw new UnprocessableEntityHttpException('به دلیل نوسانات سفارشات بسته میباشد.'); $transactionType = $data['direction'] == 1 ? "خرید" : "فروش"; @@ -192,6 +192,15 @@ class SingleOrderController extends Controller $response['sell'] = $this->processSellTransaction($data, $wallet, $wallet_interface); } } + // دفترکل داخلی همیشه ثبت شود — مستقل از اتصال به حسابداری بیرونی + if (isset($data['order'])) { + try { + app(\App\Services\AccountingLedgerService::class)->recordTrade($data['order']); + } catch (\Throwable $e) { + report($e); + } + } + if ($config->getString('send_to_tahesab', 'false') === 'true') { try { $data['tahesab'] = app(TahesabController::class)->storeTrade($data); diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index 41befa8..967af84 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -126,6 +126,17 @@ class ReportController extends Controller protected function appendUpPrices(array $payload, bool $saveFullCache = false): array { + // status: open | closed (خارج از ساعت کاری) | no_quote (مظنه در دسترس نیست) + if (!isset($payload['status'])) { + if (!empty($payload['closed'])) { + $payload['status'] = 'closed'; + } elseif ((int) ($payload['price'] ?? 0) <= 0 && (int) ($payload['buy'] ?? 0) <= 0) { + $payload['status'] = 'no_quote'; + } else { + $payload['status'] = 'open'; + } + } + $payload['up'] = $this->buildUpDownPrices(); $payload['last_historical'] = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice(); app(ProductDisplayPriceService::class)->cacheLivePricePayload($payload); @@ -387,6 +398,7 @@ class ReportController extends Controller $priceValue = $storedPrice; $priceSource = 'database_last'; } else { + // مظنه زنده نداریم و show_last_price خاموش است → نه «بسته»، بلکه «مظنه در دسترس نیست» return $this->appendUpPrices([ 'sell' => 0, 'buy' => 0, @@ -395,7 +407,8 @@ class ReportController extends Controller 'main_product' => $pr_price ?? 0, 'montzar' => $montzar_price ?? 0, 'zaarbox' => $zaarbox_price ?? 0, - 'closed' => true, + 'closed' => false, + 'status' => 'no_quote', 'time' => now(), ], $forceRefresh); } diff --git a/app/Models/Product.php b/app/Models/Product.php index 56ce3a9..801af97 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -23,6 +23,7 @@ class Product extends Model private static ?bool $directPriceColumnExists = null; private static ?bool $tradeByQuantityColumnExists = null; private static ?bool $inventoryColumnExists = null; + private static ?bool $overrideTradingHoursColumnExists = null; protected static function booted(): void { @@ -52,9 +53,30 @@ class Product extends Model if (!static::hasInventoryColumn()) { unset($product->attributes['inventory']); } + if (!static::hasOverrideTradingHoursColumn()) { + unset($product->attributes['override_trading_hours']); + } }); } + public static function hasOverrideTradingHoursColumn(): bool + { + if (static::$overrideTradingHoursColumnExists !== null) { + return static::$overrideTradingHoursColumnExists; + } + + try { + static::$overrideTradingHoursColumnExists = Schema::hasColumn( + (new static)->getTable(), + 'override_trading_hours' + ); + } catch (\Throwable) { + static::$overrideTradingHoursColumnExists = false; + } + + return static::$overrideTradingHoursColumnExists; + } + public static function hasPriceOffsetColumn(): bool { if (static::$priceOffsetColumnExists !== null) { @@ -253,6 +275,7 @@ class Product extends Model 'open_sell', // Whether selling is open (boolean). 'auto_open_at', // Whether selling is open (boolean). 'auto_close_at', // Whether selling is open (boolean). + 'override_trading_hours', // روشن: بازه ساعت خود محصول جایگزین بازه سراسری می‌شود 'kalhor_name', // The name of the Kalhor entity. 'default_credit', // Default credit amount. 'reading_method', // The method of reading. @@ -286,6 +309,7 @@ class Product extends Model 'show_in_assets' => 'boolean', 'trade_by_quantity' => 'boolean', 'use_last_price_if_zero' => 'boolean', + 'override_trading_hours' => 'boolean', 'for_barayand' => 'boolean', // Cast to boolean. 'open_sell' => 'boolean', // Cast to boolean. 'auto_sell' => 'boolean', // Cast to boolean (you mentioned this in your casts but it's missing in `$fillable`). diff --git a/app/Services/AccountingLedgerService.php b/app/Services/AccountingLedgerService.php new file mode 100644 index 0000000..95b0288 --- /dev/null +++ b/app/Services/AccountingLedgerService.php @@ -0,0 +1,59 @@ +id) { + return null; + } + + $existing = TransactionsAdmin::where('order_id', $order->id)->first(); + if ($existing) { + return $existing; + } + + try { + return TransactionsAdmin::create([ + 'customer_id' => $order->customer_id, + 'customer_to_id' => $order->customer_id, + 'order_id' => $order->id, + 'user_id' => $order->customer_id, + 'wallet' => null, + 'transaction_id' => null, + 'cash' => (int) round((float) ($order->total_price ?? 0)), + 'note' => $this->tradeNote((int) ($order->direction ?? 0)), + 'type' => 1, + 'gold' => (float) ($order->amount ?? 0), + 'balance_cash' => 0, + 'balance_gold' => 0, + ]); + } catch (Throwable $e) { + report($e); + + return null; + } + } + + /** توضیح سند معامله — همسان با desc ته‌حساب تا گزارشات و حسابداری یکی دیده شوند. */ + public function tradeNote(int $direction): string + { + return $direction === 1 ? 'خرید آبشده' : ($direction === 2 ? 'فروش آبشده' : 'معامله'); + } +} diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index 0e2614d..c1a3a05 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -580,21 +580,46 @@ class ConfigService return $current >= $openStr && $current <= $closeStr; } - /** ساعت کاری سراسری (close_price) + بازه auto_open_at/auto_close_at هر محصول */ + /** + * آیا محصول بازه زمانی اختصاصی خودش را override کرده؟ + * فقط وقتی سوییچ override_trading_hours روشن باشد و بازه معتبری (شروع != پایان) داشته باشد. + */ + public function productOverridesTradingHours(Product $product): bool + { + if (!Product::hasOverrideTradingHoursColumn()) { + return false; + } + + if (!($product->override_trading_hours ?? false)) { + return false; + } + + return $this->hasProductTradingWindow($product->auto_open_at, $product->auto_close_at); + } + + /** + * ساعت معامله یک محصول: + * - اگر سوییچ override محصول روشن باشد → فقط بازه خود محصول حاکم است (سراسری نادیده گرفته می‌شود). + * - در غیر این صورت → فیلتر سراسری close_price + startDay/endDay اعمال می‌شود. + */ public function isProductWithinTradingHours(Product $product, ?Carbon $now = null): bool { + if ($this->productOverridesTradingHours($product)) { + return $this->isWithinTimeWindow($product->auto_open_at, $product->auto_close_at, $now); + } + if ($this->isClosePriceEnabled() && $this->isOutsideTradingHours($now)) { return false; } - return $this->isWithinTimeWindow($product->auto_open_at, $product->auto_close_at, $now); + return true; } public function productTradingClosedMessage(Product $product): string { $title = trim((string) ($product->title ?? 'محصول')); - if ($this->hasProductTradingWindow($product->auto_open_at, $product->auto_close_at)) { + if ($this->productOverridesTradingHours($product)) { $open = $this->formatProductTime($product->auto_open_at); $close = $this->formatProductTime($product->auto_close_at); @@ -606,8 +631,6 @@ class ConfigService public function assertProductTradingOpen(Product $product, ?Carbon $now = null): void { - $this->assertTradingOpen($now); - if (!$this->isProductWithinTradingHours($product, $now)) { throw new UnprocessableEntityHttpException($this->productTradingClosedMessage($product)); } diff --git a/app/Services/LivePriceCacheService.php b/app/Services/LivePriceCacheService.php index 36cd2ef..8594785 100644 --- a/app/Services/LivePriceCacheService.php +++ b/app/Services/LivePriceCacheService.php @@ -90,7 +90,8 @@ class LivePriceCacheService 'main_product' => 0, 'montzar' => 0, 'zaarbox' => 0, - 'closed' => true, + 'closed' => false, + 'status' => 'no_quote', 'time' => now(), ]; } else { @@ -123,6 +124,7 @@ class LivePriceCacheService 'montzar' => $montzarSource, 'zaarbox' => $zaarboxSource, 'closed' => false, + 'status' => 'open', 'time' => now(), ]; } diff --git a/database/migrations/2026_06_28_000001_add_override_trading_hours_to_products_table.php b/database/migrations/2026_06_28_000001_add_override_trading_hours_to_products_table.php new file mode 100644 index 0000000..6442177 --- /dev/null +++ b/database/migrations/2026_06_28_000001_add_override_trading_hours_to_products_table.php @@ -0,0 +1,38 @@ +boolean('override_trading_hours')->default(false)->after('auto_close_at'); + } else { + $table->boolean('override_trading_hours')->default(false); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('products') || ! Schema::hasColumn('products', 'override_trading_hours')) { + return; + } + + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('override_trading_hours'); + }); + } +}; diff --git a/database/migrations/2026_06_28_000002_backfill_trades_into_transactions_admins.php b/database/migrations/2026_06_28_000002_backfill_trades_into_transactions_admins.php new file mode 100644 index 0000000..6795500 --- /dev/null +++ b/database/migrations/2026_06_28_000002_backfill_trades_into_transactions_admins.php @@ -0,0 +1,80 @@ +whereNotNull('order_id')->pluck('order_id')->all() + ); + + $now = now(); + + DB::table('orders') + ->when(Schema::hasColumn('orders', 'sts'), function ($q) { + // معاملات ناموفق (sts=3) را وارد دفترکل نکن + $q->where(function ($inner) { + $inner->whereNull('sts')->orWhere('sts', '!=', 3); + }); + }) + ->orderBy('id') + ->chunk(500, function ($orders) use (&$existing, $now) { + $rows = []; + foreach ($orders as $order) { + if (isset($existing[$order->id])) { + continue; + } + + $direction = (int) ($order->direction ?? 0); + $note = $direction === 1 ? 'خرید آبشده' : ($direction === 2 ? 'فروش آبشده' : 'معامله'); + + $rows[] = [ + 'customer_id' => $order->customer_id, + 'customer_to_id' => $order->customer_id, + 'order_id' => $order->id, + 'user_id' => $order->customer_id, + 'wallet' => null, + 'transaction_id' => null, + 'cash' => (int) round((float) ($order->total_price ?? 0)), + 'note' => $note, + 'type' => 1, + 'gold' => (float) ($order->amount ?? 0), + 'balance_cash' => 0, + 'balance_gold' => 0, + 'created_at' => $order->created_at ?? $now, + 'updated_at' => $now, + ]; + $existing[$order->id] = true; + } + + if ($rows !== []) { + DB::table('transactions_admins')->insert($rows); + } + }); + } + + public function down(): void + { + // برگشت‌پذیر نیست: تفکیک ردیف‌های backfill از ردیف‌های واقعی forward مطمئن نیست، + // پس برای جلوگیری از حذف دادهٔ درست، down هیچ کاری نمی‌کند. + } +}; diff --git a/routes/admin.php b/routes/admin.php index ebdce6d..0eb32a0 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -134,6 +134,7 @@ Route::middleware('auth:user')->group(function (){ Route::post('finance', [FinanceController::class, 'fall']); + Route::post('finance/{id}/sync', [FinanceController::class, 'sync']); //dashboard