feat(pargar): cap inbound mande/document logs after insert

Keep the newest 2000 success rows per inbound type so dumps stop growing from Pargar webhooks, and store compact document payloads instead of full JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 10:19:56 +03:30
co-authored by Cursor
parent c802c67e0d
commit 7e6cd92df4
6 changed files with 323 additions and 5 deletions
@@ -279,11 +279,7 @@ class PargarInboundController extends Controller
'type' => PargarSyncService::TYPE_DOCUMENT, 'type' => PargarSyncService::TYPE_DOCUMENT,
'sync_status' => PargarSyncService::STATUS_SUCCESS, 'sync_status' => PargarSyncService::STATUS_SUCCESS,
'pargar_ref' => $accountId !== '' ? $accountId : null, 'pargar_ref' => $accountId !== '' ? $accountId : null,
'data' => json_encode(array_merge($data, [ 'data' => $this->sync()->compactInboundDocumentData($data, $summary),
'_summary' => $summary,
'wallet_applied' => false,
'code_build' => 'docs-then-mande-auto',
]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
]); ]);
\Illuminate\Support\Facades\Log::info('Pargar documents stored; queue auto mande sync', [ \Illuminate\Support\Facades\Log::info('Pargar documents stored; queue auto mande sync', [
+22
View File
@@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Services\DatabaseLogPruneService;
use App\Services\PargarSyncService;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -46,6 +48,26 @@ class PargarSyncLog extends Model
return self::$hasConfirmedAtColumn; 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 public function customer(): BelongsTo
{ {
return $this->belongsTo(Customer::class); return $this->belongsTo(Customer::class);
+102
View File
@@ -10,6 +10,7 @@ use App\Models\Tahesab;
use App\Models\Zarin; use App\Models\Zarin;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
/** /**
@@ -20,6 +21,9 @@ class DatabaseLogPruneService
/** هم‌راستا با حذف قطعی در PriceSnapshotService::pruneHistory */ /** هم‌راستا با حذف قطعی در PriceSnapshotService::pruneHistory */
public const RETENTION_DAYS = 31; public const RETENTION_DAYS = 31;
/** سقف ردیف موفق مانده/سند ورودی پرگار — بقیه (سفارش/کیف) را نمی‌بُرد */
public const PARAGAR_INBOUND_KEEP = 2000;
private const CHUNK = 500; private const CHUNK = 500;
public function pruneHistory(?Carbon $now = null): array public function pruneHistory(?Carbon $now = null): array
@@ -29,6 +33,7 @@ class DatabaseLogPruneService
return [ return [
'retention_days' => self::RETENTION_DAYS, 'retention_days' => self::RETENTION_DAYS,
'cutoff' => $cutoff->toDateTimeString(), 'cutoff' => $cutoff->toDateTimeString(),
'pargar_inbound' => $this->prunePargarInboundOverflow(),
'price_sync_logs' => $this->prunePriceSyncLogs($cutoff), 'price_sync_logs' => $this->prunePriceSyncLogs($cutoff),
'logs' => $this->pruneGenericLogs($cutoff), 'logs' => $this->pruneGenericLogs($cutoff),
'pargar_sync_logs' => $this->pruneSuccessfulRows( 'pargar_sync_logs' => $this->pruneSuccessfulRows(
@@ -137,4 +142,101 @@ class DatabaseLogPruneService
return ['deleted' => $deleted, 'skipped' => false]; 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) {
}
}
} }
+22
View File
@@ -906,6 +906,28 @@ class PargarSyncService
]; ];
} }
/**
* وب‌هوک سند پرگار را کامل در LONGTEXT نگذار — همان جدول دامپ را باد می‌کند.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $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 private function parsePargarNumber(mixed $value): float
{ {
if ($value === null || $value === '') { if ($value === null || $value === '') {
@@ -0,0 +1,89 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('pargar_sync_logs')) {
return;
}
$indexNames = collect(DB::select('SHOW INDEX FROM `pargar_sync_logs`'))
->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) {
}
}
};
@@ -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;