Files
boursecu_server/app/Services/ColleagueOrderCallbackService.php
T
mohmdsdgandCursor 0b1e5c687b feat(order): wait for colleague callback before settling wallet
Keep cover orders pending until colleague sts 2/4/6 via callback or 35s poll. Nightly prices:prune also drops old log tables.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:55:03 +03:30

335 lines
9.5 KiB
PHP

<?php
namespace App\Services;
use App\Http\Controllers\Single\SingleOrderController;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class ColleagueOrderCallbackService
{
public const POLL_SECONDS = 35;
public const SSE_EVENT = 'colleague_order';
public function waitEnabled(): bool
{
$config = app(ConfigService::class);
return $config->usesMontzarPricing() && $config->getBool('montzar_order_wait_callback', true);
}
public function callbackUrl(int $localOrderId): string
{
$origin = rtrim(app(ConfigService::class)->resolvePublicSiteUrl(), '/');
if ($origin === '') {
$origin = rtrim((string) config('app.url'), '/');
$origin = (string) preg_replace('#/server/?$#', '', $origin);
}
return $origin . '/server/customer/colleague/order/callback/' . $localOrderId;
}
/**
* @param array<string, mixed> $body
*/
public function handleCallbackPayload(array $body, ?int $localOrderId = null): array
{
$order = $this->findPendingOrder($body, $localOrderId);
if (!$order) {
return ['ok' => false, 'message' => 'سفارش در انتظار یافت نشد'];
}
$sts = $this->providerStsFromBody($body);
if ($sts === null) {
return ['ok' => false, 'message' => 'وضعیت نامعتبر'];
}
$this->rememberProviderId($order, $body);
return $this->applyProviderSts($order, $sts, $body);
}
public function pollDueOrders(): int
{
if (!Schema::hasTable('orders') || !Schema::hasColumn('orders', 'sts')) {
return 0;
}
$cutoff = now()->subSeconds(self::POLL_SECONDS);
$handled = 0;
$query = Order::query()->where('sts', 4)->whereNotNull('provider_id');
if (Schema::hasColumn('orders', 'colleague_last_poll_at')) {
$query->where(function ($q) use ($cutoff) {
$q->where(function ($inner) use ($cutoff) {
$inner->whereNull('colleague_last_poll_at')
->where('created_at', '<=', $cutoff);
})->orWhere('colleague_last_poll_at', '<=', $cutoff);
});
} else {
$query->where('created_at', '<=', $cutoff);
}
$orders = $query->orderBy('id')->limit(80)->get();
foreach ($orders as $order) {
try {
$this->pollOne($order);
$handled++;
} catch (\Throwable $e) {
Log::warning('Colleague pending poll failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
$this->touchPollAt($order);
}
}
return $handled;
}
public function pollOne(Order $order): array
{
$providerId = (int) ($order->provider_id ?? 0);
if ($providerId <= 0) {
$this->touchPollAt($order);
return ['ok' => true, 'state' => 'wait'];
}
$body = app(MontzarService::class)->fetchColleagueOrder($providerId);
$this->touchPollAt($order);
if (!is_array($body)) {
return ['ok' => true, 'state' => 'wait'];
}
$sts = $this->providerStsFromBody($body);
if ($sts === null) {
return ['ok' => true, 'state' => 'wait'];
}
return $this->applyProviderSts($order, $sts, $body);
}
/**
* @param array<string, mixed> $body
*/
public function applyProviderSts(Order $order, int $providerSts, array $body = []): array
{
if (in_array($providerSts, [4, 6], true)) {
return $this->rejectPending($order, $body);
}
if ($providerSts === 2) {
return $this->approvePending($order, $body);
}
return ['ok' => true, 'state' => 'wait', 'sts' => (int) $order->sts];
}
public function pendingForCustomer(int $customerId): ?Order
{
if ($customerId <= 0) {
return null;
}
return Order::query()
->where('customer_id', $customerId)
->where('sts', 4)
->orderByDesc('id')
->first();
}
public function publicStatusPayload(?Order $order): array
{
if (!$order) {
return [
'pending' => false,
'order' => null,
'sts' => null,
'sts_str' => null,
];
}
$sts = (int) $order->sts;
return [
'pending' => $sts === 4,
'order_id' => $order->id,
'sts' => $sts,
'sts_str' => Order::$STS[$sts] ?? null,
'order' => $order,
];
}
public function broadcast(Order $order): void
{
$customerId = (int) ($order->customer_id ?? 0);
if ($customerId <= 0) {
return;
}
app(PriceStreamService::class)->publishForCustomer($customerId, self::SSE_EVENT, [
'order_id' => $order->id,
'sts' => (int) $order->sts,
'sts_str' => Order::$STS[(int) $order->sts] ?? null,
'pending' => (int) $order->sts === 4,
]);
}
/**
* @param array<string, mixed> $body
*/
private function findPendingOrder(array $body, ?int $localOrderId): ?Order
{
if ($localOrderId && $localOrderId > 0) {
$order = Order::query()->where('id', $localOrderId)->where('sts', 4)->first();
if ($order) {
return $order;
}
}
$providerId = $this->providerIdFromBody($body);
if ($providerId > 0) {
return Order::query()
->where('provider_id', $providerId)
->where('sts', 4)
->orderByDesc('id')
->first();
}
return null;
}
/**
* @param array<string, mixed> $body
*/
private function rememberProviderId(Order $order, array $body): void
{
$providerId = $this->providerIdFromBody($body);
if ($providerId <= 0 || !Schema::hasColumn('orders', 'provider_id')) {
return;
}
if ((int) ($order->provider_id ?? 0) === $providerId) {
return;
}
$order->provider_id = $providerId;
$order->save();
}
/**
* @param array<string, mixed> $body
*/
private function approvePending(Order $order, array $body): array
{
$this->rememberProviderId($order, $body);
$done = false;
DB::transaction(function () use ($order, &$done) {
$locked = Order::query()->where('id', $order->id)->lockForUpdate()->first();
if (!$locked || (int) $locked->sts !== 4) {
return;
}
app(SingleOrderController::class)->finalizeColleaguePendingOrder($locked);
$done = true;
});
$fresh = Order::find($order->id);
if ($fresh) {
$this->broadcast($fresh);
}
return [
'ok' => true,
'state' => $done ? 'approved' : 'ignored',
'sts' => $fresh ? (int) $fresh->sts : 2,
];
}
/**
* @param array<string, mixed> $body
*/
private function rejectPending(Order $order, array $body): array
{
DB::transaction(function () use ($order, $body) {
$locked = Order::query()->where('id', $order->id)->lockForUpdate()->first();
if (!$locked || (int) $locked->sts !== 4) {
return;
}
$message = trim((string) ($body['sts_str'] ?? $body['message'] ?? 'رد شده توسط همکار'));
app(SingleOrderController::class)->rejectColleaguePendingOrder($locked, $message !== '' ? $message : 'رد شده توسط همکار');
});
$fresh = Order::find($order->id);
if ($fresh) {
$this->broadcast($fresh);
}
return [
'ok' => true,
'state' => 'rejected',
'sts' => $fresh ? (int) $fresh->sts : 3,
];
}
private function touchPollAt(Order $order): void
{
if (!Schema::hasColumn('orders', 'colleague_last_poll_at')) {
return;
}
$order->colleague_last_poll_at = now();
$order->save();
}
/**
* @param array<string, mixed> $body
*/
public function providerStsFromBody(array $body): ?int
{
$rows = [$body, $body['data'] ?? null, $body['order'] ?? null];
foreach ($body as $item) {
if (!is_array($item)) {
continue;
}
$rows[] = $item;
$rows[] = $item['order'] ?? null;
$rows[] = $item['data'] ?? null;
}
foreach ($rows as $row) {
if (!is_array($row) || !array_key_exists('sts', $row) || $row['sts'] === null || $row['sts'] === '') {
continue;
}
return (int) $row['sts'];
}
return null;
}
/**
* @param array<string, mixed> $body
*/
private function providerIdFromBody(array $body): int
{
foreach ([$body, $body['data'] ?? null, $body['order'] ?? null] as $row) {
if (!is_array($row)) {
continue;
}
foreach (['order_id', 'id', 'provider_order_id'] as $key) {
$value = (int) ($row[$key] ?? 0);
if ($value > 0) {
return $value;
}
}
}
return 0;
}
}