feat: initialize Dr-Panel project with patient file number support
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
||||
class OsurgImportService
|
||||
{
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$report = [];
|
||||
|
||||
if (! file_exists($filePath)) {
|
||||
return ['success' => false, 'report' => ["فایل پیدا نشد: $filePath"]];
|
||||
}
|
||||
|
||||
@set_time_limit(600);
|
||||
@ini_set('memory_limit', '512M');
|
||||
|
||||
try {
|
||||
$report[] = "خواندن فایل: $filePath";
|
||||
|
||||
[$patients, $oldIdToIcno] = $this->parsePatients($filePath);
|
||||
$report[] = 'بیماران خواندهشده: ' . count($patients);
|
||||
|
||||
$visits = $this->parseVisits($filePath);
|
||||
$report[] = 'ویزیتهای خواندهشده: ' . count($visits);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
[$pCreated, $pUpdated, $icnoToNewId] = $this->importPatients($patients);
|
||||
$report[] = "بیماران — جدید: $pCreated | بهروزرسانی: $pUpdated";
|
||||
|
||||
$oldIntIdToNewId = $this->buildOldIntIdMap($oldIdToIcno, $icnoToNewId);
|
||||
|
||||
[$vCreated, $vSkipped] = $this->importVisits($visits, $oldIntIdToNewId, $icnoToNewId);
|
||||
$report[] = "ویزیتها — جدید: $vCreated | ردشده: $vSkipped";
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['success' => true, 'report' => $report];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'report' => array_merge($report, ['خطا: ' . $e->getMessage()])];
|
||||
}
|
||||
}
|
||||
|
||||
private function parsePatients(string $filePath): array
|
||||
{
|
||||
$rows = [];
|
||||
$oldIdToIcno = [];
|
||||
|
||||
foreach ($this->streamInsertRows($filePath, 'patients') as [$columns, $values]) {
|
||||
$row = array_combine($columns, $values);
|
||||
|
||||
$oldId = (string) ($row['ID'] ?? '');
|
||||
$icno = $row['icno'] ?? '';
|
||||
|
||||
if ($icno !== '' && $oldId !== '') {
|
||||
$oldIdToIcno[$oldId] = $icno;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return [$rows, $oldIdToIcno];
|
||||
}
|
||||
|
||||
|
||||
private function parseVisits(string $filePath): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($this->streamInsertRows($filePath, 'visits') as [$columns, $values]) {
|
||||
$rows[] = array_combine($columns, $values);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
|
||||
private function importPatients(array $patients): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$icnoToNewId = [];
|
||||
|
||||
$colMap = [
|
||||
'icno' => 'icno',
|
||||
'first_name' => 'first_name',
|
||||
'last_name' => 'last_name',
|
||||
'father_name' => 'father_name',
|
||||
'gender' => 'gender',
|
||||
'age' => 'age',
|
||||
'job' => 'job',
|
||||
'marital_status' => 'marital_status',
|
||||
'education' => 'education',
|
||||
'hand_phone' => 'hand_phone',
|
||||
'home_phone' => 'home_phone',
|
||||
'work_phone' => 'work_phone',
|
||||
'other_phone' => 'other_phone',
|
||||
'home_address' => 'home_address',
|
||||
'work_address' => 'work_address',
|
||||
'refered_by' => 'refered_by',
|
||||
'referal_reason' => 'referal_reason',
|
||||
'doc_id' => 'doc_id',
|
||||
'insurance' => 'insurance',
|
||||
'insurance_no' => 'insurance_no',
|
||||
'is_undercare' => 'is_undercare',
|
||||
'undercare_reason' => 'undercare_reason',
|
||||
'is_usingdrug' => 'is_usingdrug',
|
||||
'underdrug_reason' => 'underdrug_reason',
|
||||
'has_alergyto' => 'has_alergyto',
|
||||
'alergy_reason' => 'alergy_reason',
|
||||
'description' => 'description',
|
||||
'birth_date' => 'birth_date',
|
||||
'blood_pressure' => 'blood_pressure',
|
||||
'blood_sugar' => 'blood_sugar',
|
||||
'surgery_before' => 'surgery_before',
|
||||
'sergery_after' => 'sergery_after',
|
||||
'photos_before' => 'photos_before',
|
||||
'photos_after' => 'photos_after',
|
||||
'videos' => 'videos',
|
||||
'current_illness_1' => 'current_illness_1',
|
||||
'current_illness_2' => 'current_illness_2',
|
||||
];
|
||||
|
||||
$jsonArrayFields = [
|
||||
'photos_before', 'photos_after', 'videos',
|
||||
'has_alergyto', 'current_illness_1', 'current_illness_2',
|
||||
];
|
||||
|
||||
foreach ($patients as $raw) {
|
||||
$icno = trim($raw['icno'] ?? '');
|
||||
if ($icno === '' || $icno === 'NULL') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [];
|
||||
foreach ($colMap as $oldCol => $newCol) {
|
||||
$val = $raw[$oldCol] ?? null;
|
||||
$val = ($val === 'NULL' || $val === null) ? null : $val;
|
||||
|
||||
if ($val !== null && in_array($newCol, $jsonArrayFields, true)) {
|
||||
$val = $this->normaliseJsonArray($val);
|
||||
}
|
||||
|
||||
$row[$newCol] = $val;
|
||||
}
|
||||
|
||||
$row['audio_files'] = null;
|
||||
$row['icno'] = $icno;
|
||||
|
||||
|
||||
$createdAt = $this->toGregorianDatetime($raw['log_datetime'] ?? null);
|
||||
$updatedAt = $this->toGregorianDatetime($raw['modify_datetime'] ?? null);
|
||||
$row['created_at'] = $createdAt ?? now();
|
||||
$row['updated_at'] = $updatedAt ?? $createdAt ?? now();
|
||||
|
||||
|
||||
$row['created_by'] = null;
|
||||
$row['updated_by'] = null;
|
||||
|
||||
$existing = DB::table('patients')->where('icno', $icno)->first();
|
||||
if ($existing) {
|
||||
DB::table('patients')->where('icno', $icno)->update($row);
|
||||
$icnoToNewId[$icno] = $existing->id;
|
||||
$updated++;
|
||||
} else {
|
||||
$newId = DB::table('patients')->insertGetId($row);
|
||||
$icnoToNewId[$icno] = $newId;
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
return [$created, $updated, $icnoToNewId];
|
||||
}
|
||||
|
||||
|
||||
private function importVisits(array $visits, array $oldIntIdToNewId, array $icnoToNewId): array
|
||||
{
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$colMap = [
|
||||
'visit_date' => 'visit_date',
|
||||
'visit_time' => 'visit_time',
|
||||
'treatment' => 'treatment',
|
||||
'treatment_cost' => 'treatment_cost',
|
||||
'payment_type' => 'payment_type',
|
||||
'paid_amount' => 'paid_amount',
|
||||
'pos_ul' => 'pos_ul',
|
||||
'pos_ll' => 'pos_ll',
|
||||
'pos_ur' => 'pos_ur',
|
||||
'pos_lr' => 'pos_lr',
|
||||
'treatment_description'=> 'treatment_description',
|
||||
'doctor' => 'doctor',
|
||||
];
|
||||
|
||||
$intFields = ['treatment', 'treatment_cost', 'payment_type', 'paid_amount',
|
||||
'pos_ul', 'pos_ll', 'pos_ur', 'pos_lr', 'doctor'];
|
||||
|
||||
foreach ($visits as $raw) {
|
||||
$oldPatientRef = (string) ($raw['patient'] ?? '');
|
||||
|
||||
$newPatientId = $this->resolvePatientId($oldPatientRef, $oldIntIdToNewId, $icnoToNewId);
|
||||
if ($newPatientId === null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = ['patient' => $newPatientId];
|
||||
foreach ($colMap as $oldCol => $newCol) {
|
||||
$val = $raw[$oldCol] ?? null;
|
||||
$val = ($val === 'NULL' || $val === null || $val === '') ? null : $val;
|
||||
$row[$newCol] = $val;
|
||||
}
|
||||
|
||||
if (! empty($row['visit_date'])) {
|
||||
$row['visit_date'] = $this->jalaliToGregorian($row['visit_date']);
|
||||
}
|
||||
|
||||
$row['visit_time'] = null;
|
||||
|
||||
foreach ($intFields as $f) {
|
||||
if (isset($row[$f])) {
|
||||
$row[$f] = (int) $row[$f];
|
||||
}
|
||||
}
|
||||
|
||||
$row['created_at'] = now();
|
||||
$row['updated_at'] = now();
|
||||
$row['created_by'] = null;
|
||||
$row['updated_by'] = null;
|
||||
|
||||
DB::table('visits')->insert($row);
|
||||
$created++;
|
||||
}
|
||||
|
||||
return [$created, $skipped];
|
||||
}
|
||||
|
||||
|
||||
private function jalaliToGregorian(?string $jalali): ?string
|
||||
{
|
||||
if (empty($jalali) || $jalali === 'NULL') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$normalised = str_replace('-', '/', trim($jalali));
|
||||
return \Morilog\Jalali\Jalalian::fromFormat('Y/m/d', $normalised)
|
||||
->toCarbon()
|
||||
->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function toGregorianDatetime(?string $value): ?string
|
||||
{
|
||||
if (empty($value) || $value === 'NULL') {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function buildOldIntIdMap(array $oldIdToIcno, array $icnoToNewId): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($oldIdToIcno as $oldId => $icno) {
|
||||
if (isset($icnoToNewId[$icno])) {
|
||||
$map[$oldId] = $icnoToNewId[$icno];
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
|
||||
private function resolvePatientId(string $ref, array $oldIntIdToNewId, array $icnoToNewId): ?int
|
||||
{
|
||||
if ($ref === '' || $ref === 'NULL') {
|
||||
return null;
|
||||
}
|
||||
if (isset($oldIntIdToNewId[$ref])) {
|
||||
return $oldIntIdToNewId[$ref];
|
||||
}
|
||||
if (isset($icnoToNewId[$ref])) {
|
||||
return $icnoToNewId[$ref];
|
||||
}
|
||||
// Live DB fallback
|
||||
$p = Patient::where('icno', $ref)->first();
|
||||
return $p?->id;
|
||||
}
|
||||
|
||||
|
||||
private function normaliseJsonArray(?string $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$paths = array_values(array_filter(array_map(function ($item) {
|
||||
if (is_string($item)) {
|
||||
return $item;
|
||||
}
|
||||
if (is_array($item) && isset($item['name'])) {
|
||||
return $item['name'];
|
||||
}
|
||||
return null;
|
||||
}, $decoded)));
|
||||
|
||||
return json_encode($paths, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
// plain string — wrap it
|
||||
return json_encode([$value], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
|
||||
private function streamInsertRows(string $filePath, string $table): \Generator
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
$pattern = '/^INSERT INTO `' . preg_quote($table, '/') . '` \((.+?)\) VALUES\s*$/i';
|
||||
|
||||
$columns = [];
|
||||
$inInsert = false;
|
||||
$rowBuffer = '';
|
||||
|
||||
while (($raw = fgets($handle)) !== false) {
|
||||
$line = rtrim($raw);
|
||||
|
||||
if (preg_match($pattern, $line, $m)) {
|
||||
$inInsert = true;
|
||||
$columns = $this->parseColumnList($m[1]);
|
||||
$rowBuffer = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inInsert && preg_match('/^INSERT INTO `([^`]+)`/i', $line, $m2)
|
||||
&& strtolower($m2[1]) !== strtolower($table)) {
|
||||
$inInsert = false;
|
||||
$rowBuffer = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $inInsert) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($line === ';') {
|
||||
$inInsert = false;
|
||||
$rowBuffer = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($line === '' || str_starts_with($line, '--')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowBuffer .= ($rowBuffer !== '' ? "\n" : '') . $line;
|
||||
|
||||
if ($this->isCompleteValueRow($rowBuffer)) {
|
||||
$values = $this->parseValueRow($rowBuffer);
|
||||
if (count($values) === count($columns)) {
|
||||
yield [$columns, $values];
|
||||
}
|
||||
$rowBuffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
private function parseColumnList(string $colStr): array
|
||||
{
|
||||
$cols = [];
|
||||
foreach (explode(',', $colStr) as $c) {
|
||||
$cols[] = trim(trim($c), '`');
|
||||
}
|
||||
return $cols;
|
||||
}
|
||||
|
||||
private function isCompleteValueRow(string $buf): bool
|
||||
{
|
||||
if (! str_starts_with($buf, '(')) {
|
||||
return false;
|
||||
}
|
||||
$count = 0;
|
||||
$escaped = false;
|
||||
$len = strlen($buf);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
if ($escaped) { $escaped = false; continue; }
|
||||
if ($buf[$i] === '\\') { $escaped = true; continue; }
|
||||
if ($buf[$i] === "'") { $count++; }
|
||||
}
|
||||
return ($count % 2 === 0) && (bool) preg_match('/\),?;?\s*$/', $buf);
|
||||
}
|
||||
|
||||
|
||||
private function parseValueRow(string $row): array
|
||||
{
|
||||
$row = trim($row);
|
||||
$row = preg_replace('/[,;]\s*$/', '', $row);
|
||||
if (str_starts_with($row, '(')) {
|
||||
$row = substr($row, 1);
|
||||
}
|
||||
if (str_ends_with($row, ')')) {
|
||||
$row = substr($row, 0, -1);
|
||||
}
|
||||
|
||||
$values = [];
|
||||
$current = '';
|
||||
$inStr = false;
|
||||
$escaped = false;
|
||||
$len = strlen($row);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ch = $row[$i];
|
||||
|
||||
if ($escaped) {
|
||||
$current .= $ch;
|
||||
$escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === '\\') {
|
||||
$current .= $ch;
|
||||
$escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === "'" && ! $inStr) {
|
||||
$inStr = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === "'" && $inStr) {
|
||||
$inStr = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === ',' && ! $inStr) {
|
||||
$values[] = $this->unescapeSqlString($current);
|
||||
$current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$current .= $ch;
|
||||
}
|
||||
|
||||
$values[] = $this->unescapeSqlString($current);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function unescapeSqlString(string $val): string
|
||||
{
|
||||
$val = trim($val);
|
||||
if ($val === 'NULL') {
|
||||
return 'NULL';
|
||||
}
|
||||
return str_replace(
|
||||
['\\n', '\\r', '\\t', "\\'", '\\\\', '\\"'],
|
||||
["\n", "\r", "\t", "'", '\\', '"'],
|
||||
$val
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PdfService
|
||||
{
|
||||
|
||||
public static function download(string $view, array $data, string $filename): StreamedResponse
|
||||
{
|
||||
if (class_exists(\Mpdf\Mpdf::class)) {
|
||||
return self::downloadWithMpdf($view, $data, $filename);
|
||||
}
|
||||
|
||||
return self::downloadWithDompdf($view, $data, $filename);
|
||||
}
|
||||
|
||||
private static function downloadWithMpdf(string $view, array $data, string $filename): StreamedResponse
|
||||
{
|
||||
$defaultConfig = (new \Mpdf\Config\ConfigVariables())->getDefaults();
|
||||
$defaultFontConfig = (new \Mpdf\Config\FontVariables())->getDefaults();
|
||||
|
||||
$fontsDir = storage_path('fonts');
|
||||
|
||||
$mpdf = new \Mpdf\Mpdf([
|
||||
'mode' => 'utf-8',
|
||||
'format' => 'A4',
|
||||
'orientation' => 'P',
|
||||
'margin_top' => 15,
|
||||
'margin_bottom' => 15,
|
||||
'margin_left' => 15,
|
||||
'margin_right' => 15,
|
||||
'tempDir' => sys_get_temp_dir(),
|
||||
'fontDir' => array_merge($defaultConfig['fontDir'], [$fontsDir]),
|
||||
'fontdata' => $defaultFontConfig['fontdata'] + [
|
||||
'tahoma' => [
|
||||
'R' => 'tahoma.ttf',
|
||||
'B' => 'tahomabd.ttf',
|
||||
'useOTL' => 0xFF,
|
||||
'useKashida' => 75,
|
||||
],
|
||||
],
|
||||
'default_font' => 'tahoma',
|
||||
]);
|
||||
|
||||
$mpdf->autoScriptToLang = true;
|
||||
$mpdf->baseScript = 1;
|
||||
$mpdf->autoArabic = true;
|
||||
$mpdf->autoLangToFont = true;
|
||||
|
||||
$html = view($view, $data)->render();
|
||||
$mpdf->WriteHTML($html);
|
||||
$output = $mpdf->Output('', 'S');
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print($output),
|
||||
$filename,
|
||||
['Content-Type' => 'application/pdf'],
|
||||
);
|
||||
}
|
||||
|
||||
private static function downloadWithDompdf(string $view, array $data, string $filename): StreamedResponse
|
||||
{
|
||||
$pdf = Pdf::loadView($view, $data)->setPaper('a4', 'portrait');
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print($pdf->output()),
|
||||
$filename,
|
||||
['Content-Type' => 'application/pdf'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\OsurgInitial;
|
||||
use App\Models\SyncLog;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $peerIp,
|
||||
private readonly int $peerPort,
|
||||
private readonly string $token,
|
||||
private readonly string $ftpUser,
|
||||
private readonly string $ftpPass,
|
||||
private readonly int $ftpPort,
|
||||
private readonly string $ftpRemotePath,
|
||||
) {}
|
||||
|
||||
public static function fromConfig(): static
|
||||
{
|
||||
return new static(
|
||||
peerIp: OsurgInitial::val('sync_peer_ip', config('sync.peer_ip', '')),
|
||||
peerPort: (int) OsurgInitial::val('sync_peer_port', config('sync.peer_port', 80)),
|
||||
token: OsurgInitial::val('sync_token', config('sync.token', '')),
|
||||
ftpUser: OsurgInitial::val('ftp_user', config('sync.ftp_user', '')),
|
||||
ftpPass: OsurgInitial::val('ftp_pass', config('sync.ftp_pass', '')),
|
||||
ftpPort: (int) OsurgInitial::val('ftp_port', config('sync.ftp_port', 2121)),
|
||||
ftpRemotePath: OsurgInitial::val('ftp_remote_path', config('sync.ftp_remote_path', '')),
|
||||
);
|
||||
}
|
||||
|
||||
public function ping(): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return ['connected' => false, 'path' => null];
|
||||
}
|
||||
|
||||
$ip = escapeshellarg($this->peerIp);
|
||||
$cmd = PHP_OS_FAMILY === 'Windows'
|
||||
? "ping -n 1 -w 1000 {$ip}"
|
||||
: "ping -c 1 -W 1 {$ip}";
|
||||
|
||||
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
return ['connected' => false, 'path' => null];
|
||||
}
|
||||
|
||||
$path = null;
|
||||
try {
|
||||
$response = Http::timeout(3)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->get("http://{$this->peerIp}:{$this->peerPort}/api/sync/ping");
|
||||
|
||||
if ($response->successful() && $response->json('status') === 'ok') {
|
||||
$path = $response->json('path');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return ['connected' => true, 'path' => $path];
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->peerIp !== '' && $this->token !== '';
|
||||
}
|
||||
|
||||
public function isFtpConfigured(): bool
|
||||
{
|
||||
return $this->peerIp !== '' && $this->ftpUser !== '' && $this->ftpRemotePath !== '';
|
||||
}
|
||||
|
||||
public function sync(): array
|
||||
{
|
||||
$report = [];
|
||||
$baseUrl = "http://{$this->peerIp}:{$this->peerPort}";
|
||||
|
||||
$peerCursor = \App\Models\OsurgInitial::val('sync_peer_cursor') ?: null;
|
||||
$ourLastSync = \App\Models\OsurgInitial::val('our_sync_cursor') ?: null;
|
||||
|
||||
try {
|
||||
$pullResponse = Http::timeout(30)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->get("{$baseUrl}/api/sync/export", ['since' => $peerCursor]);
|
||||
|
||||
if ($pullResponse->successful()) {
|
||||
$peerChanges = $pullResponse->json('changes', []);
|
||||
|
||||
if (! empty($peerChanges)) {
|
||||
app()->instance('sync.applying', true);
|
||||
|
||||
$pullReport = [];
|
||||
DB::transaction(function () use ($peerChanges, &$pullReport) {
|
||||
foreach ($peerChanges as $change) {
|
||||
$this->applyChange($change, 'دریافت', $pullReport);
|
||||
}
|
||||
});
|
||||
foreach ($pullReport as $line) {
|
||||
$report[] = $line;
|
||||
}
|
||||
|
||||
$pullHasErrors = ! empty(array_filter($pullReport, fn ($l) => str_contains((string) $l, '✗')));
|
||||
|
||||
if (! $pullHasErrors) {
|
||||
$datetimes = array_filter(array_column($peerChanges, 'datetime'));
|
||||
$maxPeerDatetime = \Carbon\Carbon::parse(max($datetimes))->format('Y-m-d H:i:s');
|
||||
if ($peerCursor === null || $maxPeerDatetime > $peerCursor) {
|
||||
$this->savePeerCursor($maxPeerDatetime);
|
||||
}
|
||||
} else {
|
||||
$report[] = 'هشدار: peer_cursor پیش نرفت — برخی تغییرات دریافتی اعمال نشد و دفعه بعد retry میشه.';
|
||||
}
|
||||
|
||||
if ($this->isFtpConfigured()) {
|
||||
$this->transferFilesViaFtp($peerChanges, 's2c', $report);
|
||||
} else {
|
||||
$this->syncFilesViaHttp($peerChanges, $baseUrl, $report);
|
||||
}
|
||||
}
|
||||
|
||||
$report[] = count($peerChanges) . ' تغییر از سیستم مقابل دریافت شد.';
|
||||
} else {
|
||||
$report[] = 'خطا در دریافت تغییرات از سیستم مقابل: ' . $pullResponse->status();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = 'خطا در اتصال به سیستم مقابل: ' . $e->getMessage();
|
||||
return ['success' => false, 'report' => $report];
|
||||
}
|
||||
|
||||
$ourChanges = SyncLog::changesSince($ourLastSync)->toArray();
|
||||
|
||||
try {
|
||||
$pushResponse = Http::timeout(120)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->post("{$baseUrl}/api/sync/apply", ['changes' => $ourChanges]);
|
||||
|
||||
if ($pushResponse->successful()) {
|
||||
$pushReport = $pushResponse->json('report', []);
|
||||
foreach ($pushReport as $line) {
|
||||
$report[] = 'ارسال: ' . $line;
|
||||
}
|
||||
$report[] = count($ourChanges) . ' تغییر به سیستم مقابل ارسال شد.';
|
||||
|
||||
$hasErrors = ! empty(array_filter($pushReport, fn ($l) => str_contains((string) $l, '✗')));
|
||||
|
||||
if (! empty($ourChanges) && ! $hasErrors) {
|
||||
$ourDatetimes = array_filter(array_column($ourChanges, 'datetime'));
|
||||
$maxOurDatetime = \Carbon\Carbon::parse(max($ourDatetimes))->format('Y-m-d H:i:s');
|
||||
if ($ourLastSync === null || $maxOurDatetime > $ourLastSync) {
|
||||
$this->saveOurCursor($maxOurDatetime);
|
||||
}
|
||||
} elseif ($hasErrors) {
|
||||
$report[] = 'هشدار: cursor پیش نرفت — برخی تغییرات روی سیستم مقابل اعمال نشد و دفعه بعد retry میشه.';
|
||||
}
|
||||
|
||||
if ($this->isFtpConfigured()) {
|
||||
$this->transferFilesViaFtp($ourChanges, 'c2s', $report);
|
||||
} else {
|
||||
$this->pushFilesViaHttp($ourChanges, $baseUrl, $report);
|
||||
}
|
||||
} else {
|
||||
$report[] = 'خطا در ارسال تغییرات به سیستم مقابل: ' . $pushResponse->status();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = 'خطا در ارسال به سیستم مقابل: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
SyncLog::markSynced(request()->ip() ?? '127.0.0.1');
|
||||
|
||||
return ['success' => true, 'report' => $report];
|
||||
}
|
||||
|
||||
public function applyChanges(array $changes): array
|
||||
{
|
||||
$report = [];
|
||||
app()->instance('sync.applying', true);
|
||||
|
||||
DB::transaction(function () use ($changes, &$report) {
|
||||
foreach ($changes as $change) {
|
||||
$this->applyChange($change, '', $report);
|
||||
}
|
||||
});
|
||||
|
||||
SyncLog::markSynced(request()->ip() ?? '');
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
private function transferFilesViaFtp(array $changes, string $side, array &$report): void
|
||||
{
|
||||
if (empty($changes)) {
|
||||
return;
|
||||
}
|
||||
$ftp = $this->connectFtp();
|
||||
if (! $ftp) {
|
||||
$report[] = 'خطا در اتصال FTP به سیستم مقابل';
|
||||
Log::error('FTP connection failed in transferFilesViaFtp');
|
||||
return;
|
||||
}
|
||||
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
$localBase = rtrim(Storage::disk('public')->path(''), '/\\');
|
||||
$remoteBase = rtrim($this->ftpRemotePath, '/\\');
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$data = $change['changed_data'] ?? [];
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
continue;
|
||||
}
|
||||
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
if (! $filePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = ltrim(str_replace('\\', '/', $filePath), '/');
|
||||
$localFile = $localBase . '/' . $filePath;
|
||||
$remoteFile = $remoteBase . '/' . $filePath;
|
||||
$remoteFolder = dirname($remoteFile);
|
||||
|
||||
if ($side === 'c2s') {
|
||||
if (! file_exists($localFile)) {
|
||||
$errorMsg = "فایل [{$filePath}] در سیستم محلی یافت نشد";
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::warning('FTP upload skipped - local file not found', [
|
||||
'file' => $filePath,
|
||||
'local_path' => $localFile,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->createFtpDirectory($ftp, $remoteFolder)) {
|
||||
$errorMsg = "ایجاد پوشه [{$remoteFolder}] برای فایل [{$filePath}] شکست خورد";
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::error('FTP mkdir failed', [
|
||||
'file' => $filePath,
|
||||
'remote_folder' => $remoteFolder,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ftp_put($ftp, $remoteFile, $localFile, FTP_BINARY)) {
|
||||
$report[] = "فایل [{$filePath}] ارسال شد (FTP) ✓";
|
||||
Log::info('FTP file uploaded', [
|
||||
'file' => $filePath,
|
||||
'size' => filesize($localFile),
|
||||
]);
|
||||
} else {
|
||||
$errorDetail = error_get_last();
|
||||
$errorMsg = "فایل [{$filePath}] ارسال نشد (FTP)";
|
||||
if ($errorDetail) {
|
||||
$errorMsg .= " - " . $errorDetail['message'];
|
||||
}
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::error('FTP upload failed', [
|
||||
'file' => $filePath,
|
||||
'local_file' => $localFile,
|
||||
'remote_file' => $remoteFile,
|
||||
'error' => $errorDetail,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
if (file_exists($localFile)) {
|
||||
Log::debug('FTP download skipped - file already exists', [
|
||||
'file' => $filePath,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$localFolder = dirname($localFile);
|
||||
if (! is_dir($localFolder)) {
|
||||
if (! mkdir($localFolder, 0755, true)) {
|
||||
$report[] = "ایجاد پوشه [{$localFolder}] شکست خورد ✗";
|
||||
Log::error('Local mkdir failed', [
|
||||
'folder' => $localFolder,
|
||||
'error' => error_get_last(),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ftp_get($ftp, $localFile, $remoteFile, FTP_BINARY)) {
|
||||
$report[] = "فایل [{$filePath}] دریافت شد (FTP) ✓";
|
||||
Log::info('FTP file downloaded', [
|
||||
'file' => $filePath,
|
||||
'size' => filesize($localFile),
|
||||
]);
|
||||
} else {
|
||||
$errorDetail = error_get_last();
|
||||
$errorMsg = "فایل [{$filePath}] دریافت نشد (FTP)";
|
||||
if ($errorDetail) {
|
||||
$errorMsg .= " - " . $errorDetail['message'];
|
||||
}
|
||||
$report[] = $errorMsg . ' ✗';
|
||||
Log::error('FTP download failed', [
|
||||
'file' => $filePath,
|
||||
'local_file' => $localFile,
|
||||
'remote_file' => $remoteFile,
|
||||
'error' => $errorDetail,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ftp_close($ftp);
|
||||
Log::info('FTP connection closed');
|
||||
}
|
||||
|
||||
private function connectFtp(): mixed
|
||||
{
|
||||
if (! function_exists('ftp_connect')) {
|
||||
Log::error('FTP extension is not available');
|
||||
return null;
|
||||
}
|
||||
|
||||
$ftp = ftp_connect($this->peerIp, $this->ftpPort, 30);
|
||||
if (! $ftp) {
|
||||
Log::error('FTP connection failed', [
|
||||
'host' => $this->peerIp,
|
||||
'port' => $this->ftpPort,
|
||||
'error' => error_get_last(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! ftp_login($ftp, $this->ftpUser, $this->ftpPass)) {
|
||||
Log::error('FTP login failed', [
|
||||
'host' => $this->peerIp,
|
||||
'port' => $this->ftpPort,
|
||||
'user' => $this->ftpUser,
|
||||
'error' => error_get_last(),
|
||||
]);
|
||||
ftp_close($ftp);
|
||||
return null;
|
||||
}
|
||||
|
||||
ftp_pasv($ftp, true);
|
||||
Log::info('FTP connection established', [
|
||||
'host' => $this->peerIp,
|
||||
'port' => $this->ftpPort,
|
||||
]);
|
||||
|
||||
return $ftp;
|
||||
}
|
||||
|
||||
private function createFtpDirectory($ftp, string $path): bool
|
||||
{
|
||||
$parts = explode('/', trim($path, '/'));
|
||||
$currentPath = '';
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (empty($part)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentPath .= '/' . $part;
|
||||
|
||||
$originalDir = ftp_pwd($ftp);
|
||||
if (@ftp_chdir($ftp, $currentPath)) {
|
||||
@ftp_chdir($ftp, $originalDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! @ftp_mkdir($ftp, $currentPath)) {
|
||||
Log::error('FTP mkdir failed', [
|
||||
'path' => $currentPath,
|
||||
'full_path' => $path,
|
||||
'error' => error_get_last(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
Log::debug('FTP directory created', ['path' => $currentPath]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function pushFilesViaHttp(array $changes, string $baseUrl, array &$report): void
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$data = $change['changed_data'] ?? [];
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
continue;
|
||||
}
|
||||
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
if (! $filePath || ! Storage::disk('public')->exists($filePath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$response = Http::timeout(600)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->attach('file', Storage::disk('public')->get($filePath), basename($filePath))
|
||||
->post("{$baseUrl}/api/sync/receive-file", ['path' => $filePath]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$report[] = "فایل [{$filePath}] ارسال شد (HTTP) ✓";
|
||||
} else {
|
||||
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $response->status();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function syncFilesViaHttp(array $changes, string $baseUrl, array &$report): void
|
||||
{
|
||||
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$data = $change['changed_data'] ?? [];
|
||||
foreach ($fileFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
continue;
|
||||
}
|
||||
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
if (! $filePath || Storage::disk('public')->exists($filePath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$response = Http::timeout(60)
|
||||
->withHeader('X-Sync-Token', $this->token)
|
||||
->get("{$baseUrl}/api/sync/file", ['path' => $filePath]);
|
||||
|
||||
if ($response->successful()) {
|
||||
Storage::disk('public')->put($filePath, $response->body());
|
||||
$report[] = "فایل [{$filePath}] دریافت شد (HTTP) ✓";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function applyChange(array $change, string $prefix, array &$report): void
|
||||
{
|
||||
$table = $change['table_name'];
|
||||
$recordId = $change['record_id'];
|
||||
$action = $change['action'];
|
||||
$data = $change['changed_data'] ?? [];
|
||||
|
||||
$label = $prefix ? "{$prefix} [{$table}#{$recordId}]" : "[{$table}#{$recordId}]";
|
||||
|
||||
try {
|
||||
if ($action === 'deleted') {
|
||||
DB::table($table)->where('id', $recordId)->delete();
|
||||
$report[] = "{$label} حذف ✓";
|
||||
} elseif ($action === 'created') {
|
||||
$exists = DB::table($table)->where('id', $recordId)->exists();
|
||||
if ($exists) {
|
||||
DB::table($table)->where('id', $recordId)->update($this->sanitize($data));
|
||||
$report[] = "{$label} ایجاد→بروزرسانی ✓";
|
||||
} else {
|
||||
DB::table($table)->insert($this->sanitize(array_merge(['id' => $recordId], $data)));
|
||||
$report[] = "{$label} ایجاد ✓";
|
||||
}
|
||||
} elseif ($action === 'updated') {
|
||||
DB::table($table)->where('id', $recordId)->update($this->sanitize($data));
|
||||
$report[] = "{$label} بروزرسانی ✓";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$report[] = "{$label} {$action} ✗ " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private function saveOurCursor(string $cursor): void
|
||||
{
|
||||
\App\Models\OsurgInitial::updateOrCreate(
|
||||
['init_parameter' => 'our_sync_cursor'],
|
||||
['init_value' => $cursor],
|
||||
);
|
||||
cache()->forget('osurg_initial.our_sync_cursor');
|
||||
}
|
||||
|
||||
private function savePeerCursor(string $cursor): void
|
||||
{
|
||||
\App\Models\OsurgInitial::updateOrCreate(
|
||||
['init_parameter' => 'sync_peer_cursor'],
|
||||
['init_value' => $cursor],
|
||||
);
|
||||
cache()->forget('osurg_initial.sync_peer_cursor');
|
||||
}
|
||||
|
||||
private function sanitize(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$result[$key] = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
} elseif (is_scalar($value) || $value === null) {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user