Compare commits

14 Commits
Author SHA1 Message Date
hamedandClaude Opus 5 40b1da64be fix(seo): shorten the footer and center the related-solutions cards
Every landing page was listed in the footer, so adding keyword clusters made
that one column taller than the rest of the footer put together. It now lists
six — the head terms and the two biggest specialties — and the rest stay
reachable through the "other solutions" block each page already renders.

That trade only holds if nothing falls out of the link graph, so the old test
asserting "the footer links to every page" is replaced by one that walks the
related-page graph from the footer links and fails when any page is not
reachable. Physiotherapy was the one page that would have been orphaned; the
polyclinic page now links to it.

The related-solutions heading said "for your trade", which reads oddly for the
clinics it is addressing, and its cards were left-aligned in a centered
section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:26:59 +03:30
hamedandClaude Opus 5 8e4bb3ca7b feat(seo): add eight landing pages for the clusters we had nothing for
Keyword research put the head demand on four phrases — clinic management
software, doctor's office software, online booking, and the price/comparison
questions that precede a purchase — and we only had a page for one of them.
The new pages cover the head terms (clinic management, online booking), the
buying intent (price, how to choose, download), the positioning that separates
a hosted product from the installed ones that dominate these results (web
based, no install), and two long-tails where competition is thin (clinic
accounting, psychology practice).

The download page deliberately answers the intent rather than the wording:
someone searching for an installer wants to try something without paying, and
the page gives them that while explaining why there is nothing to install.

Copy is written per page rather than templated — eight pages saying the same
thing in different words is duplicate content, and every claim on them maps to
something the product actually does. Internal links from the older pages were
repointed at the new head-term pages, and the registry tests no longer hardcode
the page count: they now assert that every declared slug has a page, and that
titles, headings and descriptions stay unique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:16:58 +03:30
hamedandClaude Opus 5 6e3d574e38 fix(session): show and keep the doctor's insurance when editing an encounter
The encounter edit form asked for contracts in the panel's own environment, so
inside a clinic it got an empty list: no insurance to choose, and the one
already recorded on the encounter had no matching option to display. It now
scopes the request by the encounter's doctor, the same way the confirm modal
does, which is why the session payload's existing doctor_uuid is threaded
through.

Saving the form had the matching defect on the server. Recalculating the totals
looked up the coverage percentage against the record's tenant rather than the
contract's, so a single save on a correctly-billed encounter zeroed the
insurance share and moved the whole amount back onto the patient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:56:57 +03:30
hamedandClaude Opus 5 4f76cff503 fix(billing): bill the insurance share on clinic appointments
The invoice and the encounter belong to the clinic, but the contract sits on
the doctor, so every coverage lookup asked the clinic, got nothing, and
resolved to zero percent. Three failures followed from that one wrong tenant:
the encounter charged the patient the full amount, the payment page showed a
balance the insurer owed, and — because no line carried an insurance share —
no claim was ever built, leaving the claims page empty.

Coverage now resolves through the same doctor-first-then-clinic rule the rest
of the insurance settings use, both when the encounter is created and when its
invoice lines are built.

Changing the insurance on an appointment also re-runs the encounter's shares.
Reception routinely confirms first and corrects the insurance afterwards, and
until now those shares stayed frozen at whatever the first calculation said.
Recorded payments are untouched; only the payable amount moves, so an
overpayment simply clamps the remainder at zero. The PATCH response also
returns the appointment's real venue now, matching GET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:37:44 +03:30
hamedandClaude Opus 5 b24f45cc83 fix(insurance): read a doctor's own settings first, then their clinic's
A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.

Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.

Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:18:35 +03:30
hamedandClaude Opus 5 1f64b516d2 fix(insurance): let a clinic owner save the visit price of a member doctor
The pricing rows are keyed by the *target* tenant, but the tenant filter pins
every read to the environment of whoever is asking. A clinic owner setting the
free-visit price for one of their doctors was therefore blind to the row that
already existed: each save inserted another one — the unique key does not stop
it, because insurance_id is NULL for the free-visit row and MySQL does not treat
NULLs as equal — and the following read was blind in the same way, so the panel
kept showing the old value. From the outside it simply looked like the field
would not save.

Reads now run outside the filter, the same exception the tenant-insurance
repository already makes for the same reason, with authorization still coming
from resolveTargetEntity(). findOneForInsurance() takes the newest row so a
tenant that already accumulated duplicates converges on the last value the user
entered, and a migration collapses those leftovers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:52:04 +03:30
hamedandClaude Opus 5 5d2594ff87 feat(privacy): keep venue phone numbers out of every public response
A doctor's office number sat next to the address on the public profile and in
the anonymous API payload, so harvesting the phone number of every practice in
the country was one unauthenticated request away. Street address and map
coordinates stay public — a patient needs those to find the place — but the
phone is now opt-in per caller: DoctorAddress::toArray() and the clinic
serializers only emit it when told to, and the public doctor/clinic endpoints
tell them to only when the caller may edit that profile (the same can_edit they
already compute). Owner-facing address CRUD keeps returning it unchanged.

The patient still gets the number where it is actually useful — their own
appointment. That payload also stops guessing: it used to serialise the
doctor's *first* address, so a booking made at the clinic or at a second office
showed the wrong street entirely. It now resolves the address recorded on the
appointment itself, which works the same for a personal office and a clinic
branch, and falls back to the clinic's own number when the address has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:31:52 +03:30
hamedandClaude Opus 5 8a43297e24 test: remove the two real sources of full-run flakiness, and the mock notices
createUser() drew a random mobile and recovered from a collision by catching
the unique-constraint violation and calling resetManager(). That hands back a
brand-new EntityManager, which detaches every entity the running test had
built so far; its next flush died with "Multiple non-persisted new entities
were found", always in a different test and never reproducible in isolation.
The number is now checked before the insert, so the collision never reaches
the database and the manager stays open.

testParentIdAddsNoQueryPerSpecialty counted queries on the first request of
each size, so one-shot per-process caches — site config, subscription plan,
Doctrine metadata — landed inside the count or not depending on which tests
had run before it. Both requests are now warmed first; the assertion measures
steady-state growth, which is what it was always about.

The 23 PHPUnit notices were all one complaint: doubles created with
createMock() that never had an expectation. The ones that only stub return
values became createStub(); in SmsServiceLookupOnlyTest the provider and the
bus got the expectations they were missing, since "dispatch does not touch
the provider" and "sendNow does not enqueue" are exactly what that suite is
there to prove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:57:29 +03:30
hamedandClaude Opus 5 ed2d2ec74a fix(blog): order every blog list by id as well as created_at
created_at is a second-resolution integer, so dozens of posts routinely share
one value and MySQL is free to return tied rows in any order. Two consecutive
pages of the same list could hand back one post twice and never show another,
and the admin-list tests failed at random on whichever post got shuffled past
the page boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:43:11 +03:30
hamedandClaude Opus 5 5986fad5a1 fix(logs): resolve the five real defects surfaced by app_log
TenantInsurance reads ran through the tenant filter, which pins every query
to the *requesting* user's environment. A clinic owner managing a doctor's
contracts therefore read an empty set, recomputed version 1, and hit
`uniq_tenant_insurance_version` on insert. The reads now bypass the filter —
authorization is already established by resolveTargetEntity(), and the
uuid-based paths re-assert ownership after loading.

UserActiveContext::upsert() raced with itself: the panel fires several
/oauth/userinfo requests at once, all saw no row, all inserted, and the
losers died on a duplicate PRIMARY (closing the EntityManager with them).
Replaced with INSERT ... ON DUPLICATE KEY UPDATE.

A service that carries a treatment protocol but no catalog category is bad
catalog data, not a system failure; it was logged at error level on every
confirm and buried the real errors. Now a warning carrying the service id.

Kavenegar's HTTP 431 says only "malformed request". The provider's own
message and the token slot names are now logged so the template can actually
be fixed in the panel; token values stay out of the log.

Redis DSNs gained timeout/retry_interval/tcp_keepalive so a brief connection
loss reconnects quietly instead of logging a warning each time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:31:15 +03:30
hamed 57ea7b8c59 Refactor code structure for improved readability and maintainability 2026-08-18 08:03:40 +03:30
hamed 1d13212d47 Add AST cache for ApiLeastPrivilegeTest.php with nodes and edges 2026-08-11 09:40:37 +03:30
hamed 2459625c41 feat(tenant): implement tenant filter scope to manage cross-tenant data visibility 2026-08-10 16:22:00 +03:30
hamed ee2682e222 feat(landing): update meta titles for landing pages and enhance FAQ section layout 2026-08-10 15:01:40 +03:30
131 changed files with 25587 additions and 4420 deletions
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -2
View File
@@ -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),
+11 -2
View File
@@ -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;
+12 -5
View File
@@ -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,
}); });
+7 -7
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -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` است.
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص > آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
+3 -1
View File
@@ -167,6 +167,8 @@ limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`.
} }
``` ```
> 🔒 `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 +332,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
View File
@@ -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..." }]
} }
} }
} }
+14
View File
@@ -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)
+12
View File
@@ -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 |
|-------|------|-------------| |-------|------|-------------|
+24
View File
@@ -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` سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها».
### تغییر رفتار: ۴۰۴ به‌جای ۴۰۳ ### تغییر رفتار: ۴۰۴ به‌جای ۴۰۳
+5 -1
View File
@@ -1535,5 +1535,9 @@
"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"
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_tenant_tenantfilterscope_php", "label": "TenantFilterScope.php", "file_type": "code", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L1"}, {"id": "tenant_tenantfilterscope_tenantfilterscope", "label": "TenantFilterScope", "file_type": "code", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L16"}, {"id": "tenant_tenantfilterscope_tenantfilterscope_construct", "label": ".__construct()", "file_type": "code", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L21"}, {"id": "tenant_tenantfilterscope_tenantfilterscope_apply", "label": ".apply()", "file_type": "code", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L24"}, {"id": "tenant_tenantfilterscope_tenantfilterscope_withoutfilter", "label": ".withoutFilter()", "file_type": "code", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L45"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_tenant_tenantfilterscope_php", "target": "entitymanagerinterface", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L5", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_tenant_tenantfilterscope_php", "target": "tenant_tenantfilterscope_tenantfilterscope", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L16", "weight": 1.0}, {"source": "tenant_tenantfilterscope_tenantfilterscope", "target": "tenant_tenantfilterscope_tenantfilterscope_construct", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L21", "weight": 1.0}, {"source": "tenant_tenantfilterscope_tenantfilterscope", "target": "tenant_tenantfilterscope_tenantfilterscope_apply", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L24", "weight": 1.0}, {"source": "tenant_tenantfilterscope_tenantfilterscope", "target": "tenant_tenantfilterscope_tenantfilterscope_withoutfilter", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L45", "weight": 1.0}, {"source": "tenant_tenantfilterscope_tenantfilterscope_withoutfilter", "target": "tenant_tenantfilterscope_tenantfilterscope_apply", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "src/Shared/Tenant/TenantFilterScope.php", "source_location": "L59", "weight": 1.0}], "raw_calls": [{"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_apply", "callee": "setParameter", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L28", "receiver": null}, {"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_apply", "callee": "setParameter", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L28", "receiver": null}, {"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_apply", "callee": "enable", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L28", "receiver": null}, {"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_apply", "callee": "getFilters", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L28", "receiver": null}, {"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_withoutfilter", "callee": "getFilters", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L47", "receiver": null}, {"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_withoutfilter", "callee": "isEnabled", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L48", "receiver": null}, {"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_withoutfilter", "callee": "disable", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L51", "receiver": null}, {"caller_nid": "tenant_tenantfilterscope_tenantfilterscope_withoutfilter", "callee": "$query", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Tenant/TenantFilterScope.php", "source_location": "L55", "receiver": null}]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_landing_landingpage_php", "label": "LandingPage.php", "file_type": "code", "source_file": "src/Shared/Landing/LandingPage.php", "source_location": "L1"}, {"id": "landing_landingpage_landingpage", "label": "LandingPage", "file_type": "code", "source_file": "src/Shared/Landing/LandingPage.php", "source_location": "L11"}, {"id": "landing_landingpage_landingpage_construct", "label": ".__construct()", "file_type": "code", "source_file": "src/Shared/Landing/LandingPage.php", "source_location": "L19"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_landing_landingpage_php", "target": "landing_landingpage_landingpage", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingPage.php", "source_location": "L11", "weight": 1.0}, {"source": "landing_landingpage_landingpage", "target": "landing_landingpage_landingpage_construct", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingPage.php", "source_location": "L19", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_landing_pages_md", "label": "landing-pages.md", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L1"}, {"id": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "label": "\u0635\u0641\u062d\u0627\u062a \u0641\u0631\u0648\u062f \u0633\u0626\u0648\u06cc\u06cc", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L1"}, {"id": "docs_landing_pages_\u0645\u062a\u0646_\u0647\u0631_\u0635\u0641\u062d\u0647_\u06a9\u062c\u0627\u0633\u062a", "label": "\u0645\u062a\u0646 \u0647\u0631 \u0635\u0641\u062d\u0647 \u06a9\u062c\u0627\u0633\u062a", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L16"}, {"id": "docs_landing_pages_\u0686\u0631\u0627_\u0631\u062c\u06cc\u0633\u062a\u0631\u06cc_\u0646\u0647_\u0647\u0641\u062a_\u0641\u0627\u06cc\u0644_twig", "label": "\u0686\u0631\u0627 \u0631\u062c\u06cc\u0633\u062a\u0631\u06cc\u060c \u0646\u0647 \u0647\u0641\u062a \u0641\u0627\u06cc\u0644 Twig", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L25"}, {"id": "docs_landing_pages_\u062a\u0645_\u0645\u0634\u062a\u0631\u06a9", "label": "\u062a\u0645 \u0645\u0634\u062a\u0631\u06a9", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L30"}, {"id": "docs_landing_pages_\u0631\u0648\u062a", "label": "\u0631\u0648\u062a", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L46"}, {"id": "docs_landing_pages_\u0633\u0626\u0648", "label": "\u0633\u0626\u0648", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L53"}, {"id": "docs_landing_pages_\u0646\u06a9\u062a\u0647_\u0645\u062d\u062a\u0648\u0627\u06cc\u06cc", "label": "\u0646\u06a9\u062a\u0647\u0654 \u0645\u062d\u062a\u0648\u0627\u06cc\u06cc", "file_type": "document", "source_file": "docs/landing-pages.md", "source_location": "L64"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_landing_pages_md", "target": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/landing-pages.md", "source_location": "L1", "weight": 1.0}, {"source": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "target": "docs_landing_pages_\u0645\u062a\u0646_\u0647\u0631_\u0635\u0641\u062d\u0647_\u06a9\u062c\u0627\u0633\u062a", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/landing-pages.md", "source_location": "L16", "weight": 1.0}, {"source": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "target": "docs_landing_pages_\u0686\u0631\u0627_\u0631\u062c\u06cc\u0633\u062a\u0631\u06cc_\u0646\u0647_\u0647\u0641\u062a_\u0641\u0627\u06cc\u0644_twig", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/landing-pages.md", "source_location": "L25", "weight": 1.0}, {"source": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "target": "docs_landing_pages_\u062a\u0645_\u0645\u0634\u062a\u0631\u06a9", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/landing-pages.md", "source_location": "L30", "weight": 1.0}, {"source": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "target": "docs_landing_pages_\u0631\u0648\u062a", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/landing-pages.md", "source_location": "L46", "weight": 1.0}, {"source": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "target": "docs_landing_pages_\u0633\u0626\u0648", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/landing-pages.md", "source_location": "L53", "weight": 1.0}, {"source": "docs_landing_pages_\u0635\u0641\u062d\u0627\u062a_\u0641\u0631\u0648\u062f_\u0633\u0626\u0648\u06cc\u06cc", "target": "docs_landing_pages_\u0646\u06a9\u062a\u0647_\u0645\u062d\u062a\u0648\u0627\u06cc\u06cc", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/landing-pages.md", "source_location": "L64", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260810052544_php", "label": "Version20260810052544.php", "file_type": "code", "source_file": "migrations/Version20260810052544.php", "source_location": "L1"}, {"id": "migrations_version20260810052544_version20260810052544", "label": "Version20260810052544", "file_type": "code", "source_file": "migrations/Version20260810052544.php", "source_location": "L13"}, {"id": "abstractmigration", "label": "AbstractMigration", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "migrations_version20260810052544_version20260810052544_getdescription", "label": ".getDescription()", "file_type": "code", "source_file": "migrations/Version20260810052544.php", "source_location": "L15"}, {"id": "migrations_version20260810052544_version20260810052544_up", "label": ".up()", "file_type": "code", "source_file": "migrations/Version20260810052544.php", "source_location": "L20"}, {"id": "schema", "label": "Schema", "file_type": "code", "source_file": "migrations/Version20260810052544.php", "source_location": "L20"}, {"id": "migrations_version20260810052544_version20260810052544_down", "label": ".down()", "file_type": "code", "source_file": "migrations/Version20260810052544.php", "source_location": "L26"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260810052544_php", "target": "schema", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L7", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260810052544_php", "target": "abstractmigration", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L8", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260810052544_php", "target": "migrations_version20260810052544_version20260810052544", "relation": "contains", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L13", "weight": 1.0}, {"source": "migrations_version20260810052544_version20260810052544", "target": "abstractmigration", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L13", "weight": 1.0}, {"source": "migrations_version20260810052544_version20260810052544", "target": "migrations_version20260810052544_version20260810052544_getdescription", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L15", "weight": 1.0}, {"source": "migrations_version20260810052544_version20260810052544", "target": "migrations_version20260810052544_version20260810052544_up", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L20", "weight": 1.0}, {"source": "migrations_version20260810052544_version20260810052544_up", "target": "schema", "relation": "references", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L20", "weight": 1.0, "context": "parameter_type"}, {"source": "migrations_version20260810052544_version20260810052544", "target": "migrations_version20260810052544_version20260810052544_down", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L26", "weight": 1.0}, {"source": "migrations_version20260810052544_version20260810052544_down", "target": "schema", "relation": "references", "confidence": "EXTRACTED", "source_file": "migrations/Version20260810052544.php", "source_location": "L26", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "migrations_version20260810052544_version20260810052544_up", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260810052544.php", "source_location": "L22", "receiver": null}, {"caller_nid": "migrations_version20260810052544_version20260810052544_up", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260810052544.php", "source_location": "L23", "receiver": null}, {"caller_nid": "migrations_version20260810052544_version20260810052544_down", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260810052544.php", "source_location": "L28", "receiver": null}, {"caller_nid": "migrations_version20260810052544_version20260810052544_down", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260810052544.php", "source_location": "L29", "receiver": null}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_readme_md", "label": "README.md", "file_type": "document", "source_file": "docs/guide/README.md", "source_location": "L1"}, {"id": "guide_readme_\u0631\u0627\u0647\u0646\u0645\u0627\u06cc_\u06a9\u0627\u0631\u0628\u0631\u06cc_\u06a9\u0644\u06cc\u0646\u06cc\u06a9_\u067e\u0631\u0648", "label": "\u0631\u0627\u0647\u0646\u0645\u0627\u06cc \u06a9\u0627\u0631\u0628\u0631\u06cc \u06a9\u0644\u06cc\u0646\u06cc\u06a9\u200c\u067e\u0631\u0648", "file_type": "document", "source_file": "docs/guide/README.md", "source_location": "L1"}, {"id": "guide_readme_\u0631\u0627\u0647\u0646\u0645\u0627\u0647\u0627", "label": "\u0631\u0627\u0647\u0646\u0645\u0627\u0647\u0627", "file_type": "document", "source_file": "docs/guide/README.md", "source_location": "L6"}, {"id": "guide_readme_\u0686\u0637\u0648\u0631_\u0628\u0647_\u0631\u0648\u0632_\u0646\u06af\u0647_\u062f\u0627\u0634\u062a\u0647_\u0645\u06cc_\u0634\u0648\u062f", "label": "\u0686\u0637\u0648\u0631 \u0628\u0647\u200c\u0631\u0648\u0632 \u0646\u06af\u0647 \u062f\u0627\u0634\u062a\u0647 \u0645\u06cc\u200c\u0634\u0648\u062f", "file_type": "document", "source_file": "docs/guide/README.md", "source_location": "L16"}, {"id": "guide_readme_\u062d\u0633\u0627\u0628_\u0647\u0627\u06cc_\u0646\u0645\u0648\u0646\u0647", "label": "\u062d\u0633\u0627\u0628\u200c\u0647\u0627\u06cc \u0646\u0645\u0648\u0646\u0647", "file_type": "document", "source_file": "docs/guide/README.md", "source_location": "L33"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_readme_md", "target": "guide_readme_\u0631\u0627\u0647\u0646\u0645\u0627\u06cc_\u06a9\u0627\u0631\u0628\u0631\u06cc_\u06a9\u0644\u06cc\u0646\u06cc\u06a9_\u067e\u0631\u0648", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L1", "weight": 1.0}, {"source": "guide_readme_\u0631\u0627\u0647\u0646\u0645\u0627\u06cc_\u06a9\u0627\u0631\u0628\u0631\u06cc_\u06a9\u0644\u06cc\u0646\u06cc\u06a9_\u067e\u0631\u0648", "target": "guide_readme_\u0631\u0627\u0647\u0646\u0645\u0627\u0647\u0627", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L6", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_readme_md", "target": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_independent_doctor_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L10", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_readme_md", "target": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_clinic_owner_doctor_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L11", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_readme_md", "target": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_clinic_manager_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L12", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_readme_md", "target": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_secretary_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L13", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_readme_md", "target": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_guide_staff_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L14", "weight": 1.0}, {"source": "guide_readme_\u0631\u0627\u0647\u0646\u0645\u0627\u06cc_\u06a9\u0627\u0631\u0628\u0631\u06cc_\u06a9\u0644\u06cc\u0646\u06cc\u06a9_\u067e\u0631\u0648", "target": "guide_readme_\u0686\u0637\u0648\u0631_\u0628\u0647_\u0631\u0648\u0632_\u0646\u06af\u0647_\u062f\u0627\u0634\u062a\u0647_\u0645\u06cc_\u0634\u0648\u062f", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L16", "weight": 1.0}, {"source": "guide_readme_\u0631\u0627\u0647\u0646\u0645\u0627\u06cc_\u06a9\u0627\u0631\u0628\u0631\u06cc_\u06a9\u0644\u06cc\u0646\u06cc\u06a9_\u067e\u0631\u0648", "target": "guide_readme_\u062d\u0633\u0627\u0628_\u0647\u0627\u06cc_\u0646\u0645\u0648\u0646\u0647", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/guide/README.md", "source_location": "L33", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_tenant_globaltables_php", "label": "GlobalTables.php", "file_type": "code", "source_file": "src/Shared/Tenant/GlobalTables.php", "source_location": "L1"}, {"id": "tenant_globaltables_globaltables", "label": "GlobalTables", "file_type": "code", "source_file": "src/Shared/Tenant/GlobalTables.php", "source_location": "L16"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_tenant_globaltables_php", "target": "tenant_globaltables_globaltables", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Tenant/GlobalTables.php", "source_location": "L16", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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": "L25"}, {"id": "landing_landingregistry_landingregistry_find", "label": ".find()", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L30"}, {"id": "landingpage", "label": "LandingPage", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L30"}, {"id": "landing_landingregistry_landingregistry_build", "label": ".build()", "file_type": "code", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L36"}], "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": "L25", "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": "L30", "weight": 1.0}, {"source": "landing_landingregistry_landingregistry_find", "target": "landingpage", "relation": "references", "confidence": "EXTRACTED", "source_file": "src/Shared/Landing/LandingRegistry.php", "source_location": "L30", "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": "L36", "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": "L27", "weight": 1.0}], "raw_calls": [{"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": "L202", "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": "L203", "receiver": null}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+22327 -3312
View File
File diff suppressed because it is too large Load Diff
+623 -313
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* پاک‌سازی ردیف‌های تکراریِ «قیمت ویزیت آزاد».
*
* قید یکتای (entity_type, entity_id, insurance_id) ردیف ویزیت آزاد را نمی‌پوشاند،
* چون `insurance_id` آنجا NULL است و MySQL چند NULL را تکراری نمی‌شمارد. تا وقتی
* خواندنِ ردیفِ موجود زیر TenantFilter کور بود، هر بار ذخیره یک ردیف تازه می‌ساخت.
* تازه‌ترین ردیف همان چیزی است که کاربر آخرین‌بار ثبت کرده، پس بقیه حذف می‌شوند.
*/
final class Version20260818131524 extends AbstractMigration
{
public function getDescription(): string
{
return 'Collapse duplicate free-visit pricing rows to the newest one per tenant';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
DELETE p FROM entity_insurance_pricing p
JOIN (
SELECT entity_type, entity_id, MAX(id) AS keep_id
FROM entity_insurance_pricing
WHERE insurance_id IS NULL
GROUP BY entity_type, entity_id
HAVING COUNT(*) > 1
) newest
ON newest.entity_type = p.entity_type
AND newest.entity_id = p.entity_id
WHERE p.insurance_id IS NULL
AND p.id <> newest.keep_id
SQL);
}
public function down(Schema $schema): void
{
// ردیف‌های حذف‌شده تکراری بودند؛ برگرداندنشان همان وضعیت خراب را باز می‌سازد.
$this->throwIrreversibleMigrationException('Duplicate pricing rows are not restored.');
}
}
@@ -55,6 +55,8 @@ class AppointmentController extends BaseController
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots, private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
private readonly \App\Resource\Service\PublicResourceBookingService $publicResources, private readonly \App\Resource\Service\PublicResourceBookingService $publicResources,
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink, private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
private readonly \App\Shared\Tenant\TenantFilterScope $tenantScope,
private readonly \App\Patient\Service\PatientService $patients,
private readonly \Psr\Log\LoggerInterface $logger, private readonly \Psr\Log\LoggerInterface $logger,
) {} ) {}
@@ -724,7 +726,7 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
} }
$payload = $appointment->toArray(); $payload = $this->appointmentWithVenue($appointment);
/** /**
* نوبتِ سرویسیِ کلینیک `service_section_id` را روی خودِ نوبت نمی‌نویسد؛ فقط * نوبتِ سرویسیِ کلینیک `service_section_id` را روی خودِ نوبت نمی‌نویسد؛ فقط
@@ -884,10 +886,55 @@ class AppointmentController extends BaseController
#[Route('/api/v1/appointments/user', methods: ['GET'])] #[Route('/api/v1/appointments/user', methods: ['GET'])]
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
{ {
$status = $request->query->get('status'); $status = $request->query->get('status');
$appointments = $this->appointmentRepo->findByUser($user, $status);
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); // نوبتِ بیمار در محیطِ پزشکِ مقصد ثبت می‌شود. کاربری که خودش صاحب محیط دیگری
// است — پزشک، منشی، مالک کلینیک — با فیلترِ محیطِ خودش نوبت‌های خودش را
// نمی‌دید. صاحب اینجا با `user_id` تعیین می‌شود، نه با محیط.
// `toArray()` هم داخل محدوده است: proxyهای پزشک و کلینیک آنجا باز می‌شوند و
// با فیلترِ برگشته، Doctrine `EntityNotFoundException` می‌دهد.
$rows = $this->tenantScope->withoutFilter(fn () => array_map(
fn(Appointment $a) => $this->appointmentWithVenue($a),
$this->appointmentRepo->findByUser($user, $status),
));
return $this->success(['data' => $rows]);
}
/**
* نوبت به‌همراه نشانیِ واقعیِ همان نوبت با شمارهٔ تماس.
*
* `Appointment::toArray()` نشانی را از «اولین آدرسِ پزشک» برمی‌داشت، پس بیماری که
* نوبتش در کلینیک یا در شعبهٔ دوم بود، نشانیِ مطبِ شخصیِ پزشک را می‌دید. آدرسِ درست
* روی خودِ نوبت ثبت شده (`address_id`) و همان‌جا برای مطب و کلینیک یکسان کار می‌کند.
*
* شماره هم اینجا برمی‌گردد و نه در پاسخ‌های عمومی: مخاطبِ این متد یا صاحب نوبت است
* یا کسی که `canView()` تأییدش کرده. اگر خودِ آدرس شماره نداشته باشد، شمارهٔ کلینیکِ
* همان نوبت جایش می‌نشیند تا بیمار بی‌راه‌ارتباطی نماند.
*/
private function appointmentWithVenue(Appointment $appointment): array
{
$payload = $appointment->toArray();
$addressId = $appointment->getAddressId();
$address = $addressId !== null ? $this->addressRepo->find($addressId) : null;
if ($address !== null) {
$payload['address'] = $address->toArray(null, true);
} elseif ($payload['address'] !== null) {
// نوبت‌های قدیمی `address_id` ندارند؛ همان آدرسِ حدسیِ toArray() می‌ماند،
// ولی دست‌کم بدون شمارهٔ اشتباه نمایش داده نمی‌شود.
$payload['address']['telephone'] = null;
}
if (($payload['address']['telephone'] ?? null) === null && $payload['address'] !== null) {
$clinic = $appointment->getClinic();
if ($clinic !== null) {
$payload['address']['telephone'] = $clinic->getTelephone();
}
}
return $payload;
} }
private function canView(Appointment $a, User $user): bool private function canView(Appointment $a, User $user): bool
@@ -1436,7 +1483,12 @@ class AppointmentController extends BaseController
if ($completed) { if ($completed) {
} }
return $this->success(['data' => $appointment->toArray()]); // انتخاب بیمه می‌تواند بعد از ساخته‌شدنِ مراجعه عوض شود — پذیرش اول نوبت را
// قطعی می‌کند و بعد بیمه را اصلاح می‌کند. بدون این، سهم‌های مراجعه روی همان
// محاسبهٔ اول می‌مانند و صفحهٔ پرداخت سهمِ بیمه را از بیمار می‌خواهد.
$this->patients->resyncInsuranceShares($appointment);
return $this->success(['data' => $this->appointmentWithVenue($appointment)]);
} }
/** /**
@@ -7,6 +7,7 @@ use App\Insurance\Enum\InsuranceType;
use App\Insurance\Enum\ServiceCategory; use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceRepository; use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceRepository; use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Service\InsuranceScopeResolver;
use App\Insurance\Service\TenantServiceCategoryService; use App\Insurance\Service\TenantServiceCategoryService;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException; use App\Shared\Exception\AppException;
@@ -22,6 +23,7 @@ class AppointmentInsuranceService
private readonly TenantServiceCategoryService $serviceCategories, private readonly TenantServiceCategoryService $serviceCategories,
private readonly TenantInsuranceRepository $tenantInsuranceRepo, private readonly TenantInsuranceRepository $tenantInsuranceRepo,
private readonly InsuranceRepository $insuranceRepo, private readonly InsuranceRepository $insuranceRepo,
private readonly InsuranceScopeResolver $scope,
) {} ) {}
/** /**
@@ -39,6 +41,32 @@ class AppointmentInsuranceService
: ['doctor', (int) $appointment->getDoctor()->getId()]; : ['doctor', (int) $appointment->getDoctor()->getId()];
} }
/**
* محیطی که تنظیمات بیمهٔ این نوبت از آن خوانده می‌شود: اول خودِ پزشک، در نبودِ
* تنظیمِ او کلینیکِ همان نوبت. {@see InsuranceScopeResolver}
*
* جدا از {@see tenantOf()} است: مالکیتِ نوبت و پرونده و پرداختش همچنان با
* کلینیک است؛ این فقط می‌گوید قرارداد و درصدها را کجا پیدا کنیم.
*
* @return array{0: string, 1: int}
*/
public function contractScopeOf(Appointment $appointment): array
{
return $this->scope->forContracts(
(int) $appointment->getDoctor()->getId(),
$appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null,
);
}
/** @return array{0: string, 1: int} */
public function categoryScopeOf(Appointment $appointment): array
{
return $this->scope->forServiceCategories(
(int) $appointment->getDoctor()->getId(),
$appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null,
);
}
/** /**
* نوع خدمتِ مؤثر برای محاسبه: انتخابِ نوبت، وگرنه تنها نوع فعالِ tenant، * نوع خدمتِ مؤثر برای محاسبه: انتخابِ نوبت، وگرنه تنها نوع فعالِ tenant،
* وگرنه سرپایی (رفتار تاریخیِ ویزیت). * وگرنه سرپایی (رفتار تاریخیِ ویزیت).
@@ -49,7 +77,7 @@ class AppointmentInsuranceService
return $appointment->getInsuranceServiceCategory(); return $appointment->getInsuranceServiceCategory();
} }
[$entityType, $entityId] = $this->tenantOf($appointment); [$entityType, $entityId] = $this->categoryScopeOf($appointment);
return $this->serviceCategories->defaultCategory($entityType, $entityId) ?? ServiceCategory::Outpatient; return $this->serviceCategories->defaultCategory($entityType, $entityId) ?? ServiceCategory::Outpatient;
} }
@@ -63,23 +91,26 @@ class AppointmentInsuranceService
*/ */
public function apply(Appointment $appointment, array $data): void public function apply(Appointment $appointment, array $data): void
{ {
[$entityType, $entityId] = $this->tenantOf($appointment); // هر کدام محیط خودش را دارد: پزشکی که قرارداد بیمهٔ خودش را دارد ولی نوع
// خدمات را به کلینیک سپرده، هر دو را از جای درست می‌گیرد.
[$categoryType, $categoryId] = $this->categoryScopeOf($appointment);
[$contractType, $contractId] = $this->contractScopeOf($appointment);
if (array_key_exists('insurance_service_category', $data)) { if (array_key_exists('insurance_service_category', $data)) {
$appointment->setInsuranceServiceCategory( $appointment->setInsuranceServiceCategory(
$this->resolveCategory($data['insurance_service_category'], $entityType, $entityId) $this->resolveCategory($data['insurance_service_category'], $categoryType, $categoryId)
); );
} }
if (array_key_exists('insurance_base_id', $data)) { if (array_key_exists('insurance_base_id', $data)) {
$appointment->setInsuranceBaseId( $appointment->setInsuranceBaseId(
$this->resolveInsuranceId($data['insurance_base_id'], $entityType, $entityId, false) $this->resolveInsuranceId($data['insurance_base_id'], $contractType, $contractId, false)
); );
} }
if (array_key_exists('insurance_supplementary_id', $data)) { if (array_key_exists('insurance_supplementary_id', $data)) {
$appointment->setInsuranceSupplementaryId( $appointment->setInsuranceSupplementaryId(
$this->resolveInsuranceId($data['insurance_supplementary_id'], $entityType, $entityId, true) $this->resolveInsuranceId($data['insurance_supplementary_id'], $contractType, $contractId, true)
); );
} }
} }
@@ -19,16 +19,35 @@ class UserActiveContextRepository extends ServiceEntityRepository
return $this->findOneBy(['user' => $user]); return $this->findOneBy(['user' => $user]);
} }
/**
* ثبت یا به‌روزرسانی محیط فعال کاربر مقاوم در برابر رقابت همزمانی.
*
* پنل هنگام بالا آمدن چند درخواست `/oauth/userinfo` را تقریباً همزمان می‌فرستد و
* وقتی کاربر فقط یک محیط دارد، همه‌شان می‌خواستند همان ردیف را بسازند: هر دو
* `findByUser()` را `null` می‌دیدند و دومی با
* `Duplicate entry for key 'PRIMARY'` می‌ترکید (و در همان حال EntityManager بسته
* می‌شد). درج با `ON DUPLICATE KEY UPDATE` این مسابقه را حذف می‌کند.
*/
public function upsert(User $user, string $dbUuid, string $dbType): UserActiveContext public function upsert(User $user, string $dbUuid, string $dbType): UserActiveContext
{ {
$ctx = $this->findByUser($user); $ctx = $this->findByUser($user);
if ($ctx === null) { if ($ctx !== null) {
$ctx = new UserActiveContext($user, $dbUuid, $dbType);
$this->getEntityManager()->persist($ctx);
} else {
$ctx->setContext($dbUuid, $dbType); $ctx->setContext($dbUuid, $dbType);
$this->getEntityManager()->flush();
return $ctx;
} }
$this->getEntityManager()->flush();
return $ctx; $this->getEntityManager()->getConnection()->executeStatement(
'INSERT INTO user_active_context (user_id, db_uuid, db_type, updated_at)
VALUES (:user, :uuid, :type, :now)
ON DUPLICATE KEY UPDATE db_uuid = VALUES(db_uuid), db_type = VALUES(db_type), updated_at = VALUES(updated_at)',
['user' => $user->getId(), 'uuid' => $dbUuid, 'type' => $dbType, 'now' => time()],
);
// ردیف بیرون از ORM ساخته شد، پس entity باید از دیتابیس خوانده شود تا
// identity map همان چیزی را نشان دهد که واقعاً ذخیره شده است.
return $this->findByUser($user)
?? throw new \RuntimeException('user_active_context row vanished right after upsert');
} }
} }
+32 -4
View File
@@ -25,6 +25,7 @@ class InvoiceService
private readonly InsuranceRepository $insuranceRepo, private readonly InsuranceRepository $insuranceRepo,
private readonly EventDispatcherInterface $events, private readonly EventDispatcherInterface $events,
private readonly ActorIdentityResolver $actorIdentity, private readonly ActorIdentityResolver $actorIdentity,
private readonly \App\Insurance\Service\InsuranceScopeResolver $insuranceScope,
) {} ) {}
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */ /** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */
@@ -107,10 +108,16 @@ class InvoiceService
$visitCategory = $session->getInsuranceServiceCategory() ?? ServiceCategory::Outpatient; $visitCategory = $session->getInsuranceServiceCategory() ?? ServiceCategory::Outpatient;
$invoice->setServiceCategory($session->getInsuranceServiceCategory()); $invoice->setServiceCategory($session->getInsuranceServiceCategory());
// قرارداد بیمه جای دیگری غیر از محیطِ صورتحساب نشسته است: صورتحسابِ نوبتِ
// کلینیک به کلینیک تعلق دارد، ولی قرارداد معمولاً روی خودِ پزشک ثبت شده. با
// محیط صورتحساب، هر سهم بیمه صفر درمی‌آمد — بیمار کل مبلغ را بدهکار می‌شد و
// مطالبه‌ای هم برای بیمه ساخته نمی‌شد. {@see InsuranceScopeResolver}
[$coverageType, $coverageId] = $this->coverageScope($session, $entityType, $entityId);
$visitPrice = $session->getVisitPriceRials(); $visitPrice = $session->getVisitPriceRials();
if ($visitPrice > 0) { if ($visitPrice > 0) {
$baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId, $visitCategory); $baseRule = $this->tenantInsuranceService->coverageRule($coverageType, $coverageId, $baseId, $visitCategory);
$suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId, $visitCategory); $suppRule = $this->tenantInsuranceService->coverageRule($coverageType, $coverageId, $suppId, $visitCategory);
$breakdown = $this->calculator->calculateItem(new Money($visitPrice), $baseRule, $suppRule); $breakdown = $this->calculator->calculateItem(new Money($visitPrice), $baseRule, $suppRule);
$invoice->addItem(new InvoiceItem($invoice, 'ویزیت', $visitPrice, 1, $breakdown, null)); $invoice->addItem(new InvoiceItem($invoice, 'ویزیت', $visitPrice, 1, $breakdown, null));
} }
@@ -122,8 +129,8 @@ class InvoiceService
$unitPrice = $item->getPriceRials(); $unitPrice = $item->getPriceRials();
$total = new Money($unitPrice * $qty); $total = new Money($unitPrice * $qty);
$baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseId, $item->getId()); $baseRule = $this->tenantInsuranceService->coverageRuleForService($coverageType, $coverageId, $baseId, $item->getId());
$suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppId, $item->getId()); $suppRule = $this->tenantInsuranceService->coverageRuleForService($coverageType, $coverageId, $suppId, $item->getId());
$breakdown = $this->calculator->calculateItem($total, $baseRule, $suppRule); $breakdown = $this->calculator->calculateItem($total, $baseRule, $suppRule);
$invoice->addItem(new InvoiceItem($invoice, $item->getName(), $unitPrice, $qty, $breakdown, $item->getId())); $invoice->addItem(new InvoiceItem($invoice, $item->getName(), $unitPrice, $qty, $breakdown, $item->getId()));
@@ -132,6 +139,27 @@ class InvoiceService
$invoice->recalculateTotals(); $invoice->recalculateTotals();
} }
/**
* محیطی که قرارداد بیمهٔ این مراجعه در آن ثبت شده اول پزشکِ نوبت، بعد کلینیکش.
*
* مراجعهٔ دستی نوبتی ندارد، پس پزشکی هم برای پرسیدن نیست و همان محیط صورتحساب
* می‌ماند؛ آنجا قرارداد در همان محیط ثبت می‌شود.
*
* @return array{0: string, 1: int}
*/
private function coverageScope(PatientSession $session, string $entityType, int $entityId): array
{
$appointment = $session->getAppointment();
if ($appointment === null) {
return [$entityType, $entityId];
}
return $this->insuranceScope->forContracts(
(int) $appointment->getDoctor()->getId(),
$appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null,
);
}
/** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */ /** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */
public function finalize(Invoice $invoice): void public function finalize(Invoice $invoice): void
{ {
+11
View File
@@ -53,6 +53,13 @@ class BlogRepository extends ServiceEntityRepository
* The admin review queue: posts awaiting a doctor's decision, newest first. * The admin review queue: posts awaiting a doctor's decision, newest first.
* @return Blog[] * @return Blog[]
*/ */
/*
* هر چهار فهرست روی `id` هم مرتب می‌شوند، نه فقط `createdAt`.
*
* `created_at` ثانیه‌ای است و ده‌ها مقاله می‌توانند دقیقاً یک مقدار داشته باشند؛
* ترتیب بین ردیف‌های برابر در MySQL تضمین‌شده نیست. بدون این tiebreaker، دو صفحهٔ
* پیاپیِ همان فهرست می‌توانند یک مقاله را دوبار بدهند و مقالهٔ دیگری را هرگز.
*/
public function findByReviewStatus(string $reviewStatus, int $page = 1, int $limit = 20): array public function findByReviewStatus(string $reviewStatus, int $page = 1, int $limit = 20): array
{ {
return $this->createQueryBuilder('b') return $this->createQueryBuilder('b')
@@ -61,6 +68,7 @@ class BlogRepository extends ServiceEntityRepository
->where('b.reviewStatus = :rs') ->where('b.reviewStatus = :rs')
->setParameter('rs', $reviewStatus) ->setParameter('rs', $reviewStatus)
->orderBy('b.createdAt', 'DESC') ->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit) ->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit) ->setMaxResults($limit)
->getQuery()->getResult(); ->getQuery()->getResult();
@@ -86,6 +94,7 @@ class BlogRepository extends ServiceEntityRepository
$qb = $this->adminQb($status, $search) $qb = $this->adminQb($status, $search)
->leftJoin('b.city', 'c')->addSelect('c') ->leftJoin('b.city', 'c')->addSelect('c')
->orderBy('b.createdAt', 'DESC') ->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit) ->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit); ->setMaxResults($limit);
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
@@ -120,6 +129,7 @@ class BlogRepository extends ServiceEntityRepository
->where('b.representation = :rep') ->where('b.representation = :rep')
->setParameter('rep', $rep) ->setParameter('rep', $rep)
->orderBy('b.createdAt', 'DESC') ->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit) ->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit); ->setMaxResults($limit);
if ($status !== null && $status !== '') { if ($status !== null && $status !== '') {
@@ -166,6 +176,7 @@ class BlogRepository extends ServiceEntityRepository
->where('b.status = :status') ->where('b.status = :status')
->setParameter('status', Blog::STATUS_PUBLISHED) ->setParameter('status', Blog::STATUS_PUBLISHED)
->orderBy('b.createdAt', 'DESC') ->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit) ->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit); ->setMaxResults($limit);
+5 -5
View File
@@ -131,7 +131,7 @@ class ClinicController extends BaseController
$this->userRepo->save($user); $this->userRepo->save($user);
} }
return $this->success(['data' => $clinic->toDetailArray()], 201); return $this->success(['data' => $clinic->toDetailArray(withContact: true)], 201);
} }
#[OA\Get( #[OA\Get(
@@ -177,7 +177,7 @@ class ClinicController extends BaseController
); );
return $this->success(['data' => array_merge( return $this->success(['data' => array_merge(
$clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone), $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone, $canEdit),
['can_edit' => $canEdit], ['can_edit' => $canEdit],
)]); )]);
} }
@@ -277,7 +277,7 @@ class ClinicController extends BaseController
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic); [$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]); return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone, true)]);
} }
#[OA\Get( #[OA\Get(
@@ -795,7 +795,7 @@ class ClinicController extends BaseController
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data); $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data);
} }
return $this->success(['data' => $address->toArray()], 201); return $this->success(['data' => $address->toArray(null, true)], 201);
} }
#[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['PATCH'])] #[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['PATCH'])]
@@ -824,7 +824,7 @@ class ClinicController extends BaseController
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data); $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data);
} }
return $this->success(['data' => $address->toArray()]); return $this->success(['data' => $address->toArray(null, true)]);
} }
#[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['DELETE'])] #[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['DELETE'])]
+6 -4
View File
@@ -209,9 +209,11 @@ class Clinic
array $cityData = [], array $cityData = [],
?array $map = null, ?array $map = null,
?string $street = null, ?string $street = null,
?string $telephone = null ?string $telephone = null,
bool $withContact = false
): array { ): array {
$phone = $this->firstFilled($telephone, $this->telephone); // شمارهٔ کلینیک هم مثل شمارهٔ مطب عمومی نیست. {@see DoctorAddress::toArray()}
$phone = $withContact ? $this->firstFilled($telephone, $this->telephone) : null;
$address = $this->firstFilled($street, $this->address); $address = $this->firstFilled($street, $this->address);
return [ return [
@@ -267,9 +269,9 @@ class Clinic
return null; return null;
} }
public function toListArray(?string $city = null, ?string $state = null, ?string $telephone = null): array public function toListArray(?string $city = null, ?string $state = null, ?string $telephone = null, bool $withContact = false): array
{ {
$phone = $this->firstFilled($telephone, $this->telephone); $phone = $withContact ? $this->firstFilled($telephone, $this->telephone) : null;
return [ return [
'id' => (string) $this->id, 'id' => (string) $this->id,
+2 -1
View File
@@ -41,7 +41,8 @@ class AddressController extends BaseController
$this->clinicDoctorAccess->denyUnlessGranted($user, 'addresses', 'view'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'addresses', 'view');
return $this->success(array_map( return $this->success(array_map(
static fn (DoctorAddress $a): array => $a->toArray(), // فهرست محیطِ خودِ کاربر است، پس شمارهٔ تماس هم برمی‌گردد.
static fn (DoctorAddress $a): array => $a->toArray(null, true),
$this->addresses->listForContext($user), $this->addresses->listForContext($user),
)); ));
} }
+16 -8
View File
@@ -127,7 +127,7 @@ class DoctorController extends BaseController
$this->userRepo->save($user); $this->userRepo->save($user);
} }
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))], 201); return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor), true)], 201);
} }
#[OA\Get( #[OA\Get(
@@ -159,12 +159,14 @@ class DoctorController extends BaseController
} }
$clinics = $this->clinicRepo->findByDoctor($doctor); $clinics = $this->clinicRepo->findByDoctor($doctor);
// شمارهٔ کلینیک بعد از محاسبهٔ can_edit اضافه می‌شود؛ اینجا هنوز معلوم نیست
// درخواست‌دهنده صاحب پروفایل است یا یک بازدیدکنندهٔ عمومی.
$clinicData = array_map(fn(Clinic $c) => [ $clinicData = array_map(fn(Clinic $c) => [
'id' => (string) $c->getId(), 'id' => (string) $c->getId(),
'uuid' => $c->getUuid(), 'uuid' => $c->getUuid(),
'name' => $c->getName(), 'name' => $c->getName(),
'address' => $c->getAddress(), 'address' => $c->getAddress(),
'telephone' => $c->getTelephone(), 'telephone' => null,
'city_id' => $c->getCityId(), 'city_id' => $c->getCityId(),
'province_id' => $c->getProvinceId(), 'province_id' => $c->getProvinceId(),
'map' => [ 'map' => [
@@ -187,8 +189,14 @@ class DoctorController extends BaseController
|| $this->editPolicy->ownsDoctor($user, $doctor) || $this->editPolicy->ownsDoctor($user, $doctor)
); );
if ($canEdit) {
foreach ($clinics as $index => $clinic) {
$clinicData[$index]['telephone'] = $clinic->getTelephone();
}
}
$schedules = $this->scheduleRepo->findAllByDoctor($doctor); $schedules = $this->scheduleRepo->findAllByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [ return $this->success(['data' => array_merge($doctor->toDetailArray($schedules, $canEdit), [
'clinics' => $clinicData, 'clinics' => $clinicData,
'representation' => $representation, 'representation' => $representation,
'can_edit' => $canEdit, 'can_edit' => $canEdit,
@@ -214,7 +222,7 @@ class DoctorController extends BaseController
} }
$schedules = $this->scheduleRepo->findAllByDoctor($doctor); $schedules = $this->scheduleRepo->findAllByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), ['clinics' => [[ return $this->success(['data' => array_merge($doctor->toDetailArray($schedules, true), ['clinics' => [[
'id' => (string) $clinic->getId(), 'id' => (string) $clinic->getId(),
'uuid' => $clinic->getUuid(), 'uuid' => $clinic->getUuid(),
'name' => $clinic->getName(), 'name' => $clinic->getName(),
@@ -393,7 +401,7 @@ class DoctorController extends BaseController
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
} }
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]); return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor), true)]);
} }
#[OA\Delete( #[OA\Delete(
@@ -613,7 +621,7 @@ class DoctorController extends BaseController
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
} }
return $this->success(['data' => $address->toArray()], 201); return $this->success(['data' => $address->toArray(null, true)], 201);
} }
#[OA\Get( #[OA\Get(
@@ -651,7 +659,7 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
} }
return $this->success(['data' => $address->toArray()]); return $this->success(['data' => $address->toArray(null, true)]);
} }
#[OA\Patch( #[OA\Patch(
@@ -715,7 +723,7 @@ class DoctorController extends BaseController
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
} }
return $this->success(['data' => $address->toArray()]); return $this->success(['data' => $address->toArray(null, true)]);
} }
#[OA\Delete( #[OA\Delete(
+6 -2
View File
@@ -601,7 +601,11 @@ class Doctor
} }
/** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */ /** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */
public function toDetailArray(array $schedules = []): array /**
* `$withContact` فقط برای مخاطبی روشن می‌شود که حق دیدن شمارهٔ مطب را دارد
* صاحب پروفایل یا ادمین. پاسخ عمومی بدون شماره است. {@see DoctorAddress::toArray()}
*/
public function toDetailArray(array $schedules = [], bool $withContact = false): array
{ {
$sf = $this->computeScheduleFields($schedules); $sf = $this->computeScheduleFields($schedules);
return [ return [
@@ -638,7 +642,7 @@ class Doctor
'owner_status' => $this->ownerStatus, 'owner_status' => $this->ownerStatus,
'free_turn' => $sf['free_turn'], 'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'], 'hours_of_work' => $sf['hours_of_work'],
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()), 'address' => array_map(fn(DoctorAddress $a) => $a->toArray(null, $withContact), $this->addresses->toArray()),
'average_rate' => ['total_rates' => null], 'average_rate' => ['total_rates' => null],
'state' => array_map(fn(Province $p) => [ 'state' => array_map(fn(Province $p) => [
'uuid' => $p->getUuid(), 'uuid' => $p->getUuid(),
+11 -2
View File
@@ -151,7 +151,16 @@ class DoctorAddress
private function touch(): void { $this->updatedAt = time(); } private function touch(): void { $this->updatedAt = time(); }
public function toArray(?string $clinicName = null): array /**
* شمارهٔ تماسِ محل، دادهٔ عمومی نیست.
*
* صفحهٔ پزشک شمارهٔ مطب را کنار آدرس نشان می‌داد و همان شماره در پاسخ عمومیِ API
* هم می‌آمد یعنی برداشت انبوهِ شمارهٔ همهٔ مطب‌ها فقط یک درخواست فاصله داشت.
* حالا پیش‌فرض حذف است و فقط جایی که مخاطب حق دیدنش را دارد صاحب همان محل، یا
* بیماری که نوبتش آنجاست با `$withContact` برمی‌گردد. موقعیت مکانی و آدرس
* عمومی می‌مانند؛ آن‌ها همان چیزی‌اند که بیمار برای پیدا کردن مطب لازم دارد.
*/
public function toArray(?string $clinicName = null, bool $withContact = false): array
{ {
return [ return [
'id' => (string) $this->id, 'id' => (string) $this->id,
@@ -165,7 +174,7 @@ class DoctorAddress
'longitude' => $this->longitude !== null ? (string) $this->longitude : null, 'longitude' => $this->longitude !== null ? (string) $this->longitude : null,
], ],
'address' => $this->address, 'address' => $this->address,
'telephone' => $this->telephone, 'telephone' => $withContact ? $this->telephone : null,
'active' => $this->active, 'active' => $this->active,
'timezone' => $this->timezone, 'timezone' => $this->timezone,
'city' => $this->city !== null ? [ 'city' => $this->city !== null ? [
@@ -39,6 +39,7 @@ class InsuranceController extends BaseController
private readonly DoctorInsuranceRepository $doctorInsuranceRepo, private readonly DoctorInsuranceRepository $doctorInsuranceRepo,
private readonly DoctorRepository $doctorRepo, private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo, private readonly ClinicRepository $clinicRepo,
private readonly \App\Insurance\Service\InsuranceScopeResolver $insuranceScope,
private readonly EntityInsurancePricingRepository $pricingRepo, private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly TenantInsuranceRepository $tenantInsuranceRepo, private readonly TenantInsuranceRepository $tenantInsuranceRepo,
private readonly TenantServiceCoverageRepository $serviceCoverageRepo, private readonly TenantServiceCoverageRepository $serviceCoverageRepo,
@@ -317,9 +318,53 @@ class InsuranceController extends BaseController
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
} }
// `inherit=1` را فقط مصرف‌کننده‌هایی می‌فرستند که می‌خواهند بدانند «برای نوبتِ
// این پزشک واقعاً چه چیزی اعمال می‌شود» — مثل مودال قطعی‌کردن نوبت. صفحهٔ
// تنظیمات آن را نمی‌فرستد، چون آنجا باید ردیفِ خودِ پزشک ویرایش شود، نه
// ردیفِ به‌ارث‌رسیده از کلینیک.
if ($request->query->getBoolean('inherit')) {
[$entityType, $entityId] = $this->inheritedScope($entityType, $entityId, $user, 'visit_price');
}
return $this->success($this->pricingPayload($entityType, $entityId)); return $this->success($this->pricingPayload($entityType, $entityId));
} }
/**
* محیطی که تنظیم واقعاً از آن خوانده می‌شود: اول خودِ پزشک، در نبودِ تنظیمِ او
* کلینیکی که عضوش است. همان قاعده‌ای که موتور نوبت به‌کار می‌برد، پس پنل و
* محاسبهٔ نهایی یک چیز نشان می‌دهند. {@see InsuranceScopeResolver}
*
* @return array{0: string, 1: int}
*/
private function inheritedScope(string $entityType, int $entityId, User $user, string $kind): array
{
if ($entityType !== 'doctor') {
return [$entityType, $entityId];
}
$doctor = $this->doctorRepo->find($entityId);
if ($doctor === null) {
return [$entityType, $entityId];
}
// فقط کلینیکی که کاربر واقعاً در آن ایستاده — نه «هر کلینیکی که پزشک عضوش
// است»: پزشکِ چندکلینیکه وگرنه تنظیم کلینیکِ نامربوط را می‌دید.
[$callerType, $callerId] = $this->resolveEntity($user);
if ($callerType !== 'clinic' || $callerId === null) {
return [$entityType, $entityId];
}
$clinic = $this->clinicRepo->find((int) $callerId);
if ($clinic === null || !$clinic->getDoctors()->contains($doctor)) {
return [$entityType, $entityId];
}
return match ($kind) {
'visit_price' => $this->insuranceScope->forVisitPrice($entityId, (int) $callerId),
default => $this->insuranceScope->forContracts($entityId, (int) $callerId),
};
}
private function pricingPayload(string $entityType, int $entityId): array private function pricingPayload(string $entityType, int $entityId): array
{ {
$rows = $this->pricingRepo->findByEntity($entityType, $entityId); $rows = $this->pricingRepo->findByEntity($entityType, $entityId);
@@ -453,6 +498,10 @@ class InsuranceController extends BaseController
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
} }
if ($request->query->getBoolean('inherit')) {
[$entityType, $entityId] = $this->inheritedScope($entityType, $entityId, $user, 'contracts');
}
$contracts = $this->tenantInsuranceRepo->findLatestByTenant($entityType, $entityId); $contracts = $this->tenantInsuranceRepo->findLatestByTenant($entityType, $entityId);
$byId = []; $byId = [];

Some files were not shown because too many files have changed in this diff Show More