diff --git a/app/Http/Controllers/Pargar/PargarInboundController.php b/app/Http/Controllers/Pargar/PargarInboundController.php index 0b71035..1860722 100644 --- a/app/Http/Controllers/Pargar/PargarInboundController.php +++ b/app/Http/Controllers/Pargar/PargarInboundController.php @@ -279,11 +279,7 @@ class PargarInboundController extends Controller 'type' => PargarSyncService::TYPE_DOCUMENT, 'sync_status' => PargarSyncService::STATUS_SUCCESS, 'pargar_ref' => $accountId !== '' ? $accountId : null, - 'data' => json_encode(array_merge($data, [ - '_summary' => $summary, - 'wallet_applied' => false, - 'code_build' => 'docs-then-mande-auto', - ]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + 'data' => $this->sync()->compactInboundDocumentData($data, $summary), ]); \Illuminate\Support\Facades\Log::info('Pargar documents stored; queue auto mande sync', [ diff --git a/app/Models/PargarSyncLog.php b/app/Models/PargarSyncLog.php index 77b80b5..e710590 100644 --- a/app/Models/PargarSyncLog.php +++ b/app/Models/PargarSyncLog.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Services\DatabaseLogPruneService; +use App\Services\PargarSyncService; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Schema; @@ -46,6 +48,26 @@ class PargarSyncLog extends Model return self::$hasConfirmedAtColumn; } + protected static function booted(): void + { + static::created(function (self $log) { + if (! in_array($log->type, [PargarSyncService::TYPE_MANDE, PargarSyncService::TYPE_DOCUMENT], true)) { + return; + } + + dispatch(function () { + if (function_exists('fastcgi_finish_request')) { + @fastcgi_finish_request(); + } + try { + app(DatabaseLogPruneService::class)->prunePargarInboundOverflow(); + } catch (\Throwable) { + // وب‌هوک پرگار نباید به‌خاطر پاک‌سازی قطع شود + } + })->afterResponse(); + }); + } + public function customer(): BelongsTo { return $this->belongsTo(Customer::class); diff --git a/app/Services/DatabaseLogPruneService.php b/app/Services/DatabaseLogPruneService.php index c500d96..8e2ba0b 100644 --- a/app/Services/DatabaseLogPruneService.php +++ b/app/Services/DatabaseLogPruneService.php @@ -10,6 +10,7 @@ use App\Models\Tahesab; use App\Models\Zarin; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; /** @@ -20,6 +21,9 @@ class DatabaseLogPruneService /** هم‌راستا با حذف قطعی در PriceSnapshotService::pruneHistory */ public const RETENTION_DAYS = 31; + /** سقف ردیف موفق مانده/سند ورودی پرگار — بقیه (سفارش/کیف) را نمی‌بُرد */ + public const PARAGAR_INBOUND_KEEP = 2000; + private const CHUNK = 500; public function pruneHistory(?Carbon $now = null): array @@ -29,6 +33,7 @@ class DatabaseLogPruneService return [ 'retention_days' => self::RETENTION_DAYS, 'cutoff' => $cutoff->toDateTimeString(), + 'pargar_inbound' => $this->prunePargarInboundOverflow(), 'price_sync_logs' => $this->prunePriceSyncLogs($cutoff), 'logs' => $this->pruneGenericLogs($cutoff), 'pargar_sync_logs' => $this->pruneSuccessfulRows( @@ -137,4 +142,101 @@ class DatabaseLogPruneService return ['deleted' => $deleted, 'skipped' => false]; } + + /** + * فقط لاگ ورودی پرگار (mande/document). سفارش و سند کیف دست نخورده می‌ماند. + * + * @return array{mande: int, document: int} + */ + public function prunePargarInboundOverflow(int $maxDeletePerType = 0): array + { + if (! Schema::hasTable('pargar_sync_logs')) { + return ['mande' => 0, 'document' => 0]; + } + + ignore_user_abort(true); + if (function_exists('set_time_limit')) { + @set_time_limit(180); + } + + $this->ensureTypeStatusIndex(); + + $locked = $this->acquirePruneLock(); + if ($locked === false) { + return ['mande' => 0, 'document' => 0]; + } + + try { + return [ + 'mande' => $this->pruneOldestSuccessBeyond(PargarSyncService::TYPE_MANDE, $maxDeletePerType), + 'document' => $this->pruneOldestSuccessBeyond(PargarSyncService::TYPE_DOCUMENT, $maxDeletePerType), + ]; + } finally { + if ($locked === true) { + $this->releasePruneLock(); + } + } + } + + private function pruneOldestSuccessBeyond(string $type, int $maxDelete): int + { + $cutoffId = PargarSyncLog::query() + ->where('type', $type) + ->where('sync_status', PargarSyncService::STATUS_SUCCESS) + ->orderByDesc('id') + ->skip(self::PARAGAR_INBOUND_KEEP) + ->value('id'); + + if (! $cutoffId) { + return 0; + } + + $query = PargarSyncLog::query() + ->where('type', $type) + ->where('sync_status', PargarSyncService::STATUS_SUCCESS) + ->where('id', '<=', $cutoffId) + ->orderBy('id'); + + if ($maxDelete > 0) { + return (int) $query->limit($maxDelete)->delete(); + } + + return (int) $query->delete(); + } + + private function ensureTypeStatusIndex(): void + { + try { + $names = collect(DB::select('SHOW INDEX FROM `pargar_sync_logs`')) + ->pluck('Key_name') + ->all(); + if (in_array('pargar_sync_logs_type_status_index', $names, true)) { + return; + } + DB::statement( + 'ALTER TABLE `pargar_sync_logs` ADD INDEX `pargar_sync_logs_type_status_index` (`type`, `sync_status`)' + ); + } catch (\Throwable) { + } + } + + /** @return bool|null true=locked, false=busy, null=no lock support */ + private function acquirePruneLock(): ?bool + { + try { + $row = DB::selectOne('SELECT GET_LOCK(?, 0) AS l', ['khanegi_pargar_inbound_prune']); + + return (int) ($row->l ?? 0) === 1; + } catch (\Throwable) { + return null; + } + } + + private function releasePruneLock(): void + { + try { + DB::selectOne('SELECT RELEASE_LOCK(?) AS l', ['khanegi_pargar_inbound_prune']); + } catch (\Throwable) { + } + } } diff --git a/app/Services/PargarSyncService.php b/app/Services/PargarSyncService.php index 1ff4fb6..8c5f6ec 100644 --- a/app/Services/PargarSyncService.php +++ b/app/Services/PargarSyncService.php @@ -906,6 +906,28 @@ class PargarSyncService ]; } + /** + * وب‌هوک سند پرگار را کامل در LONGTEXT نگذار — همان جدول دامپ را باد می‌کند. + * + * @param array $data + * @param array $summary + */ + public function compactInboundDocumentData(array $data, array $summary): string + { + $transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : []; + + return (string) json_encode([ + 'accountName' => $data['accountName'] ?? null, + 'accountId' => $data['accountId'] ?? null, + 'transactions_count' => count($transactions), + 'transactions_fp' => hash('sha256', (string) json_encode($transactions, JSON_UNESCAPED_UNICODE)), + '_summary' => $summary, + 'wallet_applied' => false, + 'code_build' => 'docs-then-mande-auto', + 'payload_compacted' => true, + ], JSON_UNESCAPED_UNICODE); + } + private function parsePargarNumber(mixed $value): float { if ($value === null || $value === '') { diff --git a/database/migrations/2026_09_03_000001_pargar_inbound_log_prune_event.php b/database/migrations/2026_09_03_000001_pargar_inbound_log_prune_event.php new file mode 100644 index 0000000..e2f5466 --- /dev/null +++ b/database/migrations/2026_09_03_000001_pargar_inbound_log_prune_event.php @@ -0,0 +1,89 @@ +pluck('Key_name') + ->all(); + + if (! in_array('pargar_sync_logs_type_status_index', $indexNames, true)) { + Schema::table('pargar_sync_logs', function ($table) { + $table->index(['type', 'sync_status'], 'pargar_sync_logs_type_status_index'); + }); + } + + $this->dropEvents(); + + try { + DB::statement('SET GLOBAL event_scheduler = ON'); + } catch (\Throwable) { + } + + try { + DB::unprepared(<<<'SQL' +CREATE EVENT `khanegi_prune_pargar_inbound_docs` +ON SCHEDULE EVERY 1 HOUR +STARTS CURRENT_TIMESTAMP +ON COMPLETION PRESERVE +ENABLE +DO +DELETE FROM `pargar_sync_logs` +WHERE `type` = 'document' AND `sync_status` = 'success' + AND `id` <= ( + SELECT `cutoff` FROM ( + SELECT `id` AS `cutoff` FROM `pargar_sync_logs` + WHERE `type` = 'document' AND `sync_status` = 'success' + ORDER BY `id` DESC + LIMIT 1 OFFSET 2000 + ) AS `keep` + ) +LIMIT 4000 +SQL); + DB::unprepared(<<<'SQL' +CREATE EVENT `khanegi_prune_pargar_inbound_mande` +ON SCHEDULE EVERY 1 HOUR +STARTS CURRENT_TIMESTAMP +ON COMPLETION PRESERVE +ENABLE +DO +DELETE FROM `pargar_sync_logs` +WHERE `type` = 'mande' AND `sync_status` = 'success' + AND `id` <= ( + SELECT `cutoff` FROM ( + SELECT `id` AS `cutoff` FROM `pargar_sync_logs` + WHERE `type` = 'mande' AND `sync_status` = 'success' + ORDER BY `id` DESC + LIMIT 1 OFFSET 2000 + ) AS `keep` + ) +LIMIT 4000 +SQL); + } catch (\Throwable) { + // EVENT privilege / event_scheduler — پاک‌سازی روی INSERT لاگ ورودی می‌ماند + } + } + + public function down(): void + { + $this->dropEvents(); + } + + private function dropEvents(): void + { + try { + DB::unprepared('DROP EVENT IF EXISTS `khanegi_prune_pargar_inbound_docs`'); + DB::unprepared('DROP EVENT IF EXISTS `khanegi_prune_pargar_inbound_mande`'); + } catch (\Throwable) { + } + } +}; diff --git a/database/sql/prune_pargar_inbound_logs_now.sql b/database/sql/prune_pargar_inbound_logs_now.sql new file mode 100644 index 0000000..074a138 --- /dev/null +++ b/database/sql/prune_pargar_inbound_logs_now.sql @@ -0,0 +1,87 @@ +-- یک‌بار روی دیتابیس زنده (مثلاً kh_goldarvin) در Navicat اجرا شود. +-- سفارش / کیف / مشتری را پاک نمی‌کند؛ فقط لاگ ورودی مانده و سند. +-- بعد از اجرا دوباره دامپ بگیر؛ حجم باید از حدود ۲۳۰MB به حدود ۱۰–۲۰MB برسد. + +-- ایندکس برای حذف سریع +SET @db = DATABASE(); +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'pargar_sync_logs' + AND INDEX_NAME = 'pargar_sync_logs_type_status_index') = 0 + AND (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'pargar_sync_logs') > 0, + 'ALTER TABLE `pargar_sync_logs` ADD INDEX `pargar_sync_logs_type_status_index` (`type`, `sync_status`)', + 'SELECT ''skip pargar_sync_logs_type_status_index'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- نگه داشتن ۲۰۰۰ سند ورودی تازه +DELETE FROM `pargar_sync_logs` +WHERE `type` = 'document' AND `sync_status` = 'success' + AND `id` <= ( + SELECT `cutoff` FROM ( + SELECT `id` AS `cutoff` FROM `pargar_sync_logs` + WHERE `type` = 'document' AND `sync_status` = 'success' + ORDER BY `id` DESC + LIMIT 1 OFFSET 2000 + ) AS `keep` + ); + +-- نگه داشتن ۲۰۰۰ مانده تازه +DELETE FROM `pargar_sync_logs` +WHERE `type` = 'mande' AND `sync_status` = 'success' + AND `id` <= ( + SELECT `cutoff` FROM ( + SELECT `id` AS `cutoff` FROM `pargar_sync_logs` + WHERE `type` = 'mande' AND `sync_status` = 'success' + ORDER BY `id` DESC + LIMIT 1 OFFSET 2000 + ) AS `keep` + ); + +-- Event داخل خود MySQL — بدون کرون سیستم (Navicat → Events) +DROP EVENT IF EXISTS `khanegi_prune_pargar_inbound_docs`; +DROP EVENT IF EXISTS `khanegi_prune_pargar_inbound_mande`; + +CREATE EVENT `khanegi_prune_pargar_inbound_docs` +ON SCHEDULE EVERY 1 HOUR +STARTS CURRENT_TIMESTAMP +ON COMPLETION PRESERVE +ENABLE +DO +DELETE FROM `pargar_sync_logs` +WHERE `type` = 'document' AND `sync_status` = 'success' + AND `id` <= ( + SELECT `cutoff` FROM ( + SELECT `id` AS `cutoff` FROM `pargar_sync_logs` + WHERE `type` = 'document' AND `sync_status` = 'success' + ORDER BY `id` DESC + LIMIT 1 OFFSET 2000 + ) AS `keep` + ) +LIMIT 4000; + +CREATE EVENT `khanegi_prune_pargar_inbound_mande` +ON SCHEDULE EVERY 1 HOUR +STARTS CURRENT_TIMESTAMP +ON COMPLETION PRESERVE +ENABLE +DO +DELETE FROM `pargar_sync_logs` +WHERE `type` = 'mande' AND `sync_status` = 'success' + AND `id` <= ( + SELECT `cutoff` FROM ( + SELECT `id` AS `cutoff` FROM `pargar_sync_logs` + WHERE `type` = 'mande' AND `sync_status` = 'success' + ORDER BY `id` DESC + LIMIT 1 OFFSET 2000 + ) AS `keep` + ) +LIMIT 4000; + +-- اگر دسترسی SUPER داری: +-- SET GLOBAL event_scheduler = ON; +-- SHOW VARIABLES LIKE 'event_scheduler'; +-- SHOW EVENTS;