Compare commits
32
Commits
68b8b05630
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fdc232e65 | ||
|
|
b33f38072d | ||
|
|
e4bd37b3bd | ||
|
|
f863b39a74 | ||
|
|
be12502873 | ||
|
|
15dfbe61fd | ||
|
|
73a9351f14 | ||
|
|
bb09316a75 | ||
|
|
60c2eaa35e | ||
|
|
1ce9957538 | ||
|
|
5548d6248c | ||
|
|
32044c8fa9 | ||
|
|
b81cb8a90e | ||
|
|
a1584c3296 | ||
|
|
5eb3bd586f | ||
|
|
30b999872c | ||
|
|
a2ff69ebad | ||
|
|
1ca20b6f53 | ||
|
|
40b1da64be | ||
|
|
8e4bb3ca7b | ||
|
|
6e3d574e38 | ||
|
|
4f76cff503 | ||
|
|
b24f45cc83 | ||
|
|
1f64b516d2 | ||
|
|
5d2594ff87 | ||
|
|
8a43297e24 | ||
|
|
ed2d2ec74a | ||
|
|
5986fad5a1 | ||
|
|
57ea7b8c59 | ||
|
|
1d13212d47 | ||
|
|
2459625c41 | ||
|
|
ee2682e222 |
@@ -17,7 +17,9 @@
|
|||||||
# Replace mariadb-XXXXXXXX / redis-XXXXXXXX with the real hostname shown on each
|
# Replace mariadb-XXXXXXXX / redis-XXXXXXXX with the real hostname shown on each
|
||||||
# resource's page (Internal URL). serverVersion MUST match the MariaDB resource (11.8).
|
# resource's page (Internal URL). serverVersion MUST match the MariaDB resource (11.8).
|
||||||
DATABASE_URL="mysql://clinic:DB_PASSWORD@mariadb-XXXXXXXX:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4"
|
DATABASE_URL="mysql://clinic:DB_PASSWORD@mariadb-XXXXXXXX:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4"
|
||||||
REDIS_URL="redis://redis-XXXXXXXX:6379"
|
# retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بیسروصدا دوباره برقرار میشود
|
||||||
|
# و «Connection lost» بهصورت warning در app_log نمینشیند.
|
||||||
|
REDIS_URL="redis://redis-XXXXXXXX:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60"
|
||||||
# stream_max_entries caps the Redis stream so the queue cannot grow without bound
|
# stream_max_entries caps the Redis stream so the queue cannot grow without bound
|
||||||
MESSENGER_TRANSPORT_DSN="redis://redis-XXXXXXXX:6379/messages?stream_max_entries=20000"
|
MESSENGER_TRANSPORT_DSN="redis://redis-XXXXXXXX:6379/messages?stream_max_entries=20000"
|
||||||
# If the Redis resource has a password: redis://:PASSWORD@redis-XXXXXXXX:6379
|
# If the Redis resource has a password: redis://:PASSWORD@redis-XXXXXXXX:6379
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,9 @@ MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
|
|||||||
###< symfony/messenger ###
|
###< symfony/messenger ###
|
||||||
|
|
||||||
###> Redis ###
|
###> Redis ###
|
||||||
REDIS_URL=redis://redis:6379
|
# retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بیسروصدا دوباره برقرار میشود
|
||||||
|
# و «Connection lost» بهصورت warning در app_log نمینشیند.
|
||||||
|
REDIS_URL=redis://redis:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60
|
||||||
###< Redis ###
|
###< Redis ###
|
||||||
|
|
||||||
###> Auth ###
|
###> Auth ###
|
||||||
|
|||||||
+3
-1
@@ -22,7 +22,9 @@ JWT_PASSPHRASE= # openssl rand -hex 32 (JWT keypair is generated with i
|
|||||||
# put both + this app on the SAME private network, then copy their private hosts here.
|
# put both + this app on the SAME private network, then copy their private hosts here.
|
||||||
# serverVersion MUST match the MariaDB service (11.8).
|
# serverVersion MUST match the MariaDB service (11.8).
|
||||||
DATABASE_URL="mysql://<user>:<pass>@<db-private-host>:3306/<db>?serverVersion=mariadb-11.8.0&charset=utf8mb4"
|
DATABASE_URL="mysql://<user>:<pass>@<db-private-host>:3306/<db>?serverVersion=mariadb-11.8.0&charset=utf8mb4"
|
||||||
REDIS_URL="redis://<redis-private-host>:6379"
|
# retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بیسروصدا دوباره برقرار میشود
|
||||||
|
# و «Connection lost» بهصورت warning در app_log نمینشیند.
|
||||||
|
REDIS_URL="redis://<redis-private-host>:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60"
|
||||||
MESSENGER_TRANSPORT_DSN="redis://<redis-private-host>:6379/messages"
|
MESSENGER_TRANSPORT_DSN="redis://<redis-private-host>:6379/messages"
|
||||||
# If the Redis service has a password: redis://:<pass>@<redis-private-host>:6379
|
# If the Redis service has a password: redis://:<pass>@<redis-private-host>:6379
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,15 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
|
|||||||
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
|
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمیدهد. */
|
/**
|
||||||
|
* درصد پوششِ قابل ثبت: عددی بین ۰ تا ۱۰۰.
|
||||||
|
*
|
||||||
|
* صفر مقدار معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمیدهد» — سهم بیمار
|
||||||
|
* صددرصد. آنچه رد میشود خالیماندنِ فیلد است، نه صفر بودنش.
|
||||||
|
*/
|
||||||
export function isValidPercent(raw?: string): boolean {
|
export function isValidPercent(raw?: string): boolean {
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100;
|
return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 && n <= 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface SessionCardData {
|
|||||||
base_insurance_discount_percent?: number;
|
base_insurance_discount_percent?: number;
|
||||||
supplementary_discount_percent?: number;
|
supplementary_discount_percent?: number;
|
||||||
doctor_name?: string | null;
|
doctor_name?: string | null;
|
||||||
|
/** پزشکِ نوبتِ این مراجعه — محدودهٔ قرارداد بیمه از روی او حل میشود. */
|
||||||
|
doctor_uuid?: string | null;
|
||||||
final_price_rials?: number;
|
final_price_rials?: number;
|
||||||
// تفکیک بیمه — سرور محاسبه میکند (PatientSession::applyShares)
|
// تفکیک بیمه — سرور محاسبه میکند (PatientSession::applyShares)
|
||||||
gross_total_rials?: number;
|
gross_total_rials?: number;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface AppointmentLike {
|
|||||||
insurance_service_category?: string | null;
|
insurance_service_category?: string | null;
|
||||||
insurance_base_id?: number | null;
|
insurance_base_id?: number | null;
|
||||||
insurance_supplementary_id?: number | null;
|
insurance_supplementary_id?: number | null;
|
||||||
|
doctor?: { uuid?: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -159,7 +160,7 @@ export default function ConfirmAppointmentModal({
|
|||||||
const appt: AppointmentLike | null = detail ?? appointment ?? null;
|
const appt: AppointmentLike | null = detail ?? appointment ?? null;
|
||||||
|
|
||||||
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
||||||
const insurance = useAppointmentInsurance(open);
|
const insurance = useAppointmentInsurance(open, appt?.doctor?.uuid ?? null);
|
||||||
|
|
||||||
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را میگیرد؛
|
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را میگیرد؛
|
||||||
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان میدهد و مبلغ ثبتشده فرق میکند.
|
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان میدهد و مبلغ ثبتشده فرق میکند.
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ describe('TurnsTimeline', () => {
|
|||||||
|
|
||||||
it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان میدهد', async () => {
|
it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان میدهد', async () => {
|
||||||
(api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) =>
|
(api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) =>
|
||||||
url === '/api/v1/billing/tenant-insurances'
|
// با پزشکِ نوبت، آدرس `?doctor_uuid=…&inherit=1` هم میگیرد.
|
||||||
|
url.startsWith('/api/v1/billing/tenant-insurances')
|
||||||
? Promise.resolve({ success: true, data: { data: [{
|
? Promise.resolve({ success: true, data: { data: [{
|
||||||
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
|
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
|
||||||
coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
|
coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function OccupiedCard({
|
|||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
// نام بیمه فقط با نگاشت از قراردادهای کششده به دست میآید؛ payload نوبت نامی ندارد
|
// نام بیمه فقط با نگاشت از قراردادهای کششده به دست میآید؛ payload نوبت نامی ندارد
|
||||||
// تا لیستهای نوبت به N+1 نیفتند.
|
// تا لیستهای نوبت به N+1 نیفتند.
|
||||||
const insurance = useAppointmentInsurance(!!a.insurance_base_id);
|
const insurance = useAppointmentInsurance(!!a.insurance_base_id, a.doctor_uuid ?? null);
|
||||||
const insuranceChip = [
|
const insuranceChip = [
|
||||||
a.insurance_service_category_label,
|
a.insurance_service_category_label,
|
||||||
insurance.insuranceNameOf(a.insurance_base_id),
|
insurance.insuranceNameOf(a.insurance_base_id),
|
||||||
|
|||||||
@@ -105,11 +105,20 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
|||||||
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
|
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
|
||||||
queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'),
|
queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'),
|
||||||
});
|
});
|
||||||
|
// مراجعهٔ برخاسته از نوبت، قراردادِ پزشکِ همان نوبت را دارد نه قراردادِ محیط پنل:
|
||||||
|
// در کلینیک، قرارداد روی خودِ پزشک ثبت میشود و بدون این محدوده، فهرست بیمهها
|
||||||
|
// خالی میآمد و بیمهٔ ثبتشدهٔ همان مراجعه هم در فرم پیدا نمیشد.
|
||||||
|
const insuranceScope = editSession?.doctor_uuid
|
||||||
|
? `?doctor_uuid=${encodeURIComponent(editSession.doctor_uuid)}&inherit=1`
|
||||||
|
: '';
|
||||||
|
|
||||||
const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({
|
const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({
|
||||||
queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
queryKey: ['tenant-insurances', editSession?.doctor_uuid ?? null],
|
||||||
|
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${insuranceScope}`),
|
||||||
});
|
});
|
||||||
const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
|
const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
|
||||||
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'),
|
queryKey: ['insurance-pricing', editSession?.doctor_uuid ?? null],
|
||||||
|
queryFn: () => api.get(`/api/v1/insurance-pricing${insuranceScope}`),
|
||||||
});
|
});
|
||||||
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
|
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
|
||||||
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
|
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
|
||||||
|
|||||||
@@ -22,16 +22,23 @@ interface PricingPayload {
|
|||||||
* بههمراه محاسبهٔ سهم — مشترک بین مودال «قطعی کردن نوبت» و صفحهٔ ویرایش نوبت تا
|
* بههمراه محاسبهٔ سهم — مشترک بین مودال «قطعی کردن نوبت» و صفحهٔ ویرایش نوبت تا
|
||||||
* هر دو یک قاعده را نشان دهند.
|
* هر دو یک قاعده را نشان دهند.
|
||||||
*/
|
*/
|
||||||
export function useAppointmentInsurance(enabled: boolean) {
|
export function useAppointmentInsurance(enabled: boolean, doctorUuid?: string | null) {
|
||||||
|
// بدون پزشک، محیطِ خودِ کاربر پرسیده میشود — همان رفتار قبلی برای مطب شخصی.
|
||||||
|
//
|
||||||
|
// با پزشک، `inherit=1` هم میرود: نوبتِ ثبتشده در کلینیک محیطش «کلینیک» است ولی
|
||||||
|
// قرارداد بیمه معمولاً روی خودِ پزشک ذخیره شده. سرور اول تنظیم پزشک را میدهد و
|
||||||
|
// در نبودش تنظیم کلینیک را — دقیقاً همان چیزی که سرِ قطعیکردن اعمال میشود.
|
||||||
|
const scopeQuery = doctorUuid ? `?doctor_uuid=${encodeURIComponent(doctorUuid)}&inherit=1` : '';
|
||||||
|
|
||||||
const pricingQuery = useQuery<ApiResponse<PricingPayload>>({
|
const pricingQuery = useQuery<ApiResponse<PricingPayload>>({
|
||||||
queryKey: ['insurance-pricing'],
|
queryKey: ['insurance-pricing', doctorUuid ?? null],
|
||||||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
queryFn: () => api.get(`/api/v1/insurance-pricing${scopeQuery}`),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contractsQuery = useQuery<ApiResponse<{ data: TenantContract[] }>>({
|
const contractsQuery = useQuery<ApiResponse<{ data: TenantContract[] }>>({
|
||||||
queryKey: ['tenant-insurances'],
|
queryKey: ['tenant-insurances', doctorUuid ?? null],
|
||||||
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${scopeQuery}`),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { makeClient } from '../test/utils';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useSubscription } from './useSubscription';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const initial = useAuthStore.getInitialState();
|
||||||
|
|
||||||
|
function planResponse(name: string) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
subscription: null,
|
||||||
|
used_trial: false,
|
||||||
|
effective_plan: { name, features: { patient_records: name !== 'free' }, max_secretaries: 1, max_resources: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAuthStore.setState(initial, true);
|
||||||
|
get.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSubscription', () => {
|
||||||
|
/**
|
||||||
|
* پزشکی که هم مطب شخصی دارد و هم کلینیک، دو محیط دارد و اشتراک روی محیط مینشیند.
|
||||||
|
* با کلیدِ بدون محیط، پاسخِ cache شدهٔ محیط قبلی در محیط تازه سرو میشد و هر دو
|
||||||
|
* محیط ارتقایافته بهنظر میرسیدند.
|
||||||
|
*/
|
||||||
|
it('پاسخِ cache شدهٔ محیط دیگر را سرو نمیکند', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// محیط کلینیک از قبل در cache نشسته است.
|
||||||
|
client.setQueryData(['subscription-my', 'clinic-1'], planResponse('professional'));
|
||||||
|
|
||||||
|
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doctor-1' });
|
||||||
|
get.mockResolvedValue(planResponse('free'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSubscription(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.planLoaded).toBe(true));
|
||||||
|
expect(result.current.hasFeature('patient_records')).toBe(false);
|
||||||
|
expect(get).toHaveBeenCalledWith('/api/v1/subscription/my');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('در همان محیط، پاسخِ cache شده دوباره درخواست نمیشود', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
client.setQueryData(['subscription-my', 'clinic-1'], planResponse('professional'));
|
||||||
|
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic-1' });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSubscription(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.hasFeature('patient_records')).toBe(true));
|
||||||
|
expect(get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,10 +6,14 @@ import type { MySubscriptionData } from '../types';
|
|||||||
|
|
||||||
export function useSubscription() {
|
export function useSubscription() {
|
||||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||||
|
const dbUuid = useAuthStore((s) => s.dbUuid);
|
||||||
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';
|
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';
|
||||||
|
|
||||||
|
// کلید شامل محیط فعال است: اشتراک روی محیط مینشیند، نه روی کاربر. پزشکی که هم
|
||||||
|
// مطب شخصی دارد و هم کلینیک، با کلیدِ بدون محیط پلنِ محیط قبلی را میدید و هر دو
|
||||||
|
// محیط ارتقایافته بهنظر میرسیدند.
|
||||||
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
|
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
|
||||||
queryKey: ['subscription-my'],
|
queryKey: ['subscription-my', dbUuid],
|
||||||
queryFn: () => api.get('/api/v1/subscription/my'),
|
queryFn: () => api.get('/api/v1/subscription/my'),
|
||||||
enabled,
|
enabled,
|
||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage';
|
|||||||
const get = api.get as ReturnType<typeof vi.fn>;
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||||
const post = api.post as ReturnType<typeof vi.fn>;
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
const del = api.delete as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{
|
{
|
||||||
@@ -213,7 +214,27 @@ describe('AdminSubscriptionPage — اعطای اشتراک', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('AdminSubscriptionPage — گزارش', () => {
|
describe('AdminSubscriptionPage — گزارش', () => {
|
||||||
beforeEach(() => { get.mockReset(); post.mockReset(); });
|
beforeEach(() => { get.mockReset(); post.mockReset(); del.mockReset(); });
|
||||||
|
|
||||||
|
/** یک ردیف گزارشِ اعطایی — تستهای حذف و ناوبری از همین استفاده میکنند. */
|
||||||
|
function mockReportRow() {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS });
|
||||||
|
if (url.includes('/admin/subscription/report')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
meta: { totalRecords: 1 },
|
||||||
|
data: [{
|
||||||
|
uuid: 's-1', entityType: 'clinic', entityId: 11, entityName: 'کلینیک نمونه',
|
||||||
|
isTrial: false, isGranted: true, grantedBy: 'ادمین',
|
||||||
|
startsAt: 1700000000, expiresAt: 1800000000, createdAt: 1700000000,
|
||||||
|
plan_name: 'professional', plan_level: 2,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('اشتراک اعطایی را «اعطایی» نشان میدهد، نه «پولی»', async () => {
|
it('اشتراک اعطایی را «اعطایی» نشان میدهد، نه «پولی»', async () => {
|
||||||
get.mockImplementation((url: string) => {
|
get.mockImplementation((url: string) => {
|
||||||
@@ -241,4 +262,27 @@ describe('AdminSubscriptionPage — گزارش', () => {
|
|||||||
expect(screen.getByText('ادمین')).toBeInTheDocument();
|
expect(screen.getByText('ادمین')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('پولی')).not.toBeInTheDocument();
|
expect(screen.queryByText('پولی')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('حذف اشتراک پس از تأیید، DELETE میفرستد', async () => {
|
||||||
|
mockReportRow();
|
||||||
|
del.mockResolvedValue({ success: true, data: null });
|
||||||
|
|
||||||
|
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||||
|
fireEvent.click(await screen.findByText('گزارش فروش'));
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByLabelText('حذف اشتراک کلینیک نمونه'));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'حذف' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(del).toHaveBeenCalledWith('/api/v1/admin/subscription/s-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دکمهٔ اعطای اشتراک از گزارش به تب اعطا میبرد', async () => {
|
||||||
|
mockReportRow();
|
||||||
|
|
||||||
|
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||||
|
fireEvent.click(await screen.findByText('گزارش فروش'));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /اعطای اشتراک جدید/ }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/پلن و دوره/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -626,8 +626,10 @@ function GrantTab() {
|
|||||||
|
|
||||||
// ── Report tab ────────────────────────────────────────────────────────────
|
// ── Report tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ReportTab() {
|
function ReportTab({ onAdd }: { onAdd: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [deleteRow, setDeleteRow] = useState<ReportRow | null>(null);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -635,11 +637,27 @@ function ReportTab() {
|
|||||||
queryFn: () => api.get<PaginatedResponse<ReportRow>>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`),
|
queryFn: () => api.get<PaginatedResponse<ReportRow>>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (uuid: string) => api.delete(`/api/v1/admin/subscription/${uuid}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-subscription-report'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-grant-active'] });
|
||||||
|
setDeleteRow(null);
|
||||||
|
toast.success('اشتراک حذف شد');
|
||||||
|
},
|
||||||
|
onError: (e: any) => { setDeleteRow(null); toast.error(e.message); },
|
||||||
|
});
|
||||||
|
|
||||||
const rows: ReportRow[] = data?.data ?? [];
|
const rows: ReportRow[] = data?.data ?? [];
|
||||||
const total = data?.meta?.totalRecords ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<div className="card-pad" style={{ display: 'flex', justifyContent: 'flex-end', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<button type="button" className="btn primary" onClick={onAdd}>
|
||||||
|
<PlusIcon style={{ width: 15 }} /> اعطای اشتراک جدید
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
@@ -655,6 +673,7 @@ function ReportTab() {
|
|||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>انقضا</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>انقضا</th>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -689,6 +708,17 @@ function ReportTab() {
|
|||||||
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بینهایت</span>}
|
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بینهایت</span>}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
|
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
|
||||||
|
<td style={{ padding: '10px 16px', textAlign: 'left' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn sm"
|
||||||
|
title="حذف اشتراک"
|
||||||
|
aria-label={`حذف اشتراک ${row.entityName ?? row.entityId}`}
|
||||||
|
onClick={() => setDeleteRow(row)}
|
||||||
|
>
|
||||||
|
<TrashIcon style={{ width: 13 }} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -698,6 +728,17 @@ function ReportTab() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteRow}
|
||||||
|
title="حذف اشتراک"
|
||||||
|
message={`آیا مطمئن هستید که میخواهید اشتراک «${deleteRow?.entityName ?? ''}» را حذف کنید؟ دسترسی این محیط به قابلیتهای پلن قطع میشود.`}
|
||||||
|
confirmLabel="حذف"
|
||||||
|
danger
|
||||||
|
loading={deleteMut.isPending}
|
||||||
|
onConfirm={() => deleteRow && deleteMut.mutate(deleteRow.uuid)}
|
||||||
|
onCancel={() => setDeleteRow(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -722,7 +763,7 @@ export default function AdminSubscriptionPage() {
|
|||||||
|
|
||||||
{tab === 'plans' && <PlansTab />}
|
{tab === 'plans' && <PlansTab />}
|
||||||
{tab === 'grant' && <GrantTab />}
|
{tab === 'grant' && <GrantTab />}
|
||||||
{tab === 'report' && <ReportTab />}
|
{tab === 'report' && <ReportTab onAdd={() => setTab('grant')} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export default function PaymentDetailPage() {
|
|||||||
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
|
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
|
||||||
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
||||||
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
||||||
|
<InfoRow
|
||||||
|
label="مبدأ"
|
||||||
|
value={payment.origin
|
||||||
|
? <span dir="ltr" title={payment.frontend_address ?? undefined}>{payment.origin}</span>
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
<InfoRow label="شماره مرجع" value={payment.ref_id ? <span dir="ltr" className="font-mono text-xs">{payment.ref_id}</span> : null} />
|
<InfoRow label="شماره مرجع" value={payment.ref_id ? <span dir="ltr" className="font-mono text-xs">{payment.ref_id}</span> : null} />
|
||||||
<InfoRow label="شماره کارت" value={payment.card_pan ? <span dir="ltr" className="font-mono text-xs">{payment.card_pan}</span> : null} />
|
<InfoRow label="شماره کارت" value={payment.card_pan ? <span dir="ltr" className="font-mono text-xs">{payment.card_pan}</span> : null} />
|
||||||
<InfoRow label="تاریخ پرداخت" value={formatDateTime(payment.paid_at)} />
|
<InfoRow label="تاریخ پرداخت" value={formatDateTime(payment.paid_at)} />
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
|
||||||
|
const navigate = vi.fn();
|
||||||
|
vi.mock('react-router', async (orig) => ({
|
||||||
|
...(await orig<typeof import('react-router')>()),
|
||||||
|
useNavigate: () => navigate,
|
||||||
|
}));
|
||||||
|
vi.mock('../lib/api', () => ({ api: { get: vi.fn() }, ApiError: class extends Error {} }));
|
||||||
|
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import PaymentsPage from './PaymentsPage';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
{ uuid: 'pay-1', amount: 1500000, status: 'success', gateway: 'mellat', ref_id: '99123',
|
||||||
|
origin: 'nobat724.com', patient_mobile: '09120000001', appointment_uuid: null,
|
||||||
|
paid_at: '2026-08-19T10:00:00+00:00', created_at: '2026-08-19T09:55:00+00:00' },
|
||||||
|
// پرداختهای قدیمی آدرس بازگشت ندارند و مبدأشان ناشناخته است.
|
||||||
|
{ uuid: 'pay-2', amount: 900000, status: 'failed', gateway: 'mellat', ref_id: null,
|
||||||
|
origin: null, patient_mobile: '09120000002', appointment_uuid: null,
|
||||||
|
paid_at: null, created_at: '2026-08-18T09:00:00+00:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigate.mockReset();
|
||||||
|
get.mockReset();
|
||||||
|
get.mockResolvedValue({ success: true, data: ROWS, meta: { totalRecords: 2, totalPages: 1, currentPage: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PaymentsPage (پرداختهای ادمین)', () => {
|
||||||
|
it('دامنهٔ مبدأ هر پرداخت را نشان میدهد', async () => {
|
||||||
|
renderWithProviders(<PaymentsPage />, { route: '/admin/payments' });
|
||||||
|
|
||||||
|
expect(await screen.findByText('nobat724.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('مبدأ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('جستجو دامنه را هم به سرور میفرستد', async () => {
|
||||||
|
renderWithProviders(<PaymentsPage />, { route: '/admin/payments?search=nobat724.com' });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(get).toHaveBeenCalledWith(expect.stringContaining('search=nobat724.com')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,6 +75,13 @@ export default function PaymentsPage() {
|
|||||||
header: 'درگاه',
|
header: 'درگاه',
|
||||||
render: (p) => <span className="chip" style={{ fontSize: 12 }}>{p.gateway}</span>,
|
render: (p) => <span className="chip" style={{ fontSize: 12 }}>{p.gateway}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'origin',
|
||||||
|
header: 'مبدأ',
|
||||||
|
render: (p) => p.origin
|
||||||
|
? <span className="chip" dir="ltr" style={{ fontSize: 12 }}>{p.origin}</span>
|
||||||
|
: <span className="muted">—</span>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'ref_id',
|
key: 'ref_id',
|
||||||
header: 'شماره مرجع',
|
header: 'شماره مرجع',
|
||||||
@@ -142,7 +149,7 @@ export default function PaymentsPage() {
|
|||||||
<div className="field" style={{ minWidth: 240 }}>
|
<div className="field" style={{ minWidth: 240 }}>
|
||||||
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
||||||
<input
|
<input
|
||||||
placeholder="جستجو بر اساس موبایل یا شماره مرجع..."
|
placeholder="جستجو بر اساس موبایل، شماره مرجع یا دامنه..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router';
|
||||||
|
import { makeClient } from '../test/utils';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import SelectContextPage from './SelectContextPage';
|
||||||
|
|
||||||
|
vi.mock('react-router', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-router')>('react-router');
|
||||||
|
return { ...actual, useNavigate: () => vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = useAuthStore.getInitialState();
|
||||||
|
|
||||||
|
const CONTEXTS = [
|
||||||
|
{ db_uuid: 'doc-1', db_key: 'k1', type: 'doctor', role: 'doctor', name: 'مطب شخصی' },
|
||||||
|
{ db_uuid: 'clinic-1', db_key: 'k2', type: 'clinic', role: 'clinic', name: 'کلینیک تست' },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAuthStore.setState(initial, true);
|
||||||
|
useAuthStore.setState({ availableContexts: CONTEXTS as any, switchContext: vi.fn() as any });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SelectContextPage', () => {
|
||||||
|
/**
|
||||||
|
* هر پاسخِ cache شده متعلق به محیط قبلی است. اشتراک روی محیط مینشیند، پس بدون
|
||||||
|
* پاک کردن cache، مطب شخصی پلن کلینیک را نشان میداد و برعکس.
|
||||||
|
*/
|
||||||
|
it('بعد از تعویض محیط، cache کوئریها را پاک میکند', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
client.setQueryData(['subscription-my', 'doc-1'], { stale: true });
|
||||||
|
|
||||||
|
render(<SelectContextPage />, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('کلینیک تست'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(client.getQueryData(['subscription-my', 'doc-1'])).toBeUndefined());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore, ContextItem } from '../stores/authStore';
|
import { useAuthStore, ContextItem } from '../stores/authStore';
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -18,11 +19,15 @@ const TYPE_ICONS: Record<string, string> = {
|
|||||||
export default function SelectContextPage() {
|
export default function SelectContextPage() {
|
||||||
const { availableContexts, switchContext } = useAuthStore();
|
const { availableContexts, switchContext } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSelect = async (ctx: ContextItem) => {
|
const handleSelect = async (ctx: ContextItem) => {
|
||||||
setLoading(ctx.db_uuid);
|
setLoading(ctx.db_uuid);
|
||||||
await switchContext(ctx.db_uuid);
|
await switchContext(ctx.db_uuid);
|
||||||
|
// هر پاسخِ cacheشده متعلق به محیط قبلی است — از اشتراک و پلن گرفته تا بیماران و
|
||||||
|
// نوبتها. بدون پاک کردن، محیط تازه داده و دسترسیهای محیط قبلی را نشان میدهد.
|
||||||
|
qc.clear();
|
||||||
navigate('/admin/dashboard', { replace: true });
|
navigate('/admin/dashboard', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,10 @@ export interface Payment {
|
|||||||
status: PaymentStatus;
|
status: PaymentStatus;
|
||||||
gateway: PaymentGateway;
|
gateway: PaymentGateway;
|
||||||
ref_id: string | null;
|
ref_id: string | null;
|
||||||
|
/** دامنهٔ مبدأ پرداخت (سایت نوبتدهی یا پنل)، از آدرس بازگشت استخراج میشود. */
|
||||||
|
origin: string | null;
|
||||||
|
/** آدرس بازگشتِ کامل؛ فقط در جزئیات میآید. */
|
||||||
|
frontend_address?: string | null;
|
||||||
card_pan?: string | null;
|
card_pan?: string | null;
|
||||||
refunds?: { amount: number; ref: string; at: number }[];
|
refunds?: { amount: number; ref: string; at: number }[];
|
||||||
patient_mobile: string;
|
patient_mobile: string;
|
||||||
|
|||||||
+7
-7
@@ -10,12 +10,12 @@
|
|||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-soap": "*",
|
"ext-soap": "*",
|
||||||
"altcha-org/altcha": "^2.0",
|
"altcha-org/altcha": "^2.1",
|
||||||
"doctrine/doctrine-bundle": ">=2.18.3",
|
"doctrine/doctrine-bundle": ">=2.19.0",
|
||||||
"doctrine/doctrine-migrations-bundle": "*",
|
"doctrine/doctrine-migrations-bundle": "*",
|
||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6.8",
|
||||||
"lexik/jwt-authentication-bundle": "*",
|
"lexik/jwt-authentication-bundle": "*",
|
||||||
"nelmio/api-doc-bundle": "*",
|
"nelmio/api-doc-bundle": ">=5.11.1",
|
||||||
"nelmio/cors-bundle": "*",
|
"nelmio/cors-bundle": "*",
|
||||||
"symfony/asset": "7.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/cache": "7.4.*",
|
"symfony/cache": "7.4.*",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"symfony/webpack-encore-bundle": "^2.4.1",
|
"symfony/webpack-encore-bundle": "^2.4.1",
|
||||||
"symfony/yaml": "7.4.*",
|
"symfony/yaml": "7.4.*",
|
||||||
"twig/twig": ">=3.28",
|
"twig/twig": ">=3.28",
|
||||||
"zircote/swagger-php": ">=6.4"
|
"zircote/swagger-php": ">=6.6"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -98,10 +98,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpstan/phpstan": "^2.2.5",
|
"phpstan/phpstan": "^2.2.8",
|
||||||
"phpstan/phpstan-doctrine": "^2.0.28",
|
"phpstan/phpstan-doctrine": "^2.0.28",
|
||||||
"phpstan/phpstan-symfony": "^2.0.20",
|
"phpstan/phpstan-symfony": "^2.0.20",
|
||||||
"phpunit/phpunit": "^12.5.31",
|
"phpunit/phpunit": "^12.5.33",
|
||||||
"symfony/browser-kit": "7.4.*",
|
"symfony/browser-kit": "7.4.*",
|
||||||
"symfony/css-selector": "7.4.*",
|
"symfony/css-selector": "7.4.*",
|
||||||
"symfony/debug-bundle": "7.4.*",
|
"symfony/debug-bundle": "7.4.*",
|
||||||
|
|||||||
Generated
+283
-283
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -654,7 +654,7 @@ List all payments.
|
|||||||
### Query Parameters (تکمیل)
|
### Query Parameters (تکمیل)
|
||||||
| Param | Type | Required | Description |
|
| Param | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id` یا `order_id` |
|
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id`، `order_id` یا دامنهٔ مبدأ |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
@@ -667,6 +667,7 @@ List all payments.
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"gateway": "mellat",
|
"gateway": "mellat",
|
||||||
"ref_id": "1234567",
|
"ref_id": "1234567",
|
||||||
|
"origin": "nobat724.com",
|
||||||
"patient_mobile": "0912...",
|
"patient_mobile": "0912...",
|
||||||
"paid_at": "2026-07-02T09:00:00+03:30",
|
"paid_at": "2026-07-02T09:00:00+03:30",
|
||||||
"created_at": "2026-07-02T08:55:00+03:30"
|
"created_at": "2026-07-02T08:55:00+03:30"
|
||||||
@@ -678,6 +679,11 @@ List all payments.
|
|||||||
|
|
||||||
> `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر اینصورت `null`. تاریخها ISO-8601.
|
> `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر اینصورت `null`. تاریخها ISO-8601.
|
||||||
|
|
||||||
|
> `origin` دامنهٔ مبدأ پرداخت است — از `frontend_address` (آدرس بازگشت) استخراج
|
||||||
|
> میشود، با حروف کوچک و بدون `www.`، تا یک دامنه در گزارش یک مقدار باشد. چند مبدأ
|
||||||
|
> به یک درگاه میروند: سایتهای نوبتدهی شهری و پنل خودِ کلینیکپرو. پرداخت بدون آدرس
|
||||||
|
> بازگشت `null` میگیرد.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### GET `/api/v1/admin/payments/{uuid}`
|
### GET `/api/v1/admin/payments/{uuid}`
|
||||||
@@ -698,6 +704,8 @@ List all payments.
|
|||||||
"gateway": "mellat",
|
"gateway": "mellat",
|
||||||
"type": "appointment",
|
"type": "appointment",
|
||||||
"ref_id": "1234567",
|
"ref_id": "1234567",
|
||||||
|
"origin": "nobat724.com",
|
||||||
|
"frontend_address": "https://nobat724.com/payment/result",
|
||||||
"card_pan": "502229******2928",
|
"card_pan": "502229******2928",
|
||||||
"patient_mobile": "0912...",
|
"patient_mobile": "0912...",
|
||||||
"patient_name": "علی احمدی",
|
"patient_name": "علی احمدی",
|
||||||
|
|||||||
+15
-1
@@ -459,7 +459,15 @@ Get appointment detail.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
> `doctor.specialties` آرایه (ممکن است خالی)؛ `address` اولین آدرس پزشک است (ممکن است `null` اگر پزشک آدرسی ندارد). `address.map.latitude/longitude` رشته یا `null`. تاریخها Unix.
|
|
||||||
|
> ℹ️ تغییر بیمهٔ نوبت با `PATCH` سهمهای مراجعهٔ همان نوبت را هم دوباره حساب میکند: پذیرش گاهی اول نوبت را قطعی میکند و بعد بیمه را اصلاح میکند، و بدون این، مراجعه روی محاسبهٔ اول میماند و صفحهٔ پرداخت سهمِ بیمه را از بیمار میخواهد. پرداختهای ثبتشده دست نمیخورند؛ فقط مبلغ قابلپرداخت اصلاح میشود. پاسخ `PATCH` هم مثل `GET` نشانیِ واقعیِ نوبت را برمیگرداند.
|
||||||
|
|
||||||
|
> ℹ️ `address` is the venue recorded on this appointment (`address_id`) — the doctor's own
|
||||||
|
> office or the clinic branch, whichever the booking was made at — and it is the only place
|
||||||
|
> `telephone` is returned. When that record carries no number, the clinic's own number takes
|
||||||
|
> its place. Public doctor and clinic responses never carry a phone number.
|
||||||
|
|
||||||
|
> `doctor.specialties` آرایه (ممکن است خالی)؛ `address` آدرسِ ثبتشدهٔ همین نوبت است و برای نوبتهای قدیمیِ بدون `address_id` ممکن است `null` باشد. `address.map.latitude/longitude` رشته یا `null`. تاریخها Unix.
|
||||||
|
|
||||||
### انتخاب بیمهٔ نوبت
|
### انتخاب بیمهٔ نوبت
|
||||||
|
|
||||||
@@ -547,6 +555,12 @@ Get all appointments for the authenticated user.
|
|||||||
|
|
||||||
**Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری.
|
**Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری.
|
||||||
|
|
||||||
|
> **فیلتر محیط اینجا اعمال نمیشود.** رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی
|
||||||
|
> مالکش از راه `user_id` تعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک،
|
||||||
|
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت.
|
||||||
|
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام میشود و
|
||||||
|
> مجوز دستنخورده باقی میماند.
|
||||||
|
|
||||||
> این اندپوینت `a.user = خودِ کاربر` را میدهد، یعنی نوبتهای خودِ فرد **بهعنوان
|
> این اندپوینت `a.user = خودِ کاربر` را میدهد، یعنی نوبتهای خودِ فرد **بهعنوان
|
||||||
> بیمار**، نه دادهٔ محیط. مصرفکنندهاش داشبورد بیمار در `nobat724_front` است.
|
> بیمار**، نه دادهٔ محیط. مصرفکنندهاش داشبورد بیمار در `nobat724_front` است.
|
||||||
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
|
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
|
||||||
|
|||||||
+7
-2
@@ -150,7 +150,7 @@ limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`.
|
|||||||
"linkedin": null
|
"linkedin": null
|
||||||
},
|
},
|
||||||
"caption": "توضیحات کلینیک",
|
"caption": "توضیحات کلینیک",
|
||||||
"list_bime": [],
|
"list_bime": [{ "uuid": "...", "id": "176", "name": "تامین اجتماعی", "logo_url": "/uploads/insurances/logo/2026-08/tamin.png" }],
|
||||||
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
|
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
|
||||||
"services": [],
|
"services": [],
|
||||||
"clinic_specialty": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
|
"clinic_specialty": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
|
||||||
@@ -167,6 +167,11 @@ limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `list_bime[].logo_url` مسیر نسبی روی همین API است (`/uploads/...`) و کلاینت باید آن را با دامنهٔ API کامل کند. بیمهٔ بدون لوگو `null` میگیرد، نه کلید غایب.
|
||||||
|
|
||||||
|
|
||||||
|
> 🔒 `phone`/`phone_number` are `null` unless the caller may edit the clinic (`can_edit: true`). The number is not public data; the patient sees the venue phone on their own appointment instead.
|
||||||
|
>
|
||||||
> `city`/`state`/`map`/`location`/`phone`/`phone_number` are all resolved from the clinic's **address** (`DoctorAddress` linked by `clinic_id`), not from columns on the clinic. `location` and `phone`/`phone_number` fall back to the deprecated `clinics.address` / `clinics.telephone` columns only when the address record has no value — reading them from different rows made one response describe two different places. Each is an array with a single object (or empty `[]` if the clinic has no address). `doctors` is a **count**; the actual doctor list comes from `GET /api/v1/clinic/doctor-list/{clinicUuid}` (`doctor_list` here is always `null`).
|
> `city`/`state`/`map`/`location`/`phone`/`phone_number` are all resolved from the clinic's **address** (`DoctorAddress` linked by `clinic_id`), not from columns on the clinic. `location` and `phone`/`phone_number` fall back to the deprecated `clinics.address` / `clinics.telephone` columns only when the address record has no value — reading them from different rows made one response describe two different places. Each is an array with a single object (or empty `[]` if the clinic has no address). `doctors` is a **count**; the actual doctor list comes from `GET /api/v1/clinic/doctor-list/{clinicUuid}` (`doctor_list` here is always `null`).
|
||||||
|
|
||||||
### معنای `is_active`
|
### معنای `is_active`
|
||||||
@@ -330,7 +335,7 @@ List clinics with pagination.
|
|||||||
| `doctors_count` | integer | Number of doctors linked to the clinic |
|
| `doctors_count` | integer | Number of doctors linked to the clinic |
|
||||||
| `city` | string\|null | City name, resolved from the clinic's address (`DoctorAddress`) |
|
| `city` | string\|null | City name, resolved from the clinic's address (`DoctorAddress`) |
|
||||||
| `state` | string\|null | Province name, resolved from the clinic's address (`DoctorAddress`) |
|
| `state` | string\|null | Province name, resolved from the clinic's address (`DoctorAddress`) |
|
||||||
| `phone` / `phone_number` | string\|null | Contact number from the clinic's address (`DoctorAddress`), falling back to the deprecated `clinics.telephone` column |
|
| `phone` / `phone_number` | string\|null | Contact number from the clinic's address (`DoctorAddress`), falling back to the deprecated `clinics.telephone` column. `null` for anyone who cannot edit the clinic, and always `null` in the public list. |
|
||||||
| `24_7` | boolean | Open 24/7 flag |
|
| `24_7` | boolean | Open 24/7 flag |
|
||||||
| `field_working_days` | string\|null | Working days/hours description |
|
| `field_working_days` | string\|null | Working days/hours description |
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -133,7 +133,7 @@ limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`.
|
|||||||
"address": [],
|
"address": [],
|
||||||
"state": [],
|
"state": [],
|
||||||
"city": [],
|
"city": [],
|
||||||
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }],
|
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": null }],
|
||||||
"representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
|
"representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +143,13 @@ limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`.
|
|||||||
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
||||||
>
|
>
|
||||||
> ℹ️ `representation` نمایندهی مالکِ پزشک است؛ برای پزشکِ بدون نماینده `null`.
|
> ℹ️ `representation` نمایندهی مالکِ پزشک است؛ برای پزشکِ بدون نماینده `null`.
|
||||||
|
>
|
||||||
|
> 🔒 **Phone numbers are not public.** `address[].telephone` and `clinics[].telephone`
|
||||||
|
> are `null` for anonymous callers and only carry a value when `can_edit` is `true`
|
||||||
|
> (the profile owner, its representative, or an admin). Street address and map
|
||||||
|
> coordinates stay public — a patient needs them to find the place. The venue phone
|
||||||
|
> reaches the patient through their own appointment (`GET /api/v1/appointments/user`),
|
||||||
|
> not through the public profile.
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
@@ -171,7 +178,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
|
|||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"title": "علی احمدی",
|
"title": "علی احمدی",
|
||||||
"specialties": [...],
|
"specialties": [...],
|
||||||
"clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }]
|
"clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "021..." }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -407,9 +407,16 @@ entity جاری از `#[CurrentUser]` resolve میشود: نقش `ROLE_DOCTOR
|
|||||||
| Param | Type | Required | Description |
|
| Param | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `doctor_uuid` | string (UUID) | ❌ | قیمتگذاری همان پزشک را برمیگرداند بهجای موجودیت کاربر جاری. برای تبهای نوبتدهی پنل کلینیک. |
|
| `doctor_uuid` | string (UUID) | ❌ | قیمتگذاری همان پزشک را برمیگرداند بهجای موجودیت کاربر جاری. برای تبهای نوبتدهی پنل کلینیک. |
|
||||||
|
| `inherit` | bool | ❌ | «برای نوبتِ این پزشک واقعاً چه قیمتی اعمال میشود؟» — اول قیمت خودِ پزشک، در نبودش قیمت کلینیکی که کاربر در آن ایستاده. مودال قطعیکردن نوبت آن را میفرستد؛ صفحهٔ تنظیمات نه، چون آنجا باید ردیفِ خودِ پزشک ویرایش شود. |
|
||||||
|
|
||||||
با `doctor_uuid`، دسترسی اینگونه بررسی میشود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دستنخورده است.
|
با `doctor_uuid`، دسترسی اینگونه بررسی میشود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دستنخورده است.
|
||||||
|
|
||||||
|
> ℹ️ ردیفهای قیمتگذاری با `doctor_uuid` بیرون از TenantFilter خوانده میشوند: مقصدِ این
|
||||||
|
> تنظیم پزشک است در حالی که محیط فعالِ مالکِ کلینیک، خودِ کلینیک است. مجوزش همان بررسی
|
||||||
|
> بالاست. تا پیش از این، خواندن به محیط کاربر محدود میشد و ردیف موجود دیده نمیشد —
|
||||||
|
> هر ذخیره یک ردیف تازه میساخت (قیدِ یکتا ردیفِ ویزیت آزاد را نمیگیرد چون `insurance_id`
|
||||||
|
> آنجا `NULL` است) و مقدار ذخیرهشده هرگز به پنل برنمیگشت.
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -513,11 +520,18 @@ entity جاری از `#[CurrentUser]` resolve میشود: نقش `ROLE_DOCTOR
|
|||||||
|
|
||||||
tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolver` resolve میشود — همان رزولور پروندهها و صورتحسابها، تا قرارداد بیمه و صورتحسابی که از آن ساخته میشود هرگز به دو محیط متفاوت نیفتند. محیط فعال (`UserActiveContext`) تعیینکننده است، نه صرفاً ترتیب نقشها؛ مالک کلینیکی که خودش پزشک هم هست، قراردادهای **کلینیک** خود را میبیند.
|
tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolver` resolve میشود — همان رزولور پروندهها و صورتحسابها، تا قرارداد بیمه و صورتحسابی که از آن ساخته میشود هرگز به دو محیط متفاوت نیفتند. محیط فعال (`UserActiveContext`) تعیینکننده است، نه صرفاً ترتیب نقشها؛ مالک کلینیکی که خودش پزشک هم هست، قراردادهای **کلینیک** خود را میبیند.
|
||||||
|
|
||||||
|
**محدودهٔ تنظیم — اول پزشک، بعد کلینیک:** نوبتی که در کلینیک ثبت میشود محیطش «کلینیک» است، ولی تنظیمات بیمه معمولاً روی خودِ پزشک ذخیره شدهاند. هنگام محاسبه — انتخاب بیمهٔ نوبت، نوع خدمت، و قیمت ویزیت — اول تنظیمِ خودِ پزشک خوانده میشود و فقط در نبودِ آن تنظیمِ کلینیک. هر سه مورد جدا سنجیده میشوند: پزشکی که قرارداد بیمهٔ خودش را دارد ولی قیمت ویزیت را به کلینیک سپرده، هرکدام را از جای درست میگیرد. مرجع: `App\Insurance\Service\InsuranceScopeResolver`.
|
||||||
|
|
||||||
|
**درصد صفر:** `0` مقدار معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمیدهد» (سهم بیمار صددرصد). آنچه رد میشود، خالیماندنِ درصدِ یک نوع خدمتِ فعال است؛ پیشفرض مرکزیِ صفر هم «تعییننشده» حساب میشود، نه انتخابِ صفر.
|
||||||
|
|
||||||
**تنظیمات per-doctor در کلینیک چندپزشکه:** درصد و شرایط هر بیمه میتواند برای هر پزشک متفاوت باشد. همهٔ اندپوینتهای زیر یک پارامتر اختیاری `doctor_uuid` میپذیرند (در `GET`/`DELETE` از query، در `POST`/`PATCH`/`PUT` از بدنه). با آن، قرارداد بهجای موجودیتِ tenantِ کاربر جاری، بهازای پزشک هدف (`entity_type='doctor'`) خوانده/نوشته میشود — دقیقاً مثل `insurance-pricing`. **بدون** آن، رفتار قبلی (tenant کاربر جاری) دستنخورده میماند (سازگاری عقبرو). دسترسی با `doctor_uuid` هم مثل `insurance-pricing` بررسی میشود: `ROLE_ADMIN`، خودِ پزشک، یا کاربرِ عضو/مالکِ کلینیکِ آن پزشک با مجوز `services.view` (برای نوشتن `services.update`)؛ در غیر این صورت `403 ERR_ACCESS_DENIED`، و پزشکِ ناموجود `404 ERR_NOT_FOUND_001`.
|
**تنظیمات per-doctor در کلینیک چندپزشکه:** درصد و شرایط هر بیمه میتواند برای هر پزشک متفاوت باشد. همهٔ اندپوینتهای زیر یک پارامتر اختیاری `doctor_uuid` میپذیرند (در `GET`/`DELETE` از query، در `POST`/`PATCH`/`PUT` از بدنه). با آن، قرارداد بهجای موجودیتِ tenantِ کاربر جاری، بهازای پزشک هدف (`entity_type='doctor'`) خوانده/نوشته میشود — دقیقاً مثل `insurance-pricing`. **بدون** آن، رفتار قبلی (tenant کاربر جاری) دستنخورده میماند (سازگاری عقبرو). دسترسی با `doctor_uuid` هم مثل `insurance-pricing` بررسی میشود: `ROLE_ADMIN`، خودِ پزشک، یا کاربرِ عضو/مالکِ کلینیکِ آن پزشک با مجوز `services.view` (برای نوشتن `services.update`)؛ در غیر این صورت `403 ERR_ACCESS_DENIED`، و پزشکِ ناموجود `404 ERR_NOT_FOUND_001`.
|
||||||
|
|
||||||
### GET `/api/v1/billing/tenant-insurances`
|
### GET `/api/v1/billing/tenant-insurances`
|
||||||
لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.
|
لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.
|
||||||
|
|
||||||
|
> پارامتر `inherit=1` همان قاعدهٔ محدودهٔ بیمه را اعمال میکند: اول قراردادهای خودِ پزشک، و اگر پزشک هیچ قراردادی نداشته باشد قراردادهای کلینیکی که کاربر در آن ایستاده. بدون این پارامتر، پاسخ دقیقاً همان محیطِ هدف است — چیزی که صفحهٔ تنظیمات برای ویرایش لازم دارد.
|
||||||
|
|
||||||
|
|
||||||
**Query:** `doctor_uuid` (اختیاری) — قراردادهای همان پزشک را برمیگرداند (نگاه کنید به «تنظیمات per-doctor» بالا).
|
**Query:** `doctor_uuid` (اختیاری) — قراردادهای همان پزشک را برمیگرداند (نگاه کنید به «تنظیمات per-doctor» بالا).
|
||||||
|
|
||||||
**Permission:** `AUTH` (doctor/clinic)
|
**Permission:** `AUTH` (doctor/clinic)
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ List the **authenticated user's own** payments (derived from the token — there
|
|||||||
|
|
||||||
**Permission:** `IS_AUTHENTICATED_FULLY`
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||||||
|
|
||||||
|
> **فیلتر محیط اینجا اعمال نمیشود.** رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی
|
||||||
|
> مالکش از راه `user_id` تعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک،
|
||||||
|
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت.
|
||||||
|
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام میشود و
|
||||||
|
> مجوز دستنخورده باقی میماند.
|
||||||
|
|
||||||
### Query Parameters
|
### Query Parameters
|
||||||
| Param | Type | Default | Description |
|
| Param | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
@@ -405,6 +411,12 @@ Get payment status and details.
|
|||||||
|
|
||||||
**Permission:** `AUTH` — must be the payment owner or `ROLE_ADMIN`
|
**Permission:** `AUTH` — must be the payment owner or `ROLE_ADMIN`
|
||||||
|
|
||||||
|
> **فیلتر محیط اینجا اعمال نمیشود.** رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی
|
||||||
|
> مالکش از راه `user_id` تعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک،
|
||||||
|
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت.
|
||||||
|
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام میشود و
|
||||||
|
> مجوز دستنخورده باقی میماند.
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ Delete a representation.
|
|||||||
|
|
||||||
Get monthly earnings dashboard for a representation.
|
Get monthly earnings dashboard for a representation.
|
||||||
|
|
||||||
|
`total_appointments` فقط نوبتهای **آنلاین** را میشمارد — یعنی نوبتهایی که از سایتِ همین نماینده رزرو شدهاند (`appointments.booking_representation_id` برابر همین نماینده). نوبتی که منشی در پنل ثبت میکند از سایت نیامده و در آمار نماینده نمیآید.
|
||||||
|
|
||||||
**Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN`
|
**Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN`
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
@@ -264,15 +266,33 @@ Get yearly earnings dashboard for a representation.
|
|||||||
## قانون کمیسیون دامنهمحور
|
## قانون کمیسیون دامنهمحور
|
||||||
|
|
||||||
کمیسیون (نوبت **و** اشتراک) فقط وقتی ثبت میشود که **هر دو** شرط برقرار باشد:
|
کمیسیون (نوبت **و** اشتراک) فقط وقتی ثبت میشود که **هر دو** شرط برقرار باشد:
|
||||||
1. دامنهی مبدأ خرید (`payment.frontend_address`) متعلق به یک نمایندهی فعال باشد (`representations.domain`).
|
1. دامنهی مبدأ خرید (`payment.frontend_address`) به یک نمایندهی فعال برسد.
|
||||||
2. پزشک/کلینیکِ موضوع خرید، `representation_id` همان نماینده را داشته باشد.
|
2. پزشک/کلینیکِ موضوع خرید، `representation_id` همان نماینده را داشته باشد.
|
||||||
|
|
||||||
|
دامنه به نماینده به این ترتیب میرسد:
|
||||||
|
|
||||||
|
- نمایندهای که همان دامنه را در `representations.domain` ثبت کرده (نمایندهی سراسری).
|
||||||
|
- وگرنه اگر دامنه، دامنهی یک شهر باشد (`cities.domain`)، نمایندهی فعالِ همان شهر از `representation_cities`.
|
||||||
|
|
||||||
|
اگر دو نمایندهی فعال یک شهر را پوشش دهند، نمایندهای انتخاب نمیشود: انتساب پول مبهم است و باید در داده صریح شود.
|
||||||
|
|
||||||
در غیر این صورت هیچ کمیسیونی برای هیچ نمایندهای ثبت نمیشود (پرداخت بدون `frontend_address` هم کمیسیون ندارد). درصد: نوبت = `commission_percent` نماینده؛ اشتراک = تنظیم سراسری `upgrade_commission_percent`. نگاشت دامنه فقط از طریق `DomainContextResolver` انجام میشود.
|
در غیر این صورت هیچ کمیسیونی برای هیچ نمایندهای ثبت نمیشود (پرداخت بدون `frontend_address` هم کمیسیون ندارد). درصد: نوبت = `commission_percent` نماینده؛ اشتراک = تنظیم سراسری `upgrade_commission_percent`. نگاشت دامنه فقط از طریق `DomainContextResolver` انجام میشود.
|
||||||
|
|
||||||
|
**زمان ثبت:** کمیسیون نوبت در لحظهی **پرداخت موفق** ثبت میشود، نه در لحظهی تأیید نوبت. نوبتِ `pending` هم کمیسیون دارد؛ تأیید کارِ پزشک/منشی است و ممکن است هرگز انجام نشود. ثبت idempotent است و مسیر تأیید دوباره چیزی نمیسازد.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## پنل نماینده (ROLE_REPRESENTATION)
|
## پنل نماینده (ROLE_REPRESENTATION)
|
||||||
|
|
||||||
|
> **شمارش نوبت:** `dashboard/summary` و `doctors/performance` هم فقط نوبتهای آنلاینِ همین نماینده را میشمارند (`appointments.booking_representation_id`). نوبتی که منشی در پنل ثبت میکند شمرده نمیشود. ستون درآمدِ هر پزشک هم فقط سهم همین نماینده است، نه سهم نمایندگان قبلیِ آن پزشک.
|
||||||
|
>
|
||||||
|
> **بازسازی گذشته:** نوبتهای آنلاینی که پیش از نگاشت دامنهی شهری پرداخت شدهاند نه `booking_representation_id` دارند و نه ردیف `FinancialBreakdown`. دستور زیر هر دو را از روی `payments.frontend_address` میسازد؛ بدون `--force` فقط گزارش میدهد و تاریخ ردیف مالی روی لحظهی پرداخت مینشیند، نه لحظهی اجرا:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> php bin/console app:representation:backfill-online-commission [--force]
|
||||||
|
> ```
|
||||||
|
|
||||||
|
|
||||||
این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین میشود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمیشود.
|
این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین میشود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمیشود.
|
||||||
|
|
||||||
> **Permission (همهی این بخش):** `ROLE_REPRESENTATION`
|
> **Permission (همهی این بخش):** `ROLE_REPRESENTATION`
|
||||||
|
|||||||
@@ -82,7 +82,13 @@
|
|||||||
|
|
||||||
**Permission:** `IS_AUTHENTICATED_FULLY`
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||||||
|
|
||||||
**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار میکند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا میکند و اشتراک همان entity برگردانده میشود.
|
**محیط اشتراک:** همان محیطی که کاربر **صاحبش** است، از `EntityContextResolver::ownedEntity()` — همان مرجعی که خرید اشتراک هم استفاده میکند، تا نمایش و پرداخت و اعطای ادمین روی یک محیط بنشینند.
|
||||||
|
|
||||||
|
کاربری که هم پزشک است و هم مالک کلینیک، دو محیط صاحبشده دارد. آنجا محیط فعال
|
||||||
|
(`UserActiveContext`) تعیین میکند اشتراک کدامیک خوانده شود. بدون محیط فعال، مطب
|
||||||
|
شخصی پیشفرض است.
|
||||||
|
|
||||||
|
**نکته:** این endpoint برای `ROLE_SECRETARY` هم کار میکند. منشی محیط صاحبشده ندارد، پس محیط فعالش خوانده میشود و اشتراک همان entity برمیگردد.
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
```json
|
```json
|
||||||
@@ -340,6 +346,35 @@ callback مشترک همهٔ درگاهها و همهٔ نوعهای پر
|
|||||||
> پلن. پس اعطای پلنی پایینتر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش میدهد. پنل
|
> پلن. پس اعطای پلنی پایینتر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش میدهد. پنل
|
||||||
> ادمین قبل از ثبت این حالت تأیید میگیرد؛ خودِ endpoint جلوی آن را نمیگیرد.
|
> ادمین قبل از ثبت این حالت تأیید میگیرد؛ خودِ endpoint جلوی آن را نمیگیرد.
|
||||||
|
|
||||||
|
### DELETE /api/v1/admin/subscription/{uuid}
|
||||||
|
**Permission:** `ROLE_ADMIN` — حذف اشتراک، برای برگرداندن اعطای اشتباه
|
||||||
|
|
||||||
|
`uuid` همان `uuid` ردیف گزارش است. حذف سخت است، نه soft delete: رکورد از
|
||||||
|
`clinic_subscriptions` پاک میشود و پلن مؤثر مقصد به اشتراک فعال بعدی یا به `free`
|
||||||
|
برمیگردد.
|
||||||
|
|
||||||
|
اشتراکِ متصل به پرداخت حذف نمیشود. سند مالیاش باید بماند و مسیر درست آن استرداد
|
||||||
|
وجه است.
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| وضعیت | کد | حالت |
|
||||||
|
|-------|----|------|
|
||||||
|
| 404 | ERR_SUBSCRIPTION_NOT_FOUND | uuid یافت نشد |
|
||||||
|
| 409 | ERR_CONFLICT_001 | اشتراک به پرداخت متصل است |
|
||||||
|
| 401 | ERR_AUTH_001 | بدون توکن |
|
||||||
|
| 403 | — | توکن معتبر ولی بدون `ROLE_ADMIN` |
|
||||||
|
|
||||||
|
> مسیر `DELETE /api/v1/admin/subscription/period/{uuid}` جداست و دورهٔ پلن را
|
||||||
|
> غیرفعال میکند، نه اشتراکِ یک مقصد را.
|
||||||
|
|
||||||
### GET /api/v1/admin/subscription/active/{entityType}/{entityUuid}
|
### GET /api/v1/admin/subscription/active/{entityType}/{entityUuid}
|
||||||
**Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا
|
**Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,30 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیرهشده
|
|||||||
| SQL خام DBAL | ❌ |
|
| SQL خام DBAL | ❌ |
|
||||||
| فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن |
|
| فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن |
|
||||||
|
|
||||||
|
### استثنای مجاز: دادهٔ «مالِ خودِ کاربر»
|
||||||
|
|
||||||
|
چند رکورد در محیطِ یک مطب ثبت میشوند ولی مالکشان بیمار است، نه آن مطب: پرداختِ نوبت و
|
||||||
|
خودِ نوبتِ بیمار. مجوزشان با `user_id` بررسی میشود، نه با محیط.
|
||||||
|
|
||||||
|
اینجا فیلتر چیزی به امنیت اضافه نمیکند و فقط ضرر میزند: کاربری که خودش صاحب محیط
|
||||||
|
دیگری است — پزشکی که از مطب دیگری نوبت میگیرد، منشیای که جایی بیمار است — رکورد
|
||||||
|
خودش را نمیدید و صفحهٔ نتیجهٔ پرداخت ۴۰۴ میشد.
|
||||||
|
|
||||||
|
تنها راه مجاز دورزدن، `App\Shared\Tenant\TenantFilterScope::withoutFilter()` است.
|
||||||
|
عمداً یک کلاس جداست تا جاهای دورزدن قابل شمردن بمانند، و فیلتر را در `finally`
|
||||||
|
برمیگرداند تا بقیهٔ همان درخواست دوباره محدود شود.
|
||||||
|
|
||||||
|
مصرفکنندگان فعلی — هر سه کاربرمحور، نه محیطمحور:
|
||||||
|
|
||||||
|
| اندپوینت | چرا |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/payment/{uuid}` | پرداختکننده باید پرداخت خودش را ببیند |
|
||||||
|
| `GET /api/v1/my/payments` | همان، به شکل فهرست |
|
||||||
|
| `GET /api/v1/appointments/user` | نوبتهای خودِ فرد بهعنوان بیمار |
|
||||||
|
|
||||||
|
`PayerSeesOwnPaymentTest` هر سه را میبندد و همزمان تأیید میکند که پرداختِ کاربر
|
||||||
|
دیگر همچنان ۴۰۳ میگیرد.
|
||||||
|
|
||||||
**فیلتر جایگزین authorization نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` سر جایشان میمانند: آنها «چه کاری مجاز است» را جواب میدهند، فیلتر فقط «کدام ردیفها».
|
**فیلتر جایگزین authorization نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` سر جایشان میمانند: آنها «چه کاری مجاز است» را جواب میدهند، فیلتر فقط «کدام ردیفها».
|
||||||
|
|
||||||
### تغییر رفتار: ۴۰۴ بهجای ۴۰۳
|
### تغییر رفتار: ۴۰۴ بهجای ۴۰۳
|
||||||
|
|||||||
@@ -1535,5 +1535,42 @@
|
|||||||
"1533": "Community 1533",
|
"1533": "Community 1533",
|
||||||
"1534": "Community 1534",
|
"1534": "Community 1534",
|
||||||
"1535": "Community 1535",
|
"1535": "Community 1535",
|
||||||
"1536": "Community 1536"
|
"1536": "Community 1536",
|
||||||
|
"1537": "Community 1537",
|
||||||
|
"1538": "Community 1538",
|
||||||
|
"1539": "Community 1539",
|
||||||
|
"1540": "Community 1540",
|
||||||
|
"1541": "Community 1541",
|
||||||
|
"1542": "Community 1542",
|
||||||
|
"1543": "Community 1543",
|
||||||
|
"1544": "Community 1544",
|
||||||
|
"1545": "Community 1545",
|
||||||
|
"1546": "Community 1546",
|
||||||
|
"1547": "Community 1547",
|
||||||
|
"1548": "Community 1548",
|
||||||
|
"1549": "Community 1549",
|
||||||
|
"1550": "Community 1550",
|
||||||
|
"1551": "Community 1551",
|
||||||
|
"1552": "Community 1552",
|
||||||
|
"1553": "Community 1553",
|
||||||
|
"1554": "Community 1554",
|
||||||
|
"1555": "Community 1555",
|
||||||
|
"1556": "Community 1556",
|
||||||
|
"1557": "Community 1557",
|
||||||
|
"1558": "Community 1558",
|
||||||
|
"1559": "Community 1559",
|
||||||
|
"1560": "Community 1560",
|
||||||
|
"1561": "Community 1561",
|
||||||
|
"1562": "Community 1562",
|
||||||
|
"1563": "Community 1563",
|
||||||
|
"1564": "Community 1564",
|
||||||
|
"1565": "Community 1565",
|
||||||
|
"1566": "Community 1566",
|
||||||
|
"1567": "Community 1567",
|
||||||
|
"1568": "Community 1568",
|
||||||
|
"1569": "Community 1569",
|
||||||
|
"1570": "Community 1570",
|
||||||
|
"1571": "Community 1571",
|
||||||
|
"1572": "Community 1572",
|
||||||
|
"1573": "Community 1573"
|
||||||
}
|
}
|
||||||
|
|||||||
+671
-523
File diff suppressed because it is too large
Load Diff
graphify-out/cache/ast/v0.8.44/01b63a0de55c5012246af8f415215e282024b99ad721411e44b22fc33759b904.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/024b5b668e6b08ee2ffab7d04fc1f42a86cd0cdb011860cf936ba30a589f08d9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/04006e3f3f8b9c7f2f33d25a575f68bfb824d3b4ad65eae66350a9ecc6af6eee.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/049887f378df22103e52ad2164bdb307b97a66fd4cdb5f3f6ba55a42adff021e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/05622d813b1af5875e0b16358db81273480eb72e103a66b246bfc63a0d1ee88a.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/05da88148a1b6f9033228c04a905ee09cbb61c682cde372ce999a16a103e8eec.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/09b36818cfdcdfc6af661f93e591dcf98b3a69c854c2dc2aa8c424c4110d9f4d.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/0b14e9e30faf94a2eea5ff3e5d2f3dacacd05b061b685cd0c92663102a87f3a2.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/0d8a03b4fa8d0d0c7c43242f61646ae97436a3ec2a562e8c7b3cdd8355494d43.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/0dfda763f44b6a57fa3696e575575886aeef00860fe3c5e75617608ca8d0c92c.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/0f58c9ff62fbd70b31b66e1bef38b78d97ab53ff80c5d184ef53baed8de2bb51.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/1472b4d3d8eb251c2beefcde2b2ddae572e8775daae1c6ca954ce745ee0aeee0.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/195240742225d313c07f80af50def2c3de9f29a94bca71d0a8d76c619a7d9f91.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "label": "reference.php", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1"}, {"id": "config_reference_app", "label": "App", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1571"}, {"id": "config_reference_app_config", "label": ".config()", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1578"}, {"id": "config_reference_routes", "label": "Routes", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1651"}, {"id": "config_reference_routes_config", "label": ".config()", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1658"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "target": "paramconfigurator", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L7", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "target": "config_reference_app", "relation": "contains", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1571", "weight": 1.0}, {"source": "config_reference_app", "target": "config_reference_app_config", "relation": "method", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1578", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "target": "config_reference_routes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1651", "weight": 1.0}, {"source": "config_reference_routes", "target": "config_reference_routes_config", "relation": "method", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1658", "weight": 1.0}], "raw_calls": [{"caller_nid": "config_reference_app_config", "callee": "AppReference", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/config/reference.php", "source_location": "L1581", "receiver": null}]}
|
||||||
graphify-out/cache/ast/v0.8.44/1a53100cdecc8d38a482eb85dd152fd9acc5f79d415b37c5adb050fa36740d79.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/1cb561d88ded300b155e235433c5a1e5deed1acce065d1443856c49af2f5cde5.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/2463950a550e7243167a1651b77bc8b2ab0cef0121f2d7b30d1676c1e9f83430.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/2553d064b5842522e0dc4e2a59ee0f7f15827d6b5b1dfa01dce382d3ec5b72e1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/2904fb34142e8d5f35d722ca8a108f3cf93c7047701cf2bc68f4bd01822af452.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/29f680610013c11b795a381f9e91b8d30f1d0bdf790f8685f27b8d227d0f276c.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/2b275dc8813c902b6310d1cfeb7433cccf7bacab7a81286bdb8253cf61815159.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/2c325baa9f0c7cea01b0ad71060eb7b1f0aa1567c9a1c5d770e2e10051154f18.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/2da4701aa95f219f0e0350b3f564ff0377392f3815c446f9be464cab3f9ff7fa.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/34d578835ba09816f07767a7cce6cf4116d2d6c72a984ad73ed1c5e1db6154ce.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/3716dd25913e1235f3a14212bd61f0647a9da2bca73fce12ae800cc0b86ae2c1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/39bd0796eef51b09b5a5015ab91c0d40fee6d77e8c86defcbd2def0789ed4d39.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/3a92c5b6cd8fb8faf62734dab803042be4dcd697a33d0533fb886f74ebbad9d1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/3d78baf9eed4b5ce77d1f02332165cf3ff515558ec3859eb32f020504bbffd2b.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/3ded4eb3d25a2a0e2a97f67c58f5ba54454f78606355fd3b1477061a4d3eb6c3.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/3e8c88204cd2cd1ec1395865e18d7d34fe547f6f55ae61c7b31f301c748b4dc2.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/3ee6dd039916e791bebf277b8588c9c72526a52711f024a0e38a9d7cc2f24ec1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/41a30cf4e79db78669677a0897799c069186ca3fff38d641834f32cfb0ccd72a.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/430febfd19f371de543b21a3f998eae3de344a20c91fa48b68172b7ee35629d9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/44a1b4140bf984e76344d3b4acda948ee60d35cb6d78ac9a7f70d300e6731e14.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/458121cf01a175fe6a6c8a0c5b2614ecc24fb335883afe9c59136352add8b791.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/48bd744fa6f42fa611841a8665db0e300da0209950a6ad7869374549a74f24f7.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/4c1f84509a6182c762fb56bd9c6b4efb3ef16d32cb19349de0f48f3d589ee279.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/4e7d37c60189820f57ed89cdb339447f8560ccbba56944a5c7c8a6e314837f36.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/4eb95f4df2f6f90d4aecd4c027aeec7174cda286990ba3d54fdbb243c01070fa.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/4f40487d076f06980f6828a54819ae76a7563925c58d7e67718f7593666762f0.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/4f588b1fdb5c29d3c426040f011d318eaa0103ef1865da2153959dc0cf078410.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/512392553ad01326d953730e964a07235fa9e81e2340c3198073545fc318e02f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5160d2a6cd07637e3b6c6c0376239e0abcefedfba59f0bff3ad132d4383a9884.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/525cd96ee40f649d93b4d7a3ad401ea3080863912084f1722109952fa672dd6c.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5269c6ae2cfc8b64da735ba3bd4a8533e81b102e0a3bc6a104bcf617fed6d089.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_landing_landingregistry_php", "label": "LandingRegistry.php", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L1"}, {"id": "landing_landingregistry_landingregistry", "label": "LandingRegistry", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L11"}, {"id": "landing_landingregistry_landingregistry_all", "label": ".all()", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L45"}, {"id": "landing_landingregistry_landingregistry_featured", "label": ".featured()", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L51"}, {"id": "landing_landingregistry_landingregistry_find", "label": ".find()", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L59"}, {"id": "landingpage", "label": "LandingPage", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L59"}, {"id": "landing_landingregistry_landingregistry_build", "label": ".build()", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L65"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_landing_landingregistry_php", "target": "landing_landingregistry_landingregistry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L11", "weight": 1.0}, {"source": "landing_landingregistry_landingregistry", "target": "landing_landingregistry_landingregistry_all", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L45", "weight": 1.0}, {"source": "landing_landingregistry_landingregistry", "target": "landing_landingregistry_landingregistry_featured", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L51", "weight": 1.0}, {"source": "landing_landingregistry_landingregistry", "target": "landing_landingregistry_landingregistry_find", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L59", "weight": 1.0}, {"source": "landing_landingregistry_landingregistry_find", "target": "landingpage", "relation": "references", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L59", "weight": 1.0, "context": "return_type"}, {"source": "landing_landingregistry_landingregistry", "target": "landing_landingregistry_landingregistry_build", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L65", "weight": 1.0}, {"source": "landing_landingregistry_landingregistry_all", "target": "landing_landingregistry_landingregistry_build", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L47", "weight": 1.0}, {"source": "landing_landingregistry_landingregistry_featured", "target": "landing_landingregistry_landingregistry_find", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L54", "weight": 1.0}], "raw_calls": [{"caller_nid": "landing_landingregistry_landingregistry_featured", "callee": "array_values", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Landing/LandingRegistry.php", "source_location": "L53", "receiver": null}, {"caller_nid": "landing_landingregistry_landingregistry_featured", "callee": "array_filter", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Landing/LandingRegistry.php", "source_location": "L53", "receiver": null}, {"caller_nid": "landing_landingregistry_landingregistry_featured", "callee": "array_map", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Landing/LandingRegistry.php", "source_location": "L53", "receiver": null}, {"caller_nid": "landing_landingregistry_landingregistry_build", "callee": "array_column", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Landing/LandingRegistry.php", "source_location": "L415", "receiver": null}, {"caller_nid": "landing_landingregistry_landingregistry_build", "callee": "array_map", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Landing/LandingRegistry.php", "source_location": "L416", "receiver": null}]}
|
||||||
graphify-out/cache/ast/v0.8.44/52f6217205b3a17ea94a68d51236a2ebe6a5a757fd5460df7c091c8c20122d61.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/569721fc586894965c8fdf214a0aa72d2e229a67435c92a1bbd5b452ba2918ab.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/570c5237bc10efa2ef08c79dd34512386dafbd5bf89f0aa51b5f16c1e48f3d67.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5841462095ca5316deba7c8809f25651cb647a91064af2793d929324cdc08d17.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5969ab02ef386bf027db1dabd367d8bfbb0785adb9532e2f7db3d703fcbb4553.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5b4d677804e39033cedce354d8869b4fc1d4b61ebde6754a38f6c2620f602c35.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5b584f4433682c7a8181e29c5c29b62a409d8ba783770624cc175b4952f67489.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5bde238a7966e3f7acf2ee09c0289aad8ca3db05f4f1567bf6bf7a985766433d.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5c8e247e2be9270215080b4320df87b25edf84b921ea6fe5127b81c8ab99cc09.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5e794860b5160fed84522cd250769442cf7bee1ca44ac4f6adb718b32195463f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/617db1135eb25774ed27ccdd7d462d3b7addc97569d0fb94a224c2be200bbcc1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/63b8faccc2d43dda0e81eec779514d3df314308e22f46e335aa61b247e344b74.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/63d3b3d74a0858655538eb5d61f5bded8b4a2345a3e66242d1bdd2c750050b18.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/644cb8c256e726c2991a2150dd56238b4596a2f280bc8d4f1df29de6a0079883.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/653601d4079c236bd0840413e14a6efc185471348b22aad3fd166e9d072ed26f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/6810be058474df84ea2ad32de5da1534391649a53c51df6e2b48ec7a3e7f789c.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/6af8a3ec8c17e66ebab72465d168adf0bdbd028e739b4b012cf3c1b50e56cb61.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/6c835ac88cd2b73435a3ac5696c372787bca0c07c1391468a137ec46abe6ccc6.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/6e8f6a71956a85b0a75194d2e46d4b5dd6e15267db9b09c9c4400d4af22c0642.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/70d9023cd44a2845ecfb3b940f784a51bc903fd35b86edcfa2d0450d10cc9644.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/70dc56d4890ec54bcfd377156b17e1f11a7a83fadea3627fca3c4f01556f4e21.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/718671fa45e63a7142c71befb0746ca633240629faba51d12562108b0b6f6922.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/72e7110ea44cfb7e410659dc613c5c9606d5eaa99b76fa86d96f7cd88fc518b7.json
Vendored
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user