Files
boursecu_server/app/Services/PargarSyncService.php
T

611 lines
20 KiB
PHP
Raw Normal View History

2026-07-05 16:43:34 +03:30
<?php
namespace App\Services;
use App\Models\Customer;
use App\Models\Order;
use App\Models\PargarSyncLog;
2026-07-20 15:52:42 +03:30
use App\Models\Product;
2026-07-05 16:43:34 +03:30
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class PargarSyncService
{
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
2026-07-20 15:52:42 +03:30
public const TYPE_CUSTOMER = 'customer';
public const TYPE_ORDER = 'order';
public const TYPE_MANDE = 'mande';
public const TYPE_DOCUMENT = 'document';
public const TYPE_WALLET_CASH = 'wallet_cash';
public const TYPE_WALLET_GOLD = 'wallet_gold';
public const PAYMENT_CASH = 'cash';
public const PAYMENT_MAIN_GOLD = 'main_gold';
public const PAYMENT_OTHER_PRODUCT = 'other_product';
2026-07-05 16:43:34 +03:30
public function buildLog(mixed $response = null, ?string $extra = null): string
{
$parts = array_filter([
$extra,
is_array($response) ? json_encode($response, JSON_UNESCAPED_UNICODE) : (is_string($response) ? $response : null),
]);
return implode("\n", $parts);
}
public function markSuccess(PargarSyncLog $log, ?string $ref = null, mixed $response = null, ?array $payload = null): void
{
$update = [
'pargar_ref' => $ref,
'res' => is_array($response)
? json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
: (is_string($response) ? $response : null),
'sync_status' => self::STATUS_SUCCESS,
'sync_log' => null,
];
if ($payload !== null) {
$update['data'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
2026-07-25 12:02:58 +03:30
// لحظهٔ ثبت سند در پرگار: ماندهٔ قدیمی‌تر از این لحظه دیگر معتبر نیست
if (PargarSyncLog::hasConfirmedAtColumn()) {
$update['confirmed_at'] = now();
}
2026-07-05 16:43:34 +03:30
PargarSyncLog::where('id', $log->id)->update($update);
}
public function markFailed(PargarSyncLog $log, mixed $response = null, ?string $extra = null): void
{
PargarSyncLog::where('id', $log->id)->update([
'res' => is_array($response)
? json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
: (is_string($response) ? $response : null),
'sync_status' => self::STATUS_FAILED,
'sync_log' => $this->buildLog($response, $extra),
]);
}
public function isFailed(PargarSyncLog $log): bool
{
return ($log->sync_status ?? null) === self::STATUS_FAILED
|| (empty($log->pargar_ref) && ($log->sync_status ?? null) !== self::STATUS_SUCCESS);
}
public function registerOnCreate(Customer $customer): void
{
if ($customer->hasValidPargarAccountId() || ! $this->isSyncEnabled()) {
2026-07-05 16:43:34 +03:30
return;
}
$customerId = (int) $customer->id;
dispatch(function () use ($customerId) {
$service = app(self::class);
if (! $service->isSyncEnabled()) {
return;
}
$customer = Customer::find($customerId);
if ($customer === null || $customer->hasValidPargarAccountId()) {
2026-07-05 16:43:34 +03:30
return;
}
try {
$service->queueCustomerExport($customer);
} catch (\Throwable $e) {
Log::warning('Pargar auto-queue on customer create failed', [
'customer_id' => $customerId,
'error' => $e->getMessage(),
]);
}
})->afterResponse();
}
public function queueCustomerExport(Customer $customer): PargarSyncLog
{
$existing = PargarSyncLog::query()
->where('customer_id', $customer->id)
2026-07-20 15:52:42 +03:30
->where('type', self::TYPE_CUSTOMER)
2026-07-05 16:43:34 +03:30
->where('sync_status', self::STATUS_PENDING)
->latest('id')
->first();
if ($existing) {
return $existing;
}
return PargarSyncLog::create([
'customer_id' => $customer->id,
'desc' => 'در انتظار دریافت توسط پرگار',
2026-07-20 15:52:42 +03:30
'type' => self::TYPE_CUSTOMER,
2026-07-05 16:43:34 +03:30
'sync_status' => self::STATUS_PENDING,
'data' => json_encode(['handler' => 'exportCustomer'], JSON_UNESCAPED_UNICODE),
]);
}
public function registerCustomer(Customer $customer): array
{
$customer->refresh();
if ($customer->hasValidPargarAccountId()) {
2026-07-05 16:43:34 +03:30
return [
'ok' => true,
'message' => 'مشتری قبلاً در پرگار ثبت شده است',
'pargar_account_id' => $customer->pargar_account_id,
];
}
$this->queueCustomerExport($customer);
return [
'ok' => true,
'message' => 'مشتری در صف دریافت پرگار قرار گرفت',
'pargar_account_id' => null,
];
}
2026-07-20 15:52:42 +03:30
/**
* صف سند کیف برای سینک مانده پرگار با اپ.
*
* @param 'cash'|'gold' $kind
* @param array{withdraw_id?:int|null,note?:string|null,product_id?:int|null,source?:string|null} $meta
*/
public function queueWalletDocument(
Customer $customer,
string $kind,
float $signedAmount,
array $meta = []
): ?PargarSyncLog {
if (! $this->isSyncEnabled() || ! $this->hasSyncTable()) {
return null;
}
if (abs($signedAmount) < ($kind === 'cash' ? 1 : 0.01)) {
return null;
}
if ($kind === 'gold') {
$productId = (int) ($meta['product_id'] ?? 0);
if ($productId > 0 && ! app(ConfigService::class)->isMainWalletProduct($productId)) {
return null;
}
} elseif ($kind !== 'cash') {
return null;
}
$withdrawId = isset($meta['withdraw_id']) ? (int) $meta['withdraw_id'] : null;
$type = $kind === 'cash' ? self::TYPE_WALLET_CASH : self::TYPE_WALLET_GOLD;
if ($withdrawId) {
$existing = PargarSyncLog::query()
->where('withdraw_id', $withdrawId)
->where('type', $type)
->latest('id')
->first();
if ($existing) {
return $existing;
}
}
$isDeposit = $signedAmount > 0;
if ($kind === 'cash') {
$desc = $isDeposit ? 'واریز ریالی — ارسال به پرگار' : 'برداشت ریالی — ارسال به پرگار';
} else {
$desc = $isDeposit ? 'شارژ طلای کیف اصلی — ارسال به پرگار' : 'برداشت طلای کیف اصلی — ارسال به پرگار';
}
return PargarSyncLog::create([
'customer_id' => $customer->id,
'amount' => abs($signedAmount),
'desc' => $desc,
'withdraw_id' => $withdrawId,
'date' => now(),
'type' => $type,
'sync_status' => self::STATUS_PENDING,
'data' => json_encode([
'handler' => 'exportWallet',
'kind' => $kind,
'signed_amount' => $signedAmount,
'note' => $meta['note'] ?? null,
'product_id' => $meta['product_id'] ?? null,
'source' => $meta['source'] ?? null,
], JSON_UNESCAPED_UNICODE),
]);
}
/**
* @param array{
* payment_kind?: string,
* payment_product_id?: int|null,
* product_id?: int|null,
* number?: float|null
* } $context
*/
public function recordTrade(?Order $order, bool $fromCart = false, array $context = []): ?PargarSyncLog
2026-07-05 16:43:34 +03:30
{
if (! $order || ! $order->id || ! $this->isSyncEnabled()) {
return null;
}
$existing = PargarSyncLog::where('order_id', $order->id)->first();
if ($existing) {
return $existing;
}
$direction = (int) ($order->direction ?? 0);
2026-07-18 16:38:46 +03:30
$desc = \App\Http\Controllers\Controller::accountingShopTradeDesc($direction, $fromCart);
2026-07-05 16:43:34 +03:30
2026-07-20 15:52:42 +03:30
$paymentKind = $context['payment_kind']
?? $this->inferPaymentKindFromContext($context);
$payload = [
'handler' => 'exportOrder',
'direction' => $direction,
'price' => $order->price,
'total_price' => $order->total_price,
'payment_kind' => $paymentKind,
'payment_product_id' => $context['payment_product_id'] ?? null,
'product_id' => $context['product_id'] ?? $order->product_id ?? null,
'number' => $context['number'] ?? null,
'payment_gold' => $context['payment_gold'] ?? null,
'from_cart' => $fromCart,
];
// تهاتر بین دو محصول غیر اصلی — روی ریال/طلای پرگار اثر ندارد
if ($paymentKind === self::PAYMENT_OTHER_PRODUCT) {
$order->pargar_confirmed_at = now();
$order->save();
return PargarSyncLog::create([
'customer_id' => $order->customer_id,
'amount' => $order->amount,
'desc' => $desc . ' (تهاتر محصول — بدون سند ریال/طلای پرگار)',
'order_id' => $order->id,
'date' => $order->pay_at ?? $order->created_at,
'type' => self::TYPE_ORDER,
'sync_status' => self::STATUS_SUCCESS,
'pargar_ref' => 'skip:barter',
'data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
}
2026-07-05 16:43:34 +03:30
return PargarSyncLog::create([
'customer_id' => $order->customer_id,
'amount' => $order->amount,
'desc' => $desc,
'order_id' => $order->id,
'date' => $order->pay_at ?? $order->created_at,
2026-07-20 15:52:42 +03:30
'type' => self::TYPE_ORDER,
2026-07-05 16:43:34 +03:30
'sync_status' => self::STATUS_PENDING,
2026-07-20 15:52:42 +03:30
'data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
2026-07-05 16:43:34 +03:30
]);
}
2026-07-20 15:52:42 +03:30
/**
* @param array{payment_kind?: string, payment_product_id?: int|null} $context
*/
public function inferPaymentKindFromContext(array $context): string
{
if (! empty($context['payment_kind'])) {
return (string) $context['payment_kind'];
}
$paymentProductId = (int) ($context['payment_product_id'] ?? 0);
if ($paymentProductId <= 0) {
return self::PAYMENT_CASH;
}
return app(ConfigService::class)->isMainWalletProduct($paymentProductId)
? self::PAYMENT_MAIN_GOLD
: self::PAYMENT_OTHER_PRODUCT;
}
2026-07-05 16:43:34 +03:30
public function confirmOrders(array $orderIds): array
{
$confirmed = 0;
2026-07-22 01:05:00 +03:30
$customerIds = [];
2026-07-05 16:43:34 +03:30
foreach ($orderIds as $orderId) {
2026-07-20 15:52:42 +03:30
$id = trim((string) $orderId);
if ($id === '') {
continue;
}
if (str_starts_with(strtoupper($id), 'W-')) {
$withdrawId = (int) substr($id, 2);
if ($withdrawId <= 0) {
continue;
}
$log = PargarSyncLog::query()
->where('withdraw_id', $withdrawId)
->whereIn('type', [self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD])
->where('sync_status', self::STATUS_PENDING)
->latest('id')
->first();
if ($log) {
$this->markSuccess($log, $id, ['OrderId' => $id]);
2026-07-22 01:05:00 +03:30
if ($log->customer_id) {
$customerIds[(int) $log->customer_id] = true;
}
2026-07-20 15:52:42 +03:30
$confirmed++;
}
continue;
}
if (str_starts_with(strtoupper($id), 'WLOG-')) {
$logId = (int) substr($id, 5);
if ($logId <= 0) {
continue;
}
$log = PargarSyncLog::query()
->where('id', $logId)
->whereIn('type', [self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD])
->where('sync_status', self::STATUS_PENDING)
->first();
if ($log) {
$this->markSuccess($log, $id, ['OrderId' => $id]);
2026-07-22 01:05:00 +03:30
if ($log->customer_id) {
$customerIds[(int) $log->customer_id] = true;
}
2026-07-20 15:52:42 +03:30
$confirmed++;
}
continue;
}
$order = Order::find($id);
2026-07-05 16:43:34 +03:30
if (! $order) {
continue;
}
$order->pargar_confirmed_at = now();
$order->save();
$log = PargarSyncLog::where('order_id', $order->id)->latest('id')->first();
if ($log) {
$this->markSuccess($log, (string) $order->id, ['OrderId' => (string) $order->id]);
}
2026-07-22 01:05:00 +03:30
if ($order->customer_id) {
$customerIds[(int) $order->customer_id] = true;
} elseif ($log?->customer_id) {
$customerIds[(int) $log->customer_id] = true;
}
2026-07-05 16:43:34 +03:30
$confirmed++;
}
2026-07-22 01:05:00 +03:30
return [
'confirmed' => $confirmed,
'customer_ids' => array_keys($customerIds),
];
2026-07-05 16:43:34 +03:30
}
public function retry(PargarSyncLog $log): array
{
if (! $this->isFailed($log) && ! empty($log->pargar_ref)) {
return ['ok' => false, 'message' => 'این سند قبلاً در پرگار ثبت شده است'];
}
$payload = json_decode((string) $log->data, true) ?: [];
$handler = $payload['handler'] ?? $this->inferHandler($log);
if ($handler === 'exportOrder' && $log->order_id) {
Order::where('id', $log->order_id)->update(['pargar_confirmed_at' => null]);
PargarSyncLog::where('id', $log->id)->update([
'sync_status' => self::STATUS_PENDING,
'sync_log' => null,
'pargar_ref' => null,
]);
$log->refresh();
return [
'ok' => true,
'message' => 'سفارش دوباره در صف ارسال به پرگار قرار گرفت',
'pargar' => $log,
];
}
if ($handler === 'exportCustomer' && $log->customer_id) {
PargarSyncLog::where('id', $log->id)->update([
'sync_status' => self::STATUS_PENDING,
'sync_log' => null,
'pargar_ref' => null,
]);
$log->refresh();
return [
'ok' => true,
'message' => 'مشتری دوباره در صف دریافت پرگار قرار گرفت',
'pargar' => $log,
];
}
2026-07-20 15:52:42 +03:30
if ($handler === 'exportWallet') {
PargarSyncLog::where('id', $log->id)->update([
'sync_status' => self::STATUS_PENDING,
'sync_log' => null,
'pargar_ref' => null,
]);
$log->refresh();
return [
'ok' => true,
'message' => 'سند کیف دوباره در صف ارسال به پرگار قرار گرفت',
'pargar' => $log,
];
}
2026-07-05 16:43:34 +03:30
return [
'ok' => false,
'message' => $log->sync_log ?: 'ارسال مجدد به پرگار ناموفق بود',
'pargar' => $log,
];
}
private function inferHandler(PargarSyncLog $log): string
{
if ($log->order_id) {
return 'exportOrder';
}
2026-07-20 15:52:42 +03:30
if ($log->type === self::TYPE_CUSTOMER) {
2026-07-05 16:43:34 +03:30
return 'exportCustomer';
}
2026-07-20 15:52:42 +03:30
if (in_array($log->type, [self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD], true)) {
return 'exportWallet';
}
if ($log->type === self::TYPE_MANDE) {
2026-07-05 16:43:34 +03:30
return 'importMande';
}
return 'importDocument';
}
public function isSyncEnabled(): bool
{
$config = app(ConfigService::class);
return $config->isPargarAccounting()
&& trim($config->getString('pargar_username', '')) !== ''
&& trim($config->getString('pargar_password', '')) !== '';
}
public function hasSyncTable(): bool
{
return Schema::hasTable('pargar_sync_logs');
}
2026-07-20 15:52:42 +03:30
public function pargarItemIdRial(): int
{
$raw = trim(app(ConfigService::class)->getString('pargar_item_id_rial', '0'));
return max(0, (int) $raw);
}
public function pargarItemIdGold(): int
{
$raw = trim(app(ConfigService::class)->getString('pargar_item_id_gold', '11'));
$id = (int) $raw;
return $id > 0 ? $id : 11;
}
public function mainWalletGoldItemName(): string
{
$product = app(ConfigService::class)->mainWalletRepresentativeProduct();
$title = trim((string) ($product?->title ?? ''));
return $title !== '' ? $title : 'آبشده';
}
2026-07-21 12:36:59 +03:30
/**
* خلاصهٔ اسناد ورودی از پرگار برای نمایش در جدول ادمین.
*
* @param array<string, mixed> $data
* @return array{
* desc: string,
* amount: float|null,
* amount_unit: 'gold'|'rial'|null,
* tx_count: int,
* account_name: string|null
* }
*/
public function summarizeInboundDocument(array $data): array
{
$accountName = trim((string) ($data['accountName'] ?? ''));
$transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : [];
$txLines = [];
$maxGold = 0.0;
$maxRial = 0.0;
$txCount = 0;
foreach ($transactions as $tx) {
if (! is_array($tx)) {
continue;
}
$txCount++;
$desc = trim((string) ($tx['desc'] ?? ''));
$docType = trim((string) ($tx['docType'] ?? ''));
$line = $desc !== '' ? $desc : ($docType !== '' ? $docType : null);
if ($line !== null) {
$txLines[] = $line;
}
$maxGold = max(
$maxGold,
abs($this->parsePargarNumber($tx['bedGold'] ?? 0)),
abs($this->parsePargarNumber($tx['besGold'] ?? 0))
);
$maxRial = max(
$maxRial,
abs($this->parsePargarNumber($tx['bedRial'] ?? 0)),
abs($this->parsePargarNumber($tx['besRial'] ?? 0))
);
}
if ($txLines !== []) {
$unique = array_values(array_unique($txLines));
$desc = implode(' | ', array_slice($unique, 0, 3));
if ($txCount > count(array_slice($unique, 0, 3))) {
$desc .= ' (+' . ($txCount - min(3, count($unique))) . ' سند دیگر)';
}
} elseif ($accountName !== '') {
$desc = "اسناد {$accountName} از پرگار";
} else {
$desc = $txCount > 0 ? "دریافت {$txCount} سند از پرگار" : 'دریافت اسناد از پرگار';
}
$amount = null;
$amountUnit = null;
if ($maxGold >= 0.01) {
$amount = $maxGold;
$amountUnit = 'gold';
} elseif ($maxRial >= 1) {
$amount = $maxRial;
$amountUnit = 'rial';
}
return [
'desc' => $desc,
'amount' => $amount,
'amount_unit' => $amountUnit,
'tx_count' => $txCount,
'account_name' => $accountName !== '' ? $accountName : null,
];
}
private function parsePargarNumber(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
return (float) str_replace('/', '.', (string) $value);
}
2026-07-20 15:52:42 +03:30
public function resolveOrderProduct(?Order $order, ?array $payload = null): ?Product
{
$productId = (int) ($payload['product_id'] ?? $order?->product_id ?? 0);
if ($productId <= 0) {
return null;
}
return Product::find($productId);
}
2026-07-05 16:43:34 +03:30
}