feat(claims): enhance claims filtering with insurance, date range, and patient search
This commit is contained in:
@@ -1,14 +1,26 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon } from '@heroicons/react/24/outline';
|
import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon, MagnifyingGlassIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
import FeatureGate from '../components/ui/FeatureGate';
|
import FeatureGate from '../components/ui/FeatureGate';
|
||||||
|
|
||||||
|
const toUnix = (iso: string): number | null => {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = new Date(iso + 'T00:00:00').getTime();
|
||||||
|
return Number.isNaN(t) ? null : Math.floor(t / 1000);
|
||||||
|
};
|
||||||
|
const endOfDayUnix = (iso: string): number | null => {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = new Date(iso + 'T23:59:59').getTime();
|
||||||
|
return Number.isNaN(t) ? null : Math.floor(t / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
interface ClaimItem {
|
interface ClaimItem {
|
||||||
invoice_item_id: number;
|
invoice_item_id: number;
|
||||||
claimed_rials: number;
|
claimed_rials: number;
|
||||||
@@ -30,9 +42,13 @@ interface Claim {
|
|||||||
total_paid_rials: number | null;
|
total_paid_rials: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
reject_reason: string | null;
|
reject_reason: string | null;
|
||||||
|
patient_name: string | null;
|
||||||
|
patient_mobile: string | null;
|
||||||
items: ClaimItem[];
|
items: ClaimItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InsuranceOption { insurance_id: number; insurance_name: string | null }
|
||||||
|
|
||||||
interface DebtRow {
|
interface DebtRow {
|
||||||
insurance_id: number;
|
insurance_id: number;
|
||||||
insurance_name: string | null;
|
insurance_name: string | null;
|
||||||
@@ -56,14 +72,36 @@ const STATUS_FILTERS = ['', 'pending', 'submitted', 'approved', 'rejected', 'pai
|
|||||||
export default function ClaimsPage() {
|
export default function ClaimsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [insuranceFilter, setInsuranceFilter] = useState('');
|
||||||
|
const [fromDate, setFromDate] = useState('');
|
||||||
|
const [toDate, setToDate] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [rejectTarget, setRejectTarget] = useState<Claim | null>(null);
|
const [rejectTarget, setRejectTarget] = useState<Claim | null>(null);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
|
const queryString = useMemo(() => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (statusFilter) p.set('status', statusFilter);
|
||||||
|
if (insuranceFilter) p.set('insurance_id', insuranceFilter);
|
||||||
|
const from = toUnix(fromDate); if (from) p.set('from', String(from));
|
||||||
|
const to = endOfDayUnix(toDate); if (to) p.set('to', String(to));
|
||||||
|
if (search.trim()) p.set('q', search.trim());
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? `?${s}` : '';
|
||||||
|
}, [statusFilter, insuranceFilter, fromDate, toDate, search]);
|
||||||
|
|
||||||
const claimsQuery = useQuery<{ data: { data: Claim[] } }>({
|
const claimsQuery = useQuery<{ data: { data: Claim[] } }>({
|
||||||
queryKey: ['claims', statusFilter],
|
queryKey: ['claims', queryString],
|
||||||
queryFn: () => api.get(`/api/v1/billing/claims${statusFilter ? `?status=${statusFilter}` : ''}`),
|
queryFn: () => api.get(`/api/v1/billing/claims${queryString}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insuranceOptionsQuery = useQuery<{ data: { data: InsuranceOption[] } }>({
|
||||||
|
queryKey: ['claim-insurance-options'],
|
||||||
|
queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'),
|
||||||
|
});
|
||||||
|
const insuranceOptions = ((insuranceOptionsQuery.data as any)?.data?.data ?? []) as InsuranceOption[];
|
||||||
|
|
||||||
const debtQuery = useQuery<{ data: { data: DebtRow[] } }>({
|
const debtQuery = useQuery<{ data: { data: DebtRow[] } }>({
|
||||||
queryKey: ['insurance-debt'],
|
queryKey: ['insurance-debt'],
|
||||||
queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'),
|
queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'),
|
||||||
@@ -113,16 +151,64 @@ export default function ClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ padding: 18 }}>
|
<div className="card" style={{ padding: 18 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>مطالبات</h2>
|
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>مطالبات</h2>
|
||||||
<div style={{ minWidth: 160 }}>
|
{(statusFilter || insuranceFilter || fromDate || toDate || search) && (
|
||||||
|
<button
|
||||||
|
className="btn ghost sm"
|
||||||
|
onClick={() => { setStatusFilter(''); setInsuranceFilter(''); setFromDate(''); setToDate(''); setSearch(''); setSearchInput(''); }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon style={{ width: 13 }} /> پاککردن فیلترها
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نوار فیلتر */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', marginBottom: 16 }}>
|
||||||
|
<div style={{ minWidth: 150 }}>
|
||||||
|
<label className="field-label">بیمه</label>
|
||||||
|
<SearchableSelect
|
||||||
|
options={[{ value: '', label: 'همه بیمهها' }, ...insuranceOptions.map((o) => ({ value: String(o.insurance_id), label: o.insurance_name ?? `بیمه #${o.insurance_id}` }))]}
|
||||||
|
value={insuranceFilter}
|
||||||
|
onChange={(v) => setInsuranceFilter(v ? String(v) : '')}
|
||||||
|
placeholder="همه بیمهها"
|
||||||
|
height={38}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 150 }}>
|
||||||
|
<label className="field-label">وضعیت</label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={STATUS_FILTERS.map((s) => ({ value: s, label: s === '' ? 'همه وضعیتها' : STATUS_META[s]?.label ?? s }))}
|
options={STATUS_FILTERS.map((s) => ({ value: s, label: s === '' ? 'همه وضعیتها' : STATUS_META[s]?.label ?? s }))}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(v) => setStatusFilter(v ? String(v) : '')}
|
onChange={(v) => setStatusFilter(v ? String(v) : '')}
|
||||||
placeholder="همه وضعیتها"
|
placeholder="همه وضعیتها"
|
||||||
|
height={38}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">از تاریخ</label>
|
||||||
|
<PersianDateInput value={fromDate} onChange={setFromDate} placeholder="از تاریخ" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">تا تاریخ</label>
|
||||||
|
<PersianDateInput value={toDate} onChange={setToDate} placeholder="تا تاریخ" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<label className="field-label">جستجوی بیمار (نام / موبایل / کدملی)</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<MagnifyingGlassIcon style={{ width: 15, position: 'absolute', insetInlineStart: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ height: 38, paddingInlineStart: 32 }}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||||
|
placeholder="نام، موبایل یا کدملی بیمار"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn primary" style={{ height: 38 }} onClick={() => setSearch(searchInput)}>جستجو</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{claimsQuery.isLoading ? (
|
{claimsQuery.isLoading ? (
|
||||||
@@ -142,6 +228,12 @@ export default function ClaimsPage() {
|
|||||||
<span className="badge gray" style={{ fontSize: 10, marginInlineStart: 6 }}>{KIND_LABEL[c.insurance_kind] ?? c.insurance_kind}</span>
|
<span className="badge gray" style={{ fontSize: 10, marginInlineStart: 6 }}>{KIND_LABEL[c.insurance_kind] ?? c.insurance_kind}</span>
|
||||||
<span className={`badge ${meta.cls}`} style={{ fontSize: 10, marginInlineStart: 4 }}><span className="bdot" />{meta.label}</span>
|
<span className={`badge ${meta.cls}`} style={{ fontSize: 10, marginInlineStart: 4 }}><span className="bdot" />{meta.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{c.patient_name && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 4, fontWeight: 500 }}>
|
||||||
|
بیمار: {c.patient_name}
|
||||||
|
{c.patient_mobile && <span style={{ color: 'var(--text-3)', fontWeight: 400 }} dir="ltr">{' '}{c.patient_mobile}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 3 }}>
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 3 }}>
|
||||||
ادعا {formatRial(c.total_claimed_rials)}
|
ادعا {formatRial(c.total_claimed_rials)}
|
||||||
{c.total_approved_rials != null && ` · تأیید ${formatRial(c.total_approved_rials)}`}
|
{c.total_approved_rials != null && ` · تأیید ${formatRial(c.total_approved_rials)}`}
|
||||||
|
|||||||
+14
-2
@@ -110,8 +110,20 @@
|
|||||||
|
|
||||||
هر مطالبه در پاسخ علاوه بر `insurance_id`، فیلد `insurance_name` (نام بیمه، یا `null` اگر بیمه حذف شده باشد) را نیز دارد. در `POST` و `transition` و `GET` یکسان است.
|
هر مطالبه در پاسخ علاوه بر `insurance_id`، فیلد `insurance_name` (نام بیمه، یا `null` اگر بیمه حذف شده باشد) را نیز دارد. در `POST` و `transition` و `GET` یکسان است.
|
||||||
|
|
||||||
## GET /api/v1/billing/claims?status=
|
## GET /api/v1/billing/claims
|
||||||
لیست مطالبات tenant. فیلتر اختیاری `status` (`pending|submitted|approved|rejected|paid`). هر مطالبه شامل `insurance_name` و آرایهی `items` با جزئیات هر ردیف است.
|
لیست مطالبات tenant با فیلترهای اختیاری (query params):
|
||||||
|
|
||||||
|
| پارامتر | نوع | توضیح |
|
||||||
|
|---------|-----|-------|
|
||||||
|
| `status` | string | `pending\|submitted\|approved\|rejected\|paid` |
|
||||||
|
| `insurance_id` | int | فقط مطالبات یک بیمهگر |
|
||||||
|
| `from` | int (Unix) | مطالبات با `created_at >= from` (شروع بازه) |
|
||||||
|
| `to` | int (Unix) | مطالبات با `created_at <= to` (پایان بازه) |
|
||||||
|
| `q` | string | جستجوی بیمار: موبایل، کدملی یا نام (LIKE) |
|
||||||
|
|
||||||
|
> بازهی تاریخ شمسی (سال/ماه/روز خاص) در سمت کلاینت به `from`/`to` یونیکس تبدیل میشود؛ backend فقط بازهی یونیکس میگیرد. فیلتر `q` از طریق زنجیره `claim → claim_item → invoice_item → invoice → patient_record → user` با `EXISTS` اعمال میشود.
|
||||||
|
|
||||||
|
هر مطالبه شامل `insurance_name`، `patient_name`، `patient_mobile` و آرایهی `items` با جزئیات هر ردیف است.
|
||||||
|
|
||||||
هر عضو `items` علاوه بر `invoice_item_id`، `claimed_rials`، `approved_rials`، این فیلدهای نمایشی را دارد (برای جدول صفحهی مطالبات):
|
هر عضو `items` علاوه بر `invoice_item_id`، `claimed_rials`، `approved_rials`، این فیلدهای نمایشی را دارد (برای جدول صفحهی مطالبات):
|
||||||
|
|
||||||
|
|||||||
@@ -59,13 +59,18 @@ class BillingController extends BaseController
|
|||||||
$sessionIds[] = $d['patient_session_id'];
|
$sessionIds[] = $d['patient_session_id'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$sessionDates = $this->sessionRepo->datesForIds(array_values(array_unique($sessionIds)));
|
$uniqueSessionIds = array_values(array_unique($sessionIds));
|
||||||
|
$sessionDates = $this->sessionRepo->datesForIds($uniqueSessionIds);
|
||||||
|
$patientInfo = $this->sessionRepo->patientInfoForIds($uniqueSessionIds);
|
||||||
|
|
||||||
return array_map(function (Claim $claim) use ($itemDetails, $sessionDates) {
|
return array_map(function (Claim $claim) use ($itemDetails, $sessionDates, $patientInfo) {
|
||||||
$data = $claim->toArray();
|
$data = $claim->toArray();
|
||||||
$data['insurance_name'] = $this->insuranceRepo->find($claim->getInsuranceId())?->getName();
|
$data['insurance_name'] = $this->insuranceRepo->find($claim->getInsuranceId())?->getName();
|
||||||
|
|
||||||
$data['items'] = array_map(function (array $item) use ($itemDetails, $sessionDates) {
|
$patientName = null;
|
||||||
|
$patientMobile = null;
|
||||||
|
|
||||||
|
$data['items'] = array_map(function (array $item) use ($itemDetails, $sessionDates, $patientInfo, &$patientName, &$patientMobile) {
|
||||||
$detail = $itemDetails[$item['invoice_item_id']] ?? null;
|
$detail = $itemDetails[$item['invoice_item_id']] ?? null;
|
||||||
$sessionId = $detail['patient_session_id'] ?? null;
|
$sessionId = $detail['patient_session_id'] ?? null;
|
||||||
$item['title'] = $detail['title'] ?? null;
|
$item['title'] = $detail['title'] ?? null;
|
||||||
@@ -73,9 +78,17 @@ class BillingController extends BaseController
|
|||||||
$item['quantity'] = $detail['quantity'] ?? 1;
|
$item['quantity'] = $detail['quantity'] ?? 1;
|
||||||
$item['total_rials'] = $detail['total_rials'] ?? null;
|
$item['total_rials'] = $detail['total_rials'] ?? null;
|
||||||
$item['visit_date'] = $sessionId !== null ? ($sessionDates[$sessionId] ?? null) : null;
|
$item['visit_date'] = $sessionId !== null ? ($sessionDates[$sessionId] ?? null) : null;
|
||||||
|
|
||||||
|
if ($patientName === null && $sessionId !== null && isset($patientInfo[$sessionId])) {
|
||||||
|
$patientName = $patientInfo[$sessionId]['name'];
|
||||||
|
$patientMobile = $patientInfo[$sessionId]['mobile'];
|
||||||
|
}
|
||||||
return $item;
|
return $item;
|
||||||
}, $data['items']);
|
}, $data['items']);
|
||||||
|
|
||||||
|
$data['patient_name'] = $patientName;
|
||||||
|
$data['patient_mobile'] = $patientMobile;
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}, $claims);
|
}, $claims);
|
||||||
}
|
}
|
||||||
@@ -164,8 +177,14 @@ class BillingController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = $request->query->get('status') ?: null;
|
$filters = [
|
||||||
$claims = $this->claimRepo->findByTenant($entityType, $entityId, $status);
|
'status' => $request->query->get('status') ?: null,
|
||||||
|
'insurance_id' => $request->query->get('insurance_id') ?: null,
|
||||||
|
'from' => $request->query->get('from') ?: null,
|
||||||
|
'to' => $request->query->get('to') ?: null,
|
||||||
|
'q' => $request->query->get('q') ?: null,
|
||||||
|
];
|
||||||
|
$claims = $this->claimRepo->findByTenant($entityType, $entityId, $filters);
|
||||||
|
|
||||||
return $this->success(['data' => $this->enrichClaims($claims)]);
|
return $this->success(['data' => $this->enrichClaims($claims)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ class ClaimRepository extends ServiceEntityRepository
|
|||||||
return $this->findOneBy(['uuid' => $uuid]);
|
return $this->findOneBy(['uuid' => $uuid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Claim[] */
|
/**
|
||||||
public function findByTenant(string $entityType, int $entityId, ?string $status = null): array
|
* @param array{status?:?string, insurance_id?:?int, from?:?int, to?:?int, q?:?string} $filters
|
||||||
|
* @return Claim[]
|
||||||
|
*/
|
||||||
|
public function findByTenant(string $entityType, int $entityId, array $filters = []): array
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
->where('c.entityType = :type')
|
->where('c.entityType = :type')
|
||||||
@@ -28,8 +31,34 @@ class ClaimRepository extends ServiceEntityRepository
|
|||||||
->setParameter('id', $entityId)
|
->setParameter('id', $entityId)
|
||||||
->orderBy('c.id', 'DESC');
|
->orderBy('c.id', 'DESC');
|
||||||
|
|
||||||
if ($status !== null) {
|
if (!empty($filters['status'])) {
|
||||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
$qb->andWhere('c.status = :status')->setParameter('status', $filters['status']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['insurance_id'])) {
|
||||||
|
$qb->andWhere('c.insuranceId = :insId')->setParameter('insId', (int) $filters['insurance_id']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['from'])) {
|
||||||
|
$qb->andWhere('c.createdAt >= :from')->setParameter('from', (int) $filters['from']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['to'])) {
|
||||||
|
$qb->andWhere('c.createdAt <= :to')->setParameter('to', (int) $filters['to']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// فیلتر بیمار: مطالباتی که آیتمشان به صورتحساب پروندهی همان کاربر اشاره دارد (موبایل/کدملی/نام).
|
||||||
|
if (!empty($filters['q'])) {
|
||||||
|
$q = trim((string) $filters['q']);
|
||||||
|
$qb->andWhere(
|
||||||
|
'EXISTS (' .
|
||||||
|
'SELECT 1 FROM App\Billing\Entity\ClaimItem ci2 ' .
|
||||||
|
'JOIN App\Billing\Entity\InvoiceItem ii2 WITH ii2.id = ci2.invoiceItemId ' .
|
||||||
|
'JOIN App\Billing\Entity\Invoice inv2 WITH inv2 = ii2.invoice ' .
|
||||||
|
'JOIN App\Patient\Entity\PatientRecord pr2 WITH pr2.id = inv2.patientRecordId ' .
|
||||||
|
'JOIN App\Auth\Entity\User u2 WITH u2 = pr2.user ' .
|
||||||
|
'WHERE ci2.claim = c AND (' .
|
||||||
|
'u2.mobileNumber LIKE :q OR u2.nationalCode LIKE :q OR u2.realName LIKE :q' .
|
||||||
|
')' .
|
||||||
|
')'
|
||||||
|
)->setParameter('q', '%' . $q . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
|
|||||||
@@ -89,4 +89,31 @@ class PatientSessionRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نام و موبایل بیمار هر مراجعه — کلیددار بر اساس id جلسه.
|
||||||
|
* @param int[] $ids
|
||||||
|
* @return array<int, array{name: ?string, mobile: ?string}>
|
||||||
|
*/
|
||||||
|
public function patientInfoForIds(array $ids): array
|
||||||
|
{
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->createQueryBuilder('s')
|
||||||
|
->select('s.id AS id', 'u.realName AS name', 'u.mobileNumber AS mobile')
|
||||||
|
->join('s.record', 'r')
|
||||||
|
->join('r.user', 'u')
|
||||||
|
->where('s.id IN (:ids)')
|
||||||
|
->setParameter('ids', $ids)
|
||||||
|
->getQuery()
|
||||||
|
->getArrayResult();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$out[(int) $r['id']] = ['name' => $r['name'], 'mobile' => $r['mobile']];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user