feat(tahesab): register remaining panel customers after document sync

After pending docs finish, the same pump writes customers without a code into Tahesab. Inbound import is server-side; the admin only polls status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 10:09:54 +03:30
co-authored by Cursor
parent ff5430eceb
commit c802c67e0d
3 changed files with 399 additions and 30 deletions
@@ -100,6 +100,8 @@ class TahesabController extends Controller
public function customerSyncStatus(Request $request): \Illuminate\Http\JsonResponse
{
app(TahesabDocSyncService::class)->tickIfStale();
// مشتریان پنل
$panelTotal = Customer::whereNull('deleted_at')->count();
$panelRegistered = Customer::whereNull('deleted_at')->whereNotNull('moshtari_code')->count();
@@ -326,6 +328,46 @@ class TahesabController extends Controller
return ['by_code' => $byCode, 'by_mobile' => $byMobile];
}
/**
* مشتریان ته‌حساب که در پنل نیستند و شماره دارند — برای ورود تکی‌تکی سمت سرور.
*
* @return list<array{code:int,name:string,tel:string}>
*/
public function collectImportableTahesabCustomers(): array
{
$tahesabCustomers = $this->fetchAllTahesabCustomers();
$maps = $this->panelAccountingMaps();
$out = [];
foreach ($tahesabCustomers as $item) {
if (! is_array($item)) {
continue;
}
$code = $this->extractTahesabCode($item);
$codeKey = $this->accountingCodeKey($code);
$phone = $this->extractTahesabPhone($item);
if (Customer::normalizeMobile($phone) === null) {
continue;
}
if ($codeKey !== null && isset($maps['by_code'][$codeKey])) {
continue;
}
$mobileKey = Customer::normalizeMobile($phone);
if ($mobileKey && isset($maps['by_mobile'][$mobileKey])) {
continue;
}
if ($codeKey === null) {
continue;
}
$out[] = [
'code' => (int) $code,
'name' => trim((string) ($item['Name'] ?? $item['name'] ?? '')),
'tel' => $phone,
];
}
return $out;
}
/** لینک کد ته‌حساب روی مشتری پنل وقتی شماره یکسان است */
public function linkMatchingPhones(): \Illuminate\Http\JsonResponse
{
@@ -701,6 +743,20 @@ class TahesabController extends Controller
return response()->json($result);
}
public function startCustomerOutbound()
{
$result = app(TahesabDocSyncService::class)->startCustomerOutbound();
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
}
public function startCustomerInbound()
{
$result = app(TahesabDocSyncService::class)->startCustomerInbound();
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
}
public function pendingDocs(Request $request)
{
$customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null;
+341 -30
View File
@@ -2,18 +2,21 @@
namespace App\Services;
use App\Http\Controllers\Admin\TahesabController;
use App\Models\Customer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* ارسال آرام همهٔ سندهای جاافتاده به ته‌حساب — بدون schedule.
* استارت از API؛ حلقه با artisan در پس‌زمینه یا یک تیک روی pending-summary.
* ارسال آرام سندها (و بعد از آن مشتریان) به ته‌حساب — بدون schedule و بدون جاب صف.
* استارت از API؛ ادامه با همان پمپ موجود یا یک تیک روی pending-summary.
*/
class TahesabDocSyncService
{
public const CACHE_KEY = 'tahesab_doc_sync_job';
/** فاصله بین دو سند تا ته‌حساب بن نکند (ثانیه) */
/** فاصله بین دو آیتم تا ته‌حساب بن نکند (ثانیه) */
public const GAP_SECONDS = 2.5;
private const STALE_SECONDS = 8;
@@ -50,7 +53,7 @@ class TahesabDocSyncService
if (($current['running'] ?? false) === true) {
return [
'ok' => true,
'message' => 'ارسال سندها از قبل در جریان است',
'message' => 'ارسال از قبل در جریان است',
'doc_sync' => $this->publicSnapshot($current),
];
}
@@ -65,32 +68,85 @@ class TahesabDocSyncService
];
}
$job = [
'running' => true,
'stop' => false,
'stopped' => false,
'finished' => false,
'total' => $total,
'processed' => 0,
'success' => 0,
'failed' => 0,
'current' => '',
'errors' => [],
'skip' => ['row' => [], 'withdraw' => [], 'order' => []],
'started_at' => now()->toDateTimeString(),
'finished_at' => null,
'beat_at' => now()->toDateTimeString(),
];
$job = $this->newRunningJob('docs', $total);
$job['skip'] = ['row' => [], 'withdraw' => [], 'order' => []];
$this->write($job, self::TTL_RUNNING);
$this->spawnPump();
return [
'ok' => true,
'message' => 'ارسال سندها شروع شد',
'message' => 'ارسال سندها شروع شد؛ بعد از سندها مشتریان پنل هم در ته‌حساب ثبت می‌شوند',
'doc_sync' => $this->publicSnapshot($job),
];
}
/**
* ثبت مشتریان پنلِ بدون کد در ته‌حساب — بدون کامند تازه؛ تیک از pending-summary.
*
* @return array{ok:bool,message:string,doc_sync:array}
*/
public function startCustomerOutbound(): array
{
$guard = $this->guardCustomerStart();
if ($guard !== null) {
return $guard;
}
$total = (int) Customer::whereNull('deleted_at')->whereNull('moshtari_code')->count();
if ($total <= 0) {
return [
'ok' => false,
'message' => 'مشتری پنل بدون کد ته‌حساب نیست',
'doc_sync' => $this->emptySnapshot(),
];
}
$job = $this->newRunningJob('customers_out', $total);
$job['skip_ids'] = [];
$this->write($job, self::TTL_RUNNING);
$this->pumpOnce();
return [
'ok' => true,
'message' => 'ثبت مشتریان در ته‌حساب شروع شد',
'doc_sync' => $this->snapshot(),
];
}
/**
* ورود مشتریان ته‌حساب به پنل — لیست یک‌بار سمت سرور؛ کلاینت فقط وضعیت می‌گیرد.
*
* @return array{ok:bool,message:string,doc_sync:array}
*/
public function startCustomerInbound(): array
{
$guard = $this->guardCustomerStart();
if ($guard !== null) {
return $guard;
}
$rows = app(TahesabController::class)->collectImportableTahesabCustomers();
if ($rows === []) {
return [
'ok' => false,
'message' => 'مشتری جدیدی با شماره برای ورود به پنل نیست',
'doc_sync' => $this->emptySnapshot(),
];
}
$job = $this->newRunningJob('customers_in', count($rows));
$job['queue'] = $rows;
$job['skip_codes'] = [];
$job['current'] = 'لیست از ته‌حساب گرفته شد';
$this->write($job, self::TTL_RUNNING);
return [
'ok' => true,
'message' => 'ورود مشتریان از ته‌حساب شروع شد',
'doc_sync' => $this->snapshot(),
];
}
/**
* @return array{ok:bool,message:string,doc_sync:array}
*/
@@ -123,7 +179,9 @@ class TahesabDocSyncService
}
$beat = $job['beat_at'] ?? null;
if ($beat && now()->diffInSeconds(\Illuminate\Support\Carbon::parse($beat)) < self::STALE_SECONDS) {
$stage = (string) ($job['stage'] ?? 'docs');
$staleAfter = ($stage === 'customers_out' || $stage === 'customers_in') ? 2 : self::STALE_SECONDS;
if ($beat && now()->diffInSeconds(\Illuminate\Support\Carbon::parse($beat)) < $staleAfter) {
return;
}
@@ -177,12 +235,23 @@ class TahesabDocSyncService
return false;
}
$stage = (string) ($job['stage'] ?? 'docs');
if ($stage === 'customers_out') {
return $this->pumpOutbound($job);
}
if ($stage === 'customers_in') {
return $this->pumpInbound($job);
}
return $this->pumpDocs($job);
}
private function pumpDocs(array $job): bool
{
$sync = app(TahesabSyncService::class);
$next = $sync->peekNextPending($job['skip'] ?? []);
if ($next === null) {
$this->finish($job, false);
return false;
return $this->advanceToOutboundOrFinish($job);
}
$job['current'] = (string) ($next['label'] ?? '');
@@ -207,6 +276,167 @@ class TahesabDocSyncService
]);
}
return $this->recordItem($ok, $message, $job['current'] ?: ($type.' #'.$id), function (array $job, bool $okNow) use ($type, $id) {
if ($okNow) {
return $job;
}
$skip = is_array($job['skip'] ?? null) ? $job['skip'] : ['row' => [], 'withdraw' => [], 'order' => []];
$skip[$type] = array_values(array_unique(array_merge($skip[$type] ?? [], [$id])));
$job['skip'] = $skip;
return $job;
});
}
private function advanceToOutboundOrFinish(array $job): bool
{
$job['doc_total'] = (int) ($job['total'] ?? 0);
$job['doc_success'] = (int) ($job['success'] ?? 0);
$job['doc_failed'] = (int) ($job['failed'] ?? 0);
$total = (int) Customer::whereNull('deleted_at')->whereNull('moshtari_code')->count();
if ($total <= 0) {
$this->finish($job, false);
return false;
}
$job['stage'] = 'customers_out';
$job['total'] = $total;
$job['processed'] = 0;
$job['success'] = 0;
$job['failed'] = 0;
$job['errors'] = [];
$job['skip_ids'] = [];
$job['current'] = 'شروع ثبت مشتریان پنل در ته‌حساب…';
$job['beat_at'] = now()->toDateTimeString();
$this->write($job, self::TTL_RUNNING);
return true;
}
private function pumpOutbound(array $job): bool
{
$skip = array_values(array_filter($job['skip_ids'] ?? []));
$customer = Customer::whereNull('deleted_at')
->whereNull('moshtari_code')
->when($skip !== [], fn ($q) => $q->whereNotIn('id', $skip))
->orderBy('id')
->first(['id', 'name', 'mobile']);
if ($customer === null) {
$this->finish($job, false);
return false;
}
$label = trim((string) $customer->name.' '.(string) $customer->mobile);
$job['current'] = $label !== '' ? $label : ('مشتری #'.$customer->id);
$job['beat_at'] = now()->toDateTimeString();
$this->write($job, self::TTL_RUNNING);
$ok = false;
$message = '';
try {
$result = app(TahesabSyncService::class)->registerCustomer($customer);
$ok = (bool) ($result['ok'] ?? false);
$message = (string) ($result['message'] ?? '');
} catch (\Throwable $e) {
$ok = false;
$message = $e->getMessage();
Log::warning('Tahesab customer outbound failed', [
'id' => $customer->id,
'error' => $e->getMessage(),
]);
}
$id = (int) $customer->id;
return $this->recordItem($ok, $message, $job['current'], function (array $job, bool $okNow) use ($id) {
if ($okNow) {
return $job;
}
$skip = array_values(array_filter($job['skip_ids'] ?? []));
$skip[] = $id;
$job['skip_ids'] = array_values(array_unique($skip));
return $job;
});
}
private function pumpInbound(array $job): bool
{
$queue = is_array($job['queue'] ?? null) ? $job['queue'] : [];
$skip = [];
foreach ($job['skip_codes'] ?? [] as $code) {
$skip[(int) $code] = true;
}
$idx = null;
$row = null;
foreach ($queue as $i => $item) {
if (! is_array($item)) {
continue;
}
$code = (int) ($item['code'] ?? 0);
if ($code > 0 && isset($skip[$code])) {
continue;
}
$idx = $i;
$row = $item;
break;
}
if ($row === null) {
$this->finish($job, false);
return false;
}
$label = trim((string) ($row['name'] ?? '').' '.(string) ($row['code'] ?? ''));
$job['current'] = $label !== '' ? $label : 'مشتری ته‌حساب';
$job['beat_at'] = now()->toDateTimeString();
$this->write($job, self::TTL_RUNNING);
$ok = false;
$message = '';
try {
$result = $this->importInboundRow($row);
$ok = (bool) ($result['ok'] ?? false);
$message = (string) ($result['message'] ?? '');
} catch (\Throwable $e) {
$ok = false;
$message = $e->getMessage();
Log::warning('Tahesab customer inbound failed', [
'code' => $row['code'] ?? null,
'error' => $e->getMessage(),
]);
}
$code = (int) ($row['code'] ?? 0);
return $this->recordItem($ok, $message, $job['current'], function (array $job, bool $okNow) use ($idx, $code) {
$queue = is_array($job['queue'] ?? null) ? $job['queue'] : [];
if ($okNow && $idx !== null) {
array_splice($queue, (int) $idx, 1);
$job['queue'] = array_values($queue);
} elseif (! $okNow) {
$skip = array_values(array_filter($job['skip_codes'] ?? []));
if ($code > 0) {
$skip[] = $code;
}
$job['skip_codes'] = array_values(array_unique($skip));
}
return $job;
});
}
/**
* @param callable(array, bool): array $mutate
*/
private function recordItem(bool $ok, string $message, string $label, callable $mutate): bool
{
if (! $ok && $this->looksRateLimited($message)) {
$job = $this->read();
if (($job['running'] ?? false) !== true) {
@@ -232,21 +462,43 @@ class TahesabDocSyncService
$job['failed'] = (int) ($job['failed'] ?? 0) + 1;
$errors = is_array($job['errors'] ?? null) ? $job['errors'] : [];
$errors[] = [
'label' => $job['current'] ?: ($type.' #'.$id),
'label' => $label !== '' ? $label : 'آیتم',
'error' => $message !== '' ? $message : 'ناموفق',
];
$job['errors'] = array_slice($errors, -30);
$skip = is_array($job['skip'] ?? null) ? $job['skip'] : ['row' => [], 'withdraw' => [], 'order' => []];
$skip[$type] = array_values(array_unique(array_merge($skip[$type] ?? [], [$id])));
$job['skip'] = $skip;
}
$job = $mutate($job, $ok);
$job['beat_at'] = now()->toDateTimeString();
$this->write($job, self::TTL_RUNNING);
return true;
}
/**
* @param array<string, mixed> $row
* @return array{ok:bool,message:string}
*/
private function importInboundRow(array $row): array
{
$request = Request::create('/', 'POST', [
'customers' => [[
'code' => (int) ($row['code'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'tel' => (string) ($row['tel'] ?? ''),
]],
]);
$response = app(TahesabController::class)->importFromTahesab($request);
$data = method_exists($response, 'getData') ? $response->getData(true) : [];
$ok = ((int) ($data['success'] ?? 0)) > 0;
$err = '';
if (! $ok) {
$errors = is_array($data['errors'] ?? null) ? $data['errors'] : [];
$err = (string) ($errors[0]['error'] ?? 'وارد نشد');
}
return ['ok' => $ok, 'message' => $err];
}
private function finish(array $job, bool $stopped): void
{
$job['running'] = false;
@@ -254,6 +506,7 @@ class TahesabDocSyncService
$job['stopped'] = $stopped;
$job['stop'] = false;
$job['current'] = '';
$job['queue'] = [];
$job['finished_at'] = now()->toDateTimeString();
$job['beat_at'] = now()->toDateTimeString();
$this->write($job, self::TTL_DONE);
@@ -327,6 +580,55 @@ class TahesabDocSyncService
|| str_contains($m, 'تعداد درخواست');
}
/**
* @return array{ok:bool,message:string,doc_sync:array}|null
*/
private function guardCustomerStart(): ?array
{
$sync = app(TahesabSyncService::class);
if (! $sync->isSyncEnabled()) {
return [
'ok' => false,
'message' => 'همگام‌سازی ته‌حساب فعال نیست یا تنظیمات ناقص است',
'doc_sync' => $this->snapshot(),
];
}
$current = $this->read();
if (($current['running'] ?? false) === true) {
return [
'ok' => true,
'message' => 'انتقال از قبل در جریان است',
'doc_sync' => $this->publicSnapshot($current),
];
}
return null;
}
/**
* @return array<string, mixed>
*/
private function newRunningJob(string $stage, int $total): array
{
return [
'running' => true,
'stop' => false,
'stopped' => false,
'finished' => false,
'stage' => $stage,
'total' => $total,
'processed' => 0,
'success' => 0,
'failed' => 0,
'current' => '',
'errors' => [],
'started_at' => now()->toDateTimeString(),
'finished_at' => null,
'beat_at' => now()->toDateTimeString(),
];
}
private function read(): array
{
$job = Cache::get(self::CACHE_KEY);
@@ -355,10 +657,16 @@ class TahesabDocSyncService
$percent = 100;
}
$stage = (string) ($job['stage'] ?? 'docs');
if ($stage === '') {
$stage = 'docs';
}
return [
'running' => $running,
'finished' => $finished,
'stopped' => (bool) ($job['stopped'] ?? false),
'stage' => $stage,
'percent' => $percent,
'total' => $total,
'processed' => $processed,
@@ -366,6 +674,9 @@ class TahesabDocSyncService
'failed' => (int) ($job['failed'] ?? 0),
'current' => (string) ($job['current'] ?? ''),
'errors' => is_array($job['errors'] ?? null) ? $job['errors'] : [],
'doc_total' => (int) ($job['doc_total'] ?? 0),
'doc_success' => (int) ($job['doc_success'] ?? 0),
'doc_failed' => (int) ($job['doc_failed'] ?? 0),
'started_at' => $job['started_at'] ?? null,
'finished_at' => $job['finished_at'] ?? null,
];
+2
View File
@@ -249,6 +249,8 @@ Route::middleware('auth:user')->group(function (){
Route::post('tahesab/sync-all-pending', [TahesabController::class, 'syncAllPending']);
Route::post('tahesab/doc-sync/start', [TahesabController::class, 'startDocSync']);
Route::post('tahesab/doc-sync/stop', [TahesabController::class, 'stopDocSync']);
Route::post('tahesab/customer-sync/outbound/start', [TahesabController::class, 'startCustomerOutbound']);
Route::post('tahesab/customer-sync/inbound/start', [TahesabController::class, 'startCustomerInbound']);
Route::get('tahesab/customer-sync/status', [TahesabController::class, 'customerSyncStatus']);
Route::post('tahesab/customer-sync/panel-to-tahesab', [TahesabController::class, 'syncPanelToTahesab']);
Route::post('tahesab/customer-sync/import-from-tahesab', [TahesabController::class, 'importFromTahesab']);