Compare commits

39 Commits
Author SHA1 Message Date
hamedandClaude Opus 5 6841848dc3 chore(graph): update knowledge graph after admin booking-mode change
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:33:17 +03:30
hamedandClaude Opus 5 a3fc7f5c8b feat(appointment): let a platform admin change a doctor's locked booking mode
The booking mode locks after the first save because existing appointments were
computed under that mode's rules. A lock with no key, though, traps a practice
that picked the wrong mode on day one, so ROLE_ADMIN can now open it.

The first attempt is still refused when the doctor has active appointments in
the next year, and says how many; the admin repeats the request with
force_mode_change to confirm they know what happens to those. The flag does
nothing for anyone else. GET now returns booking_mode_changeable so the panel
enables the toggle from the server's answer rather than guessing from the role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:32:52 +03:30
hamedandClaude Opus 5 7096b8980d chore(graph): update knowledge graph after unified doctor schedule
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:21:26 +03:30
hamedandClaude Opus 5 a0b4a969fc fix(appointment): one weekly schedule per doctor, place chosen per shift
A doctor working both at their own practice and at a clinic had to write two
independent schedules and neither panel could see the other, so the clinic
showed an empty form even though the doctor had configured their practice.

The schedule is now a single record owned by the doctor. What varies between
days is the place: the context of a shift is read from its location_id, not
from the record it lives in. Booking in a context therefore sees only that
context's days, so a personal-practice secretary still cannot book a clinic
day. The caller's own context decides which addresses they may assign: the
doctor gets every place of theirs, a clinic manager only its own, and shifts
outside their reach are returned for display but preserved verbatim on save.

Existing per-clinic rows are merged by migration; location_id was already
stored on every shift, so no context information is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:20:53 +03:30
hamed 2f030edef1 Add dental module phase 4 and phase 5 documentation, including treatment estimates, clinical operations, and preset content
- Introduced phase 4 documentation detailing treatment estimates, data models, state machines, API endpoints, and new dashboard metrics.
- Added phase 5 documentation covering consumables, lab operations, periodontal charts, sterilization cycles, clinical images, and a checklist for professional review.
- Created a preset content document outlining default dental service packages and protocols for clinics.
2026-08-20 16:21:08 +03:30
hamedandClaude Opus 5 601d211d6f fix(subscription): report the acting environment's plan for gating
Feature gates and the resource cap are enforced against the environment
the user is standing in, but /subscription/my only ever returned the
plan of the environment they own. A doctor working as a guest in another
clinic consumed the host clinic's resource quota while the panel showed
their own plan's cap, so the quota number and the menu locks disagreed
with what the server would allow.

/subscription/my now also returns context_plan — limits and features of
the acting environment, without the other environment's plan identity.
effective_plan, subscription and used_trial stay on the owned
environment so the purchase flow is unchanged, and useSubscription
reads its caps and hasFeature from context_plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:48:13 +03:30
hamedandClaude Opus 5 25a12e9a06 fix(secretary): scope plan limit to the acting environment
Secretary limit always read the doctor's plan, so a clinic on the
professional plan hit the doctor's free-plan cap of one secretary.

Limit and count are now per environment: clinic env uses the clinic
subscription and counts the clinic's active secretaries, personal env
uses the doctor's own plan. Counting is per person, not per row, so a
secretary linked to several doctors of one clinic counts once, and
re-linking an already-active person never trips the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:38:40 +03:30
hamedandClaude Opus 5 5fdc232e65 chore(graph): update knowledge graph after insurance logo fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:33:48 +03:30
hamedandClaude Opus 5 b33f38072d fix(clinic): send each contracted insurer's logo to the public page
The clinic payload carried only uuid/id/name for list_bime, so the public
page fell back to the same placeholder for every insurer and the section
read as broken. The logo has been on the insurance record all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:33:34 +03:30
hamedandClaude Opus 5 e4bd37b3bd chore(graph): update knowledge graph after agent panel fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:25:02 +03:30
hamedandClaude Opus 5 f863b39a74 fix(representation): count only online bookings in the agent panel
The agent's own dashboard (dashboard/summary and doctors/performance) counted
every appointment belonging to their doctors, so the bookings a secretary types
into the panel — which earn the agent nothing — sat next to a commission column
that ignored them. Both now count only bookings that came from the agent's own
site, matching the monthly/yearly report. The per-doctor income column is also
scoped to this agent, since a doctor may have been under another one before.

Adds a backfill for the bookings paid before city domains resolved to an agent:
they carry neither booking_representation_id nor a FinancialBreakdown, and
neither can be recovered by replaying the request. Both are derived from
payments.frontend_address, the address the payment was started from. The
recovered breakdown is dated to the payment, not to the run, or a year of
commission would land in "today". Dry-run by default; re-running is a no-op,
and a payment whose domain does not match the doctor's owner is reported and
skipped rather than retried forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:24:48 +03:30
hamedandClaude Opus 5 be12502873 chore(graph): update knowledge graph after representation commission fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:14:36 +03:30
hamedandClaude Opus 5 15dfbe61fd fix(representation): pay commission on online bookings from city sites
Three things kept a city-site booking from ever reaching its representative.

The domain never resolved. City sites carry their own domain on cities.domain
while a representative's coverage is a set of cities, and representations.domain
is normally only filled for a global agent. The resolver looked at that column
alone, so bookingRepId was always null and the commission guard rejected every
booking made through a city site. It now falls back to the active representative
covering that city, and stays null when two of them cover it — an ambiguous
money assignment has to be resolved in the data, not guessed.

Commission waited for confirmation. The money has already arrived when the
gateway callback succeeds; confirming the appointment is the doctor's or
secretary's job and may happen days later or never. It is now recorded on
payment, with the appointment still pending. Recording is idempotent, so the
confirmation path stays and creates nothing twice.

The dashboard counted every appointment of the representative's doctors,
including the ones a secretary typed into the panel. It now counts only
bookings that came from the representative's own site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:14:22 +03:30
hamedandClaude Opus 5 73a9351f14 chore(graph): update knowledge graph after payment origin
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:38:43 +03:30
hamedandClaude Opus 5 bb09316a75 feat(admin): show which domain a payment came from
Several storefronts share one gateway — the city booking sites and the
ClinicPro panel itself — so the payments report could not say where a
transaction originated. The payment already stores the return address it was
started with; the domain is derived from it.

Lowercased and stripped of "www." so one domain is one value in the report,
null when a payment has no return address. Exposed on both the list and the
detail (which also carries the full address), and the list search now matches
it, so filtering to a single site needs no new parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:38:27 +03:30
hamedandClaude Opus 5 60c2eaa35e chore(graph): update knowledge graph after context cache fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:16:11 +03:30
hamedandClaude Opus 5 1ce9957538 fix(admin): drop cached environment data when switching context
A doctor who also owns a clinic runs two environments, and a subscription
belongs to an environment, not to the user. The panel treated it as the
user's: the subscription query was keyed ['subscription-my'] with no
context, and switching environments never touched the react-query cache. So
upgrading the clinic left the personal practice showing the clinic's plan
with its feature-gated menu items unlocked, and vice versa.

The query key now carries the active dbUuid, and the context switch clears
the whole cache — every cached response belongs to the environment it was
fetched in, not just this one.

The API was already correct: DualEnvironmentSubscriptionTest pins that
granting one environment leaves the other on free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:15:56 +03:30
hamedandClaude Opus 5 5548d6248c chore(graph): update knowledge graph after owned-entity fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:08:11 +03:30
hamedandClaude Opus 5 32044c8fa9 fix(subscription): read the subscription of the environment the user owns
A user who is both a doctor and a clinic owner always resolved to the
doctor: SubscriptionController had its own role-first resolveEntity, and
ownedEntity() returned the doctor whenever one existed. So a subscription
granted to that user's clinic was stored correctly but never surfaced —
/subscription/my kept reporting the free plan and the panel kept the
feature-gated menu items locked.

ownedEntity() now disambiguates with the active context when the user owns
both environments, and the controller delegates to it instead of re-deriving
the pair from roles. Payment already used ownedEntity(), so display, purchase
and admin grant now agree on one environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:07:56 +03:30
hamedandClaude Opus 5 b81cb8a90e chore(graph): update knowledge graph after subscription revoke
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:59:36 +03:30
hamedandClaude Opus 5 a1584c3296 feat(subscription): allow admin to delete a granted subscription
Granting stacks a new row and extends the existing expiry, so a mistaken
grant had no way back: the report tab was read-only and no endpoint deleted
a subscription (only DELETE .../subscription/period/{uuid}, a different
resource).

Adds DELETE /api/v1/admin/subscription/{uuid}. Payment-backed subscriptions
are refused with 409 — deleting one would leave the sales report with a
payment that owns nothing; refunds are the correct path there.

The report tab gets a per-row delete with a confirm dialog, plus a button
that jumps to the grant tab so add and remove live in the same place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:59:12 +03:30
hamedandClaude Opus 5 5eb3bd586f chore(graph): update knowledge graph after copy tone pass
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:13:40 +03:30
hamedandClaude Opus 5 30b999872c refactor(public): unify site copy with the About page voice
The About page addresses the reader formally and speaks as "we"; the
landing pages used informal second-person singular ("تعریف می‌کنی") and
the home hero was a slogan. Both now match the About tone.

Landing copy: all informal verb forms and possessives converted to the
formal plural. Home: hero headline and lead rewritten as a plain
statement of what we built, plus small wording passes on the devices,
specialties and stats blocks.

The home-page smoke assertion follows the new headline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:13:24 +03:30
hamedandClaude Opus 5 a2ff69ebad chore(graph): update knowledge graph after about page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:54:28 +03:30
hamedandClaude Opus 5 1ca20b6f53 feat(public): add About Us page
Adds a public "درباره ما" page at /درباره-ما reusing the landing theme
(same webpack entry, header, footer, registration modal). Section ids
match the header anchors so the nav works on this page too.

The explicit route takes precedence over LandingController's catch-all
/{slug}, which is registered with priority -10.

Also links it from the header nav and footer quick links, and lists it
in sitemap.xml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:54:06 +03:30
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
311 changed files with 45774 additions and 8674 deletions
+3 -1
View File
@@ -17,7 +17,9 @@
# 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).
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
MESSENGER_TRANSPORT_DSN="redis://redis-XXXXXXXX:6379/messages?stream_max_entries=20000"
# 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 ###
###> 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 ###
###> 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.
# 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"
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"
# If the Redis service has a password: redis://:<pass>@<redis-private-host>:6379
+32 -3
View File
@@ -8,9 +8,9 @@ ClinicPro product. This glossary records the language the backend uses for its o
### Clinic configuration
**Practice Domain**:
The single field of practice a clinic declares it operates in — beauty, dental, orthopaedics. It is a
configuration key: it selects which dashboard, forms and treatment workflows the clinic gets. A
clinic has exactly one.
The single field of practice a tenant declares it operates in — beauty, dental, orthopaedics. It is a
configuration key: it selects which dashboard, forms and treatment workflows the tenant gets. A clinic
or a solo doctor's practice has exactly one.
_Avoid_: Specialty, clinic type, field, discipline
**Specialty**:
@@ -67,3 +67,32 @@ _Avoid_: Zone, body part, region
What the operator actually did to one Treatment Area in one Treatment Session — the device used, the
parameters it was set to, how long it took, and any note.
_Avoid_: Treatment log, area result, shot record
### Dental
**Dental Preset**:
The package of default data a tenant receives when it declares the dental practice domain — service
groups, services, resource types and protocols. Defined in code and versioned; it is product content,
not tenant data.
_Avoid_: Seed, fixture, template, sample data
**Preset Install**:
The record that one preset template key became one real row in one tenant. It is what makes installing
a preset twice a no-op.
_Avoid_: Migration, sync record, import log
**Tooth Chart**:
The current condition of every tooth of one patient in one tenant. It is a snapshot, never a history,
and it holds conditions that predate the tenant — a tooth lost years before the first visit.
_Avoid_: Odontogram record, dental record, chart entry
**Tooth Site**:
One tooth, identified by its two-digit FDI number, together with the surfaces of it a service targets.
It is what a dental service is performed on, and it is not a Treatment Area.
_Avoid_: Treatment Area, tooth record, position, location
**Treatment Estimate**:
The priced list of services proposed to a patient across their teeth, which the patient accepts in whole
or in part. In Persian it is «طرح درمان»; the English name stays distinct because Treatment Plan is
ambiguous between a Treatment Protocol and a Treatment Case.
_Avoid_: Treatment plan, quote, proposal, estimate sheet
+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)]));
}
/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمی‌دهد. */
/**
* درصد پوششِ قابل ثبت: عددی بین ۰ تا ۱۰۰.
*
* صفر مقدار معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمی‌دهد» — سهم بیمار
* صددرصد. آنچه رد می‌شود خالی‌ماندنِ فیلد است، نه صفر بودنش.
*/
export function isValidPercent(raw?: string): boolean {
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;
supplementary_discount_percent?: number;
doctor_name?: string | null;
/** پزشکِ نوبتِ این مراجعه — محدودهٔ قرارداد بیمه از روی او حل می‌شود. */
doctor_uuid?: string | null;
final_price_rials?: number;
// تفکیک بیمه — سرور محاسبه می‌کند (PatientSession::applyShares)
gross_total_rials?: number;
@@ -41,6 +41,7 @@ interface AppointmentLike {
insurance_service_category?: string | null;
insurance_base_id?: number | null;
insurance_supplementary_id?: number | null;
doctor?: { uuid?: string | null } | null;
}
interface Props {
@@ -159,7 +160,7 @@ export default function ConfirmAppointmentModal({
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 () => {
(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: [{
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
@@ -108,7 +108,7 @@ function OccupiedCard({
const [confirmOpen, setConfirmOpen] = useState(false);
// نام بیمه فقط با نگاشت از قراردادهای کش‌شده به دست می‌آید؛ payload نوبت نامی ندارد
// تا لیست‌های نوبت به N+1 نیفتند.
const insurance = useAppointmentInsurance(!!a.insurance_base_id);
const insurance = useAppointmentInsurance(!!a.insurance_base_id, a.doctor_uuid ?? null);
const insuranceChip = [
a.insurance_service_category_label,
insurance.insuranceNameOf(a.insurance_base_id),
@@ -21,7 +21,7 @@ const ADDRESSES: AddressData[] = [{
}];
/** پاسخ برنامهٔ هفتگی؛ فقط `meta` بین تست‌ها فرق می‌کند. */
function mockSchedule(meta: Record<string, unknown>, locked = false) {
function mockSchedule(meta: Record<string, unknown>, locked = false, changeable = false) {
get.mockImplementation((url?: string) => {
if (typeof url === 'string' && url.includes('weekly-schedule')) {
return Promise.resolve({
@@ -32,6 +32,7 @@ function mockSchedule(meta: Record<string, unknown>, locked = false) {
schedule: {},
meta: { online_booking_enabled: true, booking_window_value: 3, booking_window_unit: 'month', buffer_minutes: 0, ...meta },
booking_mode_locked: locked,
booking_mode_changeable: changeable,
},
});
}
@@ -77,3 +78,21 @@ describe('WeeklyScheduleTab — روش نوبت‌دهی', () => {
expect(screen.queryByRole('button', { name: /نوبت‌دهی منبع‌محور/ })).toBeNull();
});
});
describe('WeeklyScheduleTab — قفل نوع نوبت‌دهی', () => {
it('برای کاربر عادی، نوعِ ثبت‌شده قفل است', async () => {
mockSchedule({ booking_mode: 'slot' }, true);
renderWithProviders(<WeeklyScheduleTab doctorUuid="d1" addresses={ADDRESSES} />);
expect(await screen.findByText('نوع نوبت‌دهی ثبت شده و دیگر قابل تغییر نیست.')).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی سرویسی').closest('button')).toBeDisabled();
});
it('برای ادمین، همان نوع قابل تغییر است و هشدارش را می‌گوید', async () => {
mockSchedule({ booking_mode: 'slot' }, true, true);
renderWithProviders(<WeeklyScheduleTab doctorUuid="d1" addresses={ADDRESSES} />);
expect(await screen.findByText(/فقط ادمین می‌تواند عوضش کند/)).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی سرویسی').closest('button')).not.toBeDisabled();
});
});
@@ -102,7 +102,19 @@ const DEFAULT_BOOKING_META: BookingMeta = {
booking_mode: 'slot',
buffer_minutes: 0,
};
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; booking_mode_locked?: boolean; }
interface WeeklyScheduleData {
uuid: string;
doctor_uuid: string;
schedule: NewScheduleMap;
meta?: BookingMeta;
booking_mode_locked?: boolean;
/** همهٔ مکان‌های پزشک — مطب شخصی و هر کلینیکی که عضوش است. فقط برای برچسب‌زدن. */
locations?: AddressData[];
/** مکان‌هایی که همین کاربر حق دارد روی برنامه بنشاند. */
selectable_location_ids?: number[];
/** آیا همین کاربر می‌تواند قفلِ نوع نوبت‌دهی را باز کند — امروز فقط ادمین پلتفرم. */
booking_mode_changeable?: boolean;
}
// ── Persian (Jalali) date utilities ───────────────────────────────────────
@@ -561,14 +573,47 @@ function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) {
);
}
/**
* شیفتی که مکانش خارج از دسترس این کاربر است.
*
* حذفش از فهرست، برنامه را ناقص نشان می‌دهد و کاربر روی همان ساعت شیفت تازه می‌گذارد؛
* ویرایش‌پذیر کردنش هم یعنی مدیر کلینیک می‌تواند برنامهٔ مطب شخصی پزشک را عوض کند.
* پس دیده می‌شود و دست نمی‌خورد.
*/
function ForeignSessionRow({ session, placeName }: { session: SessionConfig; placeName: string }) {
return (
<div style={{
border: '1px dashed var(--border-2)', borderRadius: 'var(--r)', background: 'var(--surface-2)',
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
}}>
<LockClosedIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
{session.start_time} تا {session.end_time}
</span>
<span className="badge" style={{ flexShrink: 0 }}>{placeName}</span>
<span className="muted" style={{ fontSize: 12 }}>خارج از دسترس شما</span>
</div>
);
}
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
// برنامه یکی است و شیفت‌های همهٔ محیط‌های پزشک را دارد. مدیر کلینیک شیفت مطب شخصی
// را می‌بیند ولی نباید بتواند عوضش کند، پس این دو از هم جدا نگه داشته می‌شوند.
const [allLocations, setAllLocations] = useState<AddressData[]>([]);
const [selectableIds, setSelectableIds] = useState<number[] | null>(null);
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
const [expandedDay, setExpandedDay] = useState<string | null>(null);
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
const [modeLocked, setModeLocked] = useState(false);
// قفل برای همه هست، کلیدش فقط دست ادمین. نوعِ ثبت‌شده هم نگه داشته می‌شود تا
// بدانیم کاربر واقعاً عوضش کرده یا فقط ذخیرهٔ معمولی است.
const [modeChangeable, setModeChangeable] = useState(false);
const [savedMode, setSavedMode] = useState<BookingMeta['booking_mode'] | null>(null);
// پیام واقعی سرور دربارهٔ نوبت‌های آینده؛ تا وقتی null است دیالوگ تأیید بسته می‌ماند.
const [modeChangeWarning, setModeChangeWarning] = useState<string | null>(null);
const [confirmMode, setConfirmMode] = useState(false);
@@ -592,12 +637,28 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
setScheduleUuid(d.uuid);
}
if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta });
if (d?.locations) setAllLocations(d.locations);
if (d?.selectable_location_ids) setSelectableIds(d.selectable_location_ids.map(Number));
setModeLocked(!!d?.booking_mode_locked);
setModeChangeable(!!d?.booking_mode_changeable);
setSavedMode(d?.meta?.booking_mode ?? null);
} else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) {
setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null);
}
}, [scheduleQ.data, scheduleQ.error]);
// شیفت بدون مکان هنوز در حال ساخت است، پس دست کاربر باز می‌ماند.
const canEditSession = (session: SessionConfig) =>
selectableIds === null || session.location_id === null || selectableIds.includes(session.location_id);
const locationLabel = (locationId: number | null) => {
const found = (allLocations.length ? allLocations : addresses).find(a => Number(a.id) === locationId);
if (!found) return 'مکان نامشخص';
return found.type === 'clinic'
? (found.clinic_name ?? found.name ?? 'کلینیک')
: (found.name ?? 'مطب شخصی');
};
const overlapDays = useMemo(() =>
Object.fromEntries(SCHEDULE_DAYS.map(d => [d.key, hasOverlap(scheduleMap[d.key]?.sessions ?? [])]))
, [scheduleMap]);
@@ -614,22 +675,34 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
(scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null)
);
const modeChanged = savedMode !== null && savedMode !== meta.booking_mode;
const saveMut = useMutation({
mutationFn: () => {
mutationFn: ({ forceModeChange = false }: { forceModeChange?: boolean } = {}) => {
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است');
const body = { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null, force_mode_change: forceModeChange };
return scheduleUuid
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null })
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null });
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, body)
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, ...body });
},
onSuccess: (res) => {
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود
setSavedMode(meta.booking_mode);
toast.success('برنامه هفتگی ذخیره شد');
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] });
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error) => {
// سرور تغییرِ نوعِ ثبت‌شده را بار اول رد می‌کند و تعداد نوبت‌های فعال آینده را
// می‌گوید. همان جمله در دیالوگ تأیید نشان داده می‌شود، نه یک متن حدسی.
if (e instanceof ApiError && e.status === 422 && modeChanged) {
setModeChangeWarning(e.message);
return;
}
toast.error(e.message);
},
});
const setDaySessions = (key: string, sessions: SessionConfig[]) =>
@@ -734,13 +807,13 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<button
key={val}
type="button"
disabled={modeLocked}
onClick={() => !modeLocked && setMeta(m => ({ ...m, booking_mode: val }))}
disabled={modeLocked && !modeChangeable}
onClick={() => (!modeLocked || modeChangeable) && setMeta(m => ({ ...m, booking_mode: val }))}
className={`text-right p-3 rounded-lg border transition-colors ${
selected
? 'border-[var(--primary)] bg-[var(--primary)]/5'
: 'border-[var(--border)] bg-[var(--surface)]'
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
} ${modeLocked && !modeChangeable ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border-2)]'}`} />
@@ -751,7 +824,12 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
);
})}
</div>
{modeLocked ? (
{modeLocked && modeChangeable ? (
<p className="text-xs text-[var(--warning)] flex items-center gap-1.5">
<ExclamationTriangleIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبتدهی برای این پزشک ثبت شده است. فقط ادمین میتواند عوضش کند و نوبتهای ثبتشده با قواعد نوع قبلی محاسبه شدهاند.
</p>
) : modeLocked ? (
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست.
@@ -891,10 +969,14 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
</button>
</div>
) : sessions.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
serviceMode={meta.booking_mode === 'service'}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
canEditSession(session) ? (
<SessionEditor key={idx} session={session} addresses={addresses}
serviceMode={meta.booking_mode === 'service'}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
) : (
<ForeignSessionRow key={idx} session={session} placeName={locationLabel(session.location_id)} />
)
))}
</div>
)}
@@ -910,7 +992,10 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
</p>
)}
{!readOnly && (
<button type="button" onClick={() => modeLocked ? saveMut.mutate() : setConfirmMode(true)}
<button type="button" onClick={() => {
if (modeLocked) { saveMut.mutate({}); return; }
setConfirmMode(true);
}}
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
@@ -925,9 +1010,22 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
confirmLabel="ثبت و قفل"
loading={saveMut.isPending}
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }}
onConfirm={() => { setConfirmMode(false); saveMut.mutate({}); }}
onCancel={() => setConfirmMode(false)}
/>
{/* تغییر نوعِ ثبت‌شده مسیر جداست: بازگشتی ندارد و نوبت‌های آینده را زیر قواعد
تازه می‌برد. متن هشدار از خود سرور می‌آید تا تعداد واقعی گفته شود. */}
<ConfirmDialog
open={modeChangeWarning !== null}
danger
title="تغییر نوع نوبت‌دهی"
message={`${modeChangeWarning ?? ''}\n\nنوع نوبت‌دهی از «${savedMode ? MODE_LABELS[savedMode] : '—'}» به «${MODE_LABELS[meta.booking_mode]}» تغییر می‌کند و این کار برگشت‌پذیر نیست.`}
confirmLabel="بله، تغییر بده"
loading={saveMut.isPending}
onConfirm={() => { setModeChangeWarning(null); saveMut.mutate({ forceModeChange: true }); }}
onCancel={() => setModeChangeWarning(null)}
/>
</div>
);
}
+11 -2
View File
@@ -105,11 +105,20 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
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[] } }>({
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 } }>({
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 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>>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
queryKey: ['insurance-pricing', doctorUuid ?? null],
queryFn: () => api.get(`/api/v1/insurance-pricing${scopeQuery}`),
enabled,
});
const contractsQuery = useQuery<ApiResponse<{ data: TenantContract[] }>>({
queryKey: ['tenant-insurances'],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
queryKey: ['tenant-insurances', doctorUuid ?? null],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${scopeQuery}`),
enabled,
});
@@ -0,0 +1,74 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { makeClient } from '../test/utils';
import { useAuthStore } from '../stores/authStore';
vi.mock('../lib/api', () => ({
api: { get: vi.fn() },
}));
import { api } from '../lib/api';
import { useSubscription } from './useSubscription';
const get = api.get as ReturnType<typeof vi.fn>;
const initial = useAuthStore.getInitialState();
function planResponse(name: string) {
return {
success: true,
data: {
subscription: null,
used_trial: false,
effective_plan: { name, features: { patient_records: name !== 'free' }, max_secretaries: 1, max_resources: 1 },
},
};
}
beforeEach(() => {
localStorage.clear();
useAuthStore.setState(initial, true);
get.mockReset();
});
describe('useSubscription', () => {
/**
* پزشکی که هم مطب شخصی دارد و هم کلینیک، دو محیط دارد و اشتراک روی محیط می‌نشیند.
* با کلیدِ بدون محیط، پاسخِ cache شدهٔ محیط قبلی در محیط تازه سرو می‌شد و هر دو
* محیط ارتقایافته به‌نظر می‌رسیدند.
*/
it('پاسخِ cache شدهٔ محیط دیگر را سرو نمی‌کند', async () => {
const client = makeClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
// محیط کلینیک از قبل در cache نشسته است.
client.setQueryData(['subscription-my', 'clinic-1'], planResponse('professional'));
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doctor-1' });
get.mockResolvedValue(planResponse('free'));
const { result } = renderHook(() => useSubscription(), { wrapper });
await waitFor(() => expect(result.current.planLoaded).toBe(true));
expect(result.current.hasFeature('patient_records')).toBe(false);
expect(get).toHaveBeenCalledWith('/api/v1/subscription/my');
});
it('در همان محیط، پاسخِ cache شده دوباره درخواست نمی‌شود', async () => {
const client = makeClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
client.setQueryData(['subscription-my', 'clinic-1'], planResponse('professional'));
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic-1' });
const { result } = renderHook(() => useSubscription(), { wrapper });
await waitFor(() => expect(result.current.hasFeature('patient_records')).toBe(true));
expect(get).not.toHaveBeenCalled();
});
});
+13 -5
View File
@@ -6,10 +6,14 @@ import type { MySubscriptionData } from '../types';
export function useSubscription() {
const primaryRole = useAuthStore((s) => s.primaryRole);
const dbUuid = useAuthStore((s) => s.dbUuid);
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';
// کلید شامل محیط فعال است: اشتراک روی محیط می‌نشیند، نه روی کاربر. پزشکی که هم
// مطب شخصی دارد و هم کلینیک، با کلیدِ بدون محیط پلنِ محیط قبلی را می‌دید و هر دو
// محیط ارتقایافته به‌نظر می‌رسیدند.
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
queryKey: ['subscription-my'],
queryKey: ['subscription-my', dbUuid],
queryFn: () => api.get('/api/v1/subscription/my'),
enabled,
staleTime: 2 * 60 * 1000,
@@ -17,13 +21,17 @@ export function useSubscription() {
const sub = data?.data?.subscription ?? null;
const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null;
const features: Record<string, boolean> = effectivePlan?.features ?? {};
const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1;
// سقف‌ها و قابلیت‌ها از پلنِ محیطِ فعال می‌آیند، نه از پلنِ محیطِ مالکیت: سرور هم با
// همین محیط می‌سنجد. پزشکِ مهمانِ یک کلینیک، منابعش را از سهمیهٔ کلینیک میزبان
// برمی‌دارد ولی سقفِ پلنِ خودش نمایش داده می‌شد و عددِ سهمیه بی‌معنی بود.
const gatePlan = data?.data?.context_plan ?? effectivePlan;
const features: Record<string, boolean> = gatePlan?.features ?? {};
const maxSecretaries: number = gatePlan?.max_secretaries ?? 1;
// `-1` یعنی بی‌نهایت.
const maxResources: number = effectivePlan?.max_resources ?? 1;
const maxResources: number = gatePlan?.max_resources ?? 1;
// نقش‌هایی که اشتراک ندارند (ادمین) و لحظهٔ پیش از رسیدن پاسخ: سقف ناشناخته است و
// نباید با پیش‌فرضِ ۱ به‌جای کاربر تصمیم گرفت — گیت‌کردن کارِ سرور است.
const planLoaded = effectivePlan !== null;
const planLoaded = gatePlan !== null;
const hasPlan = sub !== null;
return {
@@ -14,6 +14,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const del = api.delete as ReturnType<typeof vi.fn>;
const PLANS = [
{
@@ -213,7 +214,27 @@ describe('AdminSubscriptionPage — اعطای اشتراک', () => {
});
describe('AdminSubscriptionPage — گزارش', () => {
beforeEach(() => { get.mockReset(); post.mockReset(); });
beforeEach(() => { get.mockReset(); post.mockReset(); del.mockReset(); });
/** یک ردیف گزارشِ اعطایی — تست‌های حذف و ناوبری از همین استفاده می‌کنند. */
function mockReportRow() {
get.mockImplementation((url: string) => {
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS });
if (url.includes('/admin/subscription/report')) {
return Promise.resolve({
success: true,
meta: { totalRecords: 1 },
data: [{
uuid: 's-1', entityType: 'clinic', entityId: 11, entityName: 'کلینیک نمونه',
isTrial: false, isGranted: true, grantedBy: 'ادمین',
startsAt: 1700000000, expiresAt: 1800000000, createdAt: 1700000000,
plan_name: 'professional', plan_level: 2,
}],
});
}
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
});
}
it('اشتراک اعطایی را «اعطایی» نشان می‌دهد، نه «پولی»', async () => {
get.mockImplementation((url: string) => {
@@ -241,4 +262,27 @@ describe('AdminSubscriptionPage — گزارش', () => {
expect(screen.getByText('ادمین')).toBeInTheDocument();
expect(screen.queryByText('پولی')).not.toBeInTheDocument();
});
it('حذف اشتراک پس از تأیید، DELETE می‌فرستد', async () => {
mockReportRow();
del.mockResolvedValue({ success: true, data: null });
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
fireEvent.click(await screen.findByText('گزارش فروش'));
fireEvent.click(await screen.findByLabelText('حذف اشتراک کلینیک نمونه'));
fireEvent.click(await screen.findByRole('button', { name: 'حذف' }));
await waitFor(() => expect(del).toHaveBeenCalledWith('/api/v1/admin/subscription/s-1'));
});
it('دکمهٔ اعطای اشتراک از گزارش به تب اعطا می‌برد', async () => {
mockReportRow();
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
fireEvent.click(await screen.findByText('گزارش فروش'));
fireEvent.click(await screen.findByRole('button', { name: /اعطای اشتراک جدید/ }));
expect(await screen.findByText(/پلن و دوره/)).toBeInTheDocument();
});
});
+43 -2
View File
@@ -626,8 +626,10 @@ function GrantTab() {
// ── Report tab ────────────────────────────────────────────────────────────
function ReportTab() {
function ReportTab({ onAdd }: { onAdd: () => void }) {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [deleteRow, setDeleteRow] = useState<ReportRow | null>(null);
const limit = 20;
const { data, isLoading } = useQuery({
@@ -635,11 +637,27 @@ function ReportTab() {
queryFn: () => api.get<PaginatedResponse<ReportRow>>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`),
});
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/admin/subscription/${uuid}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-subscription-report'] });
qc.invalidateQueries({ queryKey: ['admin-grant-active'] });
setDeleteRow(null);
toast.success('اشتراک حذف شد');
},
onError: (e: any) => { setDeleteRow(null); toast.error(e.message); },
});
const rows: ReportRow[] = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
return (
<div className="card">
<div className="card-pad" style={{ display: 'flex', justifyContent: 'flex-end', borderBottom: '1px solid var(--border)' }}>
<button type="button" className="btn primary" onClick={onAdd}>
<PlusIcon style={{ width: 15 }} /> اعطای اشتراک جدید
</button>
</div>
{isLoading ? (
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : rows.length === 0 ? (
@@ -655,6 +673,7 @@ function ReportTab() {
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>انقضا</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
<th style={{ textAlign: 'left', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
</tr>
</thead>
<tbody>
@@ -689,6 +708,17 @@ function ReportTab() {
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بینهایت</span>}
</td>
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
<td style={{ padding: '10px 16px', textAlign: 'left' }}>
<button
type="button"
className="btn sm"
title="حذف اشتراک"
aria-label={`حذف اشتراک ${row.entityName ?? row.entityId}`}
onClick={() => setDeleteRow(row)}
>
<TrashIcon style={{ width: 13 }} />
</button>
</td>
</tr>
))}
</tbody>
@@ -698,6 +728,17 @@ function ReportTab() {
</div>
</>
)}
<ConfirmDialog
open={!!deleteRow}
title="حذف اشتراک"
message={`آیا مطمئن هستید که می‌خواهید اشتراک «${deleteRow?.entityName ?? ''}» را حذف کنید؟ دسترسی این محیط به قابلیت‌های پلن قطع می‌شود.`}
confirmLabel="حذف"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteRow && deleteMut.mutate(deleteRow.uuid)}
onCancel={() => setDeleteRow(null)}
/>
</div>
);
}
@@ -722,7 +763,7 @@ export default function AdminSubscriptionPage() {
{tab === 'plans' && <PlansTab />}
{tab === 'grant' && <GrantTab />}
{tab === 'report' && <ReportTab />}
{tab === 'report' && <ReportTab onAdd={() => setTab('grant')} />}
</>
);
}
+6
View File
@@ -92,6 +92,12 @@ export default function PaymentDetailPage() {
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
<InfoRow
label="مبدأ"
value={payment.origin
? <span dir="ltr" title={payment.frontend_address ?? undefined}>{payment.origin}</span>
: null}
/>
<InfoRow label="شماره مرجع" value={payment.ref_id ? <span dir="ltr" className="font-mono text-xs">{payment.ref_id}</span> : null} />
<InfoRow label="شماره کارت" value={payment.card_pan ? <span dir="ltr" className="font-mono text-xs">{payment.card_pan}</span> : null} />
<InfoRow label="تاریخ پرداخت" value={formatDateTime(payment.paid_at)} />
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
const navigate = vi.fn();
vi.mock('react-router', async (orig) => ({
...(await orig<typeof import('react-router')>()),
useNavigate: () => navigate,
}));
vi.mock('../lib/api', () => ({ api: { get: vi.fn() }, ApiError: class extends Error {} }));
import { api } from '../lib/api';
import PaymentsPage from './PaymentsPage';
const get = api.get as ReturnType<typeof vi.fn>;
const ROWS = [
{ uuid: 'pay-1', amount: 1500000, status: 'success', gateway: 'mellat', ref_id: '99123',
origin: 'nobat724.com', patient_mobile: '09120000001', appointment_uuid: null,
paid_at: '2026-08-19T10:00:00+00:00', created_at: '2026-08-19T09:55:00+00:00' },
// پرداخت‌های قدیمی آدرس بازگشت ندارند و مبدأشان ناشناخته است.
{ uuid: 'pay-2', amount: 900000, status: 'failed', gateway: 'mellat', ref_id: null,
origin: null, patient_mobile: '09120000002', appointment_uuid: null,
paid_at: null, created_at: '2026-08-18T09:00:00+00:00' },
];
beforeEach(() => {
navigate.mockReset();
get.mockReset();
get.mockResolvedValue({ success: true, data: ROWS, meta: { totalRecords: 2, totalPages: 1, currentPage: 1 } });
});
describe('PaymentsPage (پرداخت‌های ادمین)', () => {
it('دامنهٔ مبدأ هر پرداخت را نشان می‌دهد', async () => {
renderWithProviders(<PaymentsPage />, { route: '/admin/payments' });
expect(await screen.findByText('nobat724.com')).toBeInTheDocument();
expect(screen.getByText('مبدأ')).toBeInTheDocument();
});
it('جستجو دامنه را هم به سرور می‌فرستد', async () => {
renderWithProviders(<PaymentsPage />, { route: '/admin/payments?search=nobat724.com' });
await waitFor(() =>
expect(get).toHaveBeenCalledWith(expect.stringContaining('search=nobat724.com')),
);
});
});
+8 -1
View File
@@ -75,6 +75,13 @@ export default function PaymentsPage() {
header: 'درگاه',
render: (p) => <span className="chip" style={{ fontSize: 12 }}>{p.gateway}</span>,
},
{
key: 'origin',
header: 'مبدأ',
render: (p) => p.origin
? <span className="chip" dir="ltr" style={{ fontSize: 12 }}>{p.origin}</span>
: <span className="muted"></span>,
},
{
key: 'ref_id',
header: 'شماره مرجع',
@@ -142,7 +149,7 @@ export default function PaymentsPage() {
<div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
placeholder="جستجو بر اساس موبایل یا شماره مرجع..."
placeholder="جستجو بر اساس موبایل، شماره مرجع یا دامنه..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import { makeClient } from '../test/utils';
import { useAuthStore } from '../stores/authStore';
import SelectContextPage from './SelectContextPage';
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => vi.fn() };
});
const initial = useAuthStore.getInitialState();
const CONTEXTS = [
{ db_uuid: 'doc-1', db_key: 'k1', type: 'doctor', role: 'doctor', name: 'مطب شخصی' },
{ db_uuid: 'clinic-1', db_key: 'k2', type: 'clinic', role: 'clinic', name: 'کلینیک تست' },
];
beforeEach(() => {
localStorage.clear();
useAuthStore.setState(initial, true);
useAuthStore.setState({ availableContexts: CONTEXTS as any, switchContext: vi.fn() as any });
});
describe('SelectContextPage', () => {
/**
* هر پاسخِ cache شده متعلق به محیط قبلی است. اشتراک روی محیط می‌نشیند، پس بدون
* پاک کردن cache، مطب شخصی پلن کلینیک را نشان می‌داد و برعکس.
*/
it('بعد از تعویض محیط، cache کوئری‌ها را پاک می‌کند', async () => {
const client = makeClient();
client.setQueryData(['subscription-my', 'doc-1'], { stale: true });
render(<SelectContextPage />, {
wrapper: ({ children }) => (
<QueryClientProvider client={client}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
),
});
fireEvent.click(screen.getByText('کلینیک تست'));
await waitFor(() => expect(client.getQueryData(['subscription-my', 'doc-1'])).toBeUndefined());
});
});
+5
View File
@@ -1,5 +1,6 @@
import { useNavigate } from 'react-router';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore, ContextItem } from '../stores/authStore';
const ROLE_LABELS: Record<string, string> = {
@@ -18,11 +19,15 @@ const TYPE_ICONS: Record<string, string> = {
export default function SelectContextPage() {
const { availableContexts, switchContext } = useAuthStore();
const navigate = useNavigate();
const qc = useQueryClient();
const [loading, setLoading] = useState<string | null>(null);
const handleSelect = async (ctx: ContextItem) => {
setLoading(ctx.db_uuid);
await switchContext(ctx.db_uuid);
// هر پاسخِ cache‌شده متعلق به محیط قبلی است — از اشتراک و پلن گرفته تا بیماران و
// نوبت‌ها. بدون پاک کردن، محیط تازه داده و دسترسی‌های محیط قبلی را نشان می‌دهد.
qc.clear();
navigate('/admin/dashboard', { replace: true });
};
+15 -1
View File
@@ -203,6 +203,10 @@ export interface Payment {
status: PaymentStatus;
gateway: PaymentGateway;
ref_id: string | null;
/** دامنهٔ مبدأ پرداخت (سایت نوبت‌دهی یا پنل)، از آدرس بازگشت استخراج می‌شود. */
origin: string | null;
/** آدرس بازگشتِ کامل؛ فقط در جزئیات می‌آید. */
frontend_address?: string | null;
card_pan?: string | null;
refunds?: { amount: number; ref: string; at: number }[];
patient_mobile: string;
@@ -600,7 +604,7 @@ export interface MySubscriptionData {
days_remaining?: number;
} | null;
used_trial: boolean;
/** پلن مؤثر: پلن اشتراک فعال یا پلن پیش‌فرض free در نبود اشتراک. */
/** پلن مؤثرِ محیطِ **مالکیت** — مبنای خرید و ارتقا. */
effective_plan: {
name: string;
level: number;
@@ -608,6 +612,16 @@ export interface MySubscriptionData {
max_resources: number;
features: Record<string, boolean>;
} | null;
/**
* پلن محیطی که کاربر همین حالا در آن ایستاده — مبنای سقف‌ها و قفل قابلیت‌ها،
* چون سرور هم با همین محیط می‌سنجد. برای کاربری که فقط محیط خودش را دارد با
* `effective_plan` یکی است.
*/
context_plan: {
max_secretaries: number;
max_resources: number;
features: Record<string, boolean>;
} | null;
}
/** @deprecated use MySubscriptionData */
export interface MySubscription {
+7 -7
View File
@@ -10,12 +10,12 @@
"ext-ctype": "*",
"ext-iconv": "*",
"ext-soap": "*",
"altcha-org/altcha": "^2.0",
"doctrine/doctrine-bundle": ">=2.18.3",
"altcha-org/altcha": "^2.1",
"doctrine/doctrine-bundle": ">=2.19.0",
"doctrine/doctrine-migrations-bundle": "*",
"doctrine/orm": "^3.6",
"doctrine/orm": "^3.6.8",
"lexik/jwt-authentication-bundle": "*",
"nelmio/api-doc-bundle": "*",
"nelmio/api-doc-bundle": ">=5.11.1",
"nelmio/cors-bundle": "*",
"symfony/asset": "7.4.*",
"symfony/cache": "7.4.*",
@@ -42,7 +42,7 @@
"symfony/webpack-encore-bundle": "^2.4.1",
"symfony/yaml": "7.4.*",
"twig/twig": ">=3.28",
"zircote/swagger-php": ">=6.4"
"zircote/swagger-php": ">=6.6"
},
"config": {
"allow-plugins": {
@@ -98,10 +98,10 @@
}
},
"require-dev": {
"phpstan/phpstan": "^2.2.5",
"phpstan/phpstan": "^2.2.8",
"phpstan/phpstan-doctrine": "^2.0.28",
"phpstan/phpstan-symfony": "^2.0.20",
"phpunit/phpunit": "^12.5.31",
"phpunit/phpunit": "^12.5.33",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
Generated
+283 -283
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
# Practice domain belongs to the tenant, not only to the clinic
`practice_domain_id` originally lived on `clinics`, but every piece of operational data in this
codebase is owned by an `(entity_type, entity_id)` pair where `entity_type` is `doctor` or `clinic`.
A solo practice is a `doctor` tenant, so it could never declare a practice domain at all — the beauty
domain has the same hole, it simply had not been noticed. The column is therefore added to `doctors`
as well and read through a single `PracticeDomainResolver` that takes an `EntityContext`, so no
caller has to know which kind of tenant it is looking at.
## Considered Options
Letting a doctor inherit the domain of a clinic they work at was rejected: a doctor with no clinic
would stay domainless, and a doctor working at two clinics with different domains would be ambiguous.
@@ -0,0 +1,15 @@
# Teeth are not Treatment Areas
A Treatment Area is a `CatalogCategory` snapshotted onto a case, which made "one category per tooth"
look like a free way to get dental charting. It was rejected: FDI tooth numbering is a universal fact,
not a per-clinic taxonomy, so it would duplicate 32 to 52 identical rows into every clinic's service
tree, surfaces would need a further level below that, and the persistent condition of a tooth — missing,
crowned, implanted years before the patient ever arrived — has nowhere to live on a settings row.
A tooth is instead an FDI `smallint` on the record that targets it, and tooth condition is its own
snapshot table in the Dental context.
## Consequences
Tooth condition must be updated after each visit, and that projection lives in exactly one class rather
than being spread across controllers. In exchange, rendering a chart is one query and never a replay of
history.
@@ -0,0 +1,15 @@
# Dental attributes of a service live in an extension table, not on ServiceItem
Whether a service is priced per tooth, per surface or per canal — and whether booking it must ask for a
tooth at all — is dental-only knowledge, but `ServiceItem` is shared by every practice domain. Those
attributes therefore sit in a one-to-one `dental_service_profiles` row in the Dental context, keyed by
`service_item_id`, so a beauty clinic carries no dental columns and the next domain is not invited to add
its own set to the shared table. The cost is a join whenever the dental profile is needed, which is the
same pattern the codebase already uses elsewhere.
## Consequences
The reverse choice was made deliberately one level down: the tooth and surfaces a visit line was actually
billed for are columns on `SessionService` itself, because that row is the clinical and financial record
of the visit rather than shared configuration, and splitting it would allow a billed line to lose its
target unnoticed.
@@ -0,0 +1,13 @@
# Domain-specific dashboard metrics come from tagged providers
`DashboardController` already serves four role dashboards from 803 lines and fifteen dependencies, so
branching each of them on practice domain would double four code paths and make every clinic pay for
queries only dentists need. Domain metrics instead come from a `DomainMetricProvider` interface resolved
by a tagged-service registry keyed on the practice domain code — the same shape as `TreatmentWorkflow`
in ADR 0005 — and the role endpoints simply attach whatever the provider returns.
## Consequences
Unlike the workflow registry there is no default implementation: a tenant with no domain, or a domain with
no provider, gets `null` and the panel renders no extra section. An empty metrics block is worse than an
absent one.
+9 -1
View File
@@ -654,7 +654,7 @@ List all payments.
### Query Parameters (تکمیل)
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id` یا `order_id` |
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id`، `order_id` یا دامنهٔ مبدأ |
### Response `200`
```json
@@ -667,6 +667,7 @@ List all payments.
"status": "success",
"gateway": "mellat",
"ref_id": "1234567",
"origin": "nobat724.com",
"patient_mobile": "0912...",
"paid_at": "2026-07-02T09:00:00+03:30",
"created_at": "2026-07-02T08:55:00+03:30"
@@ -678,6 +679,11 @@ List all payments.
> `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر این‌صورت `null`. تاریخ‌ها ISO-8601.
> `origin` دامنهٔ مبدأ پرداخت است — از `frontend_address` (آدرس بازگشت) استخراج
> می‌شود، با حروف کوچک و بدون `www.`، تا یک دامنه در گزارش یک مقدار باشد. چند مبدأ
> به یک درگاه می‌روند: سایت‌های نوبت‌دهی شهری و پنل خودِ کلینیک‌پرو. پرداخت بدون آدرس
> بازگشت `null` می‌گیرد.
---
### GET `/api/v1/admin/payments/{uuid}`
@@ -698,6 +704,8 @@ List all payments.
"gateway": "mellat",
"type": "appointment",
"ref_id": "1234567",
"origin": "nobat724.com",
"frontend_address": "https://nobat724.com/payment/result",
"card_pan": "502229******2928",
"patient_mobile": "0912...",
"patient_name": "علی احمدی",
+46 -8
View File
@@ -15,8 +15,20 @@ Every endpoint in this file operates inside **one booking context**, selected by
| omitted / `null` | the doctor's **personal practice** | `entity_type='doctor'` | the doctor's own `personal` addresses |
| a clinic uuid | that **doctor inside that clinic** | `entity_type='clinic'` | that clinic's addresses |
A doctor holds **one schedule per context** — a personal one plus one per clinic — and they are
fully independent: separate sessions, separate `booking_mode` lock, separate date overrides.
**Breaking change (2026-08): a doctor holds exactly ONE weekly schedule**, no matter how many clinics
they work at. What varies from day to day is the *place*: Saturday at the personal practice, Monday at
the clinic. The context of a shift is read from the `location_id` on that shift, not from the record it
lives in. Consequences:
- `clinic_uuid` no longer selects *which record* is read or written — every context reads the same one.
- It still selects **which addresses the caller may assign**, and **which days a booking context sees**:
a personal-practice secretary never sees the clinic days and cannot book on them, and vice versa.
- A shift whose address belongs to another context is returned to the caller for display but is
preserved verbatim on save — a clinic manager can neither edit nor delete the doctor's personal shifts.
- `booking_mode` and the rest of `meta` are now doctor-wide, because there is one record. The existing
"mode is locked after the first save" rule therefore applies across contexts.
- Date overrides and holidays are unchanged and remain per-context.
Services never cross the boundary (they are polymorphic on `service_sections.entity_type`).
If the doctor is not a member of the given clinic → `422 ERR_VALIDATION_001`
@@ -43,7 +55,10 @@ Anything else → `403 ERR_AUTH_006`.
## Weekly Schedule
Each doctor has **one weekly schedule per context** (upsert keyed by `doctor_id` + `clinic_id`).
Each doctor has **exactly one weekly schedule** (upsert keyed by `doctor_id`; the legacy `clinic_id`
column stays `NULL` on new rows). Rows created before 2026-08 were merged by
`migrations/Version20260820120000.php`, which appended each clinic record's sessions into the doctor's
single record — the `location_id` already on every shift carries the context.
The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions`
array.
@@ -69,8 +84,18 @@ Create or update the weekly schedule for a doctor (upsert).
> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود.
>
> **الزام محیط:** آدرس انتخاب‌شده باید به همان context تعلق داشته باشد. آدرس کلینیک در محیط شخصی (و برعکس) → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»).
> **الزام مکان:** آدرس هر شیفت باید در فهرست `available-locations` همان درخواست‌کننده باشد. پزشک هر دو محیط خودش را دارد؛ کلینیک فقط آدرس خودش. آدرس بیرون از این فهرست → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»).
>
> **ادغام هنگام ذخیره:** شیفت‌هایی که آدرسشان بیرون از دسترس درخواست‌کننده است، از نسخهٔ ذخیره‌شده دست‌نخورده برمی‌گردند؛ ورودی نه می‌تواند حذفشان کند نه عوضشان.
>
> **تغییر نوع نوبت‌دهی:** نوع پس از اولین ثبت قفل می‌شود، چون نوبت‌های ثبت‌شده با قواعد همان نوع محاسبه شده‌اند. فقط `ROLE_ADMIN` می‌تواند بازش کند:
>
> - غیر ادمین → `422 ERR_VALIDATION_001` روی `booking_mode` («نوع نوبت‌دهی پس از ثبت قابل تغییر نیست»).
> - ادمین، وقتی نوبت فعالی در ۳۶۵ روز آینده هست → `422` با تعداد آن نوبت‌ها. درخواست دوم با `force_mode_change: true` انجام می‌شود.
> - ادمین، بدون نوبت فعال آینده → بدون نیاز به `force_mode_change` انجام می‌شود.
>
> `force_mode_change` از سمت غیر ادمین بی‌اثر است.
> **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند.
### Request Body (`application/json`)
@@ -694,8 +719,12 @@ Returns all locations a doctor can assign as `location_id` in their schedule ses
| Field | Type | Meaning |
|---|---|---|
| `clinic_uuid` | `string\|null` | the clinic this schedule belongs to; `null` = personal practice |
| `context` | `"personal" \| "clinic"` | convenience mirror of the above |
| `clinic_uuid` | `string\|null` | **legacy**, always `null` on a weekly schedule — the record is no longer owned by one context |
| `context` | `"personal" \| "clinic"` | **legacy**, always `"personal"` for the same reason |
| `locations` | `array` | every address of this doctor (personal + each clinic they belong to), for labelling shifts the caller may not edit — `GET` only |
| `selectable_location_ids` | `int[]` | the subset of those the **caller** may assign — `GET` only |
| `booking_mode_locked` | `bool` | a mode has already been stored |
| `booking_mode_changeable` | `bool` | this caller may unlock it — `ROLE_ADMIN` only, `GET` only |
`DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid`
plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below).
@@ -726,8 +755,17 @@ union, because an override changes working hours and working hours are themselve
### `GET /available-locations/{doctorUuid}`
Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it,
only that clinic's addresses. The two sets are never merged (they used to be).
Returns the addresses the **caller** may assign, which is not the same as the addresses of the current
context:
| Caller | Returned |
|---|---|
| the doctor themselves, or `ROLE_ADMIN` | every address of theirs — personal **and** each clinic they belong to |
| anyone else (clinic manager, secretary) | only the addresses of the context in `?clinic_uuid=` |
The doctor gets the full set because their schedule is a single one and they move between places from
day to day; a clinic manager gets only its own so it cannot move a shift into the doctor's private
practice.
---
+19 -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` — عمداً بدون مجوزِ رجیستری.
> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی
> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک،
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت.
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و
> مجوز دست‌نخورده باقی می‌ماند.
> این اندپوینت `a.user = خودِ کاربر` را می‌دهد، یعنی نوبت‌های خودِ فرد **به‌عنوان
> بیمار**، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است.
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
@@ -1313,6 +1327,10 @@ the JWT firewall, so a valid bearer + `management=1` enables management mode (`n
Lists every place the doctor can be booked at. The site should show **all** of them, grouped by
location — picking one and hiding the rest removes real capacity from the doctor.
Since 2026-08 the doctor has a **single** weekly schedule, so this endpoint iterates over the doctor's
*places* (personal practice + each clinic they belong to) rather than over schedule records, and keeps
for each place only the shifts whose `location_id` belongs to it. The response shape is unchanged.
```json
{
"success": true,
+7 -2
View File
@@ -150,7 +150,7 @@ limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`.
"linkedin": null
},
"caption": "توضیحات کلینیک",
"list_bime": [],
"list_bime": [{ "uuid": "...", "id": "176", "name": "تامین اجتماعی", "logo_url": "/uploads/insurances/logo/2026-08/tamin.png" }],
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
"services": [],
"clinic_specialty": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
@@ -167,6 +167,11 @@ limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`.
}
```
> `list_bime[].logo_url` مسیر نسبی روی همین API است (`/uploads/...`) و کلاینت باید آن را با دامنهٔ API کامل کند. بیمهٔ بدون لوگو `null` می‌گیرد، نه کلید غایب.
> 🔒 `phone`/`phone_number` are `null` unless the caller may edit the clinic (`can_edit: true`). The number is not public data; the patient sees the venue phone on their own appointment instead.
>
> `city`/`state`/`map`/`location`/`phone`/`phone_number` are all resolved from the clinic's **address** (`DoctorAddress` linked by `clinic_id`), not from columns on the clinic. `location` and `phone`/`phone_number` fall back to the deprecated `clinics.address` / `clinics.telephone` columns only when the address record has no value — reading them from different rows made one response describe two different places. Each is an array with a single object (or empty `[]` if the clinic has no address). `doctors` is a **count**; the actual doctor list comes from `GET /api/v1/clinic/doctor-list/{clinicUuid}` (`doctor_list` here is always `null`).
### معنای `is_active`
@@ -330,7 +335,7 @@ List clinics with pagination.
| `doctors_count` | integer | Number of doctors linked to the clinic |
| `city` | string\|null | City 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 |
| `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": [],
"state": [],
"city": [],
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }],
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": null }],
"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`
>
> `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
| Code | HTTP | Description |
@@ -171,7 +178,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
"uuid": "...",
"title": "علی احمدی",
"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 |
|-------|------|----------|-------------|
| `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` بیرون از TenantFilter خوانده می‌شوند: مقصدِ این
> تنظیم پزشک است در حالی که محیط فعالِ مالکِ کلینیک، خودِ کلینیک است. مجوزش همان بررسی
> بالاست. تا پیش از این، خواندن به محیط کاربر محدود می‌شد و ردیف موجود دیده نمی‌شد —
> هر ذخیره یک ردیف تازه می‌ساخت (قیدِ یکتا ردیفِ ویزیت آزاد را نمی‌گیرد چون `insurance_id`
> آنجا `NULL` است) و مقدار ذخیره‌شده هرگز به پنل برنمی‌گشت.
### Response `200`
```json
{
@@ -513,11 +520,18 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
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`.
### GET `/api/v1/billing/tenant-insurances`
لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.
> پارامتر `inherit=1` همان قاعدهٔ محدودهٔ بیمه را اعمال می‌کند: اول قراردادهای خودِ پزشک، و اگر پزشک هیچ قراردادی نداشته باشد قراردادهای کلینیکی که کاربر در آن ایستاده. بدون این پارامتر، پاسخ دقیقاً همان محیطِ هدف است — چیزی که صفحهٔ تنظیمات برای ویرایش لازم دارد.
**Query:** `doctor_uuid` (اختیاری) — قراردادهای همان پزشک را برمی‌گرداند (نگاه کنید به «تنظیمات per-doctor» بالا).
**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`
> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی
> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک،
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت.
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و
> مجوز دست‌نخورده باقی می‌ماند.
### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
@@ -405,6 +411,12 @@ Get payment status and details.
**Permission:** `AUTH` — must be the payment owner or `ROLE_ADMIN`
> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی
> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک،
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت.
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و
> مجوز دست‌نخورده باقی می‌ماند.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
+21 -1
View File
@@ -165,6 +165,8 @@ Delete a representation.
Get monthly earnings dashboard for a representation.
`total_appointments` فقط نوبت‌های **آنلاین** را می‌شمارد — یعنی نوبت‌هایی که از سایتِ همین نماینده رزرو شده‌اند (`appointments.booking_representation_id` برابر همین نماینده). نوبتی که منشی در پنل ثبت می‌کند از سایت نیامده و در آمار نماینده نمی‌آید.
**Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN`
### Path Parameters
@@ -264,15 +266,33 @@ Get yearly earnings dashboard for a representation.
## قانون کمیسیون دامنه‌محور
کمیسیون (نوبت **و** اشتراک) فقط وقتی ثبت می‌شود که **هر دو** شرط برقرار باشد:
1. دامنه‌ی مبدأ خرید (`payment.frontend_address`) متعلق به یک نماینده‌ی فعال باشد (`representations.domain`).
1. دامنه‌ی مبدأ خرید (`payment.frontend_address`) به یک نماینده‌ی فعال برسد.
2. پزشک/کلینیکِ موضوع خرید، `representation_id` همان نماینده را داشته باشد.
دامنه به نماینده به این ترتیب می‌رسد:
- نماینده‌ای که همان دامنه را در `representations.domain` ثبت کرده (نماینده‌ی سراسری).
- وگرنه اگر دامنه، دامنه‌ی یک شهر باشد (`cities.domain`)، نماینده‌ی فعالِ همان شهر از `representation_cities`.
اگر دو نماینده‌ی فعال یک شهر را پوشش دهند، نماینده‌ای انتخاب نمی‌شود: انتساب پول مبهم است و باید در داده صریح شود.
در غیر این صورت هیچ کمیسیونی برای هیچ نماینده‌ای ثبت نمی‌شود (پرداخت بدون `frontend_address` هم کمیسیون ندارد). درصد: نوبت = `commission_percent` نماینده؛ اشتراک = تنظیم سراسری `upgrade_commission_percent`. نگاشت دامنه فقط از طریق `DomainContextResolver` انجام می‌شود.
**زمان ثبت:** کمیسیون نوبت در لحظه‌ی **پرداخت موفق** ثبت می‌شود، نه در لحظه‌ی تأیید نوبت. نوبتِ `pending` هم کمیسیون دارد؛ تأیید کارِ پزشک/منشی است و ممکن است هرگز انجام نشود. ثبت idempotent است و مسیر تأیید دوباره چیزی نمی‌سازد.
---
## پنل نماینده (ROLE_REPRESENTATION)
> **شمارش نوبت:** `dashboard/summary` و `doctors/performance` هم فقط نوبت‌های آنلاینِ همین نماینده را می‌شمارند (`appointments.booking_representation_id`). نوبتی که منشی در پنل ثبت می‌کند شمرده نمی‌شود. ستون درآمدِ هر پزشک هم فقط سهم همین نماینده است، نه سهم نمایندگان قبلیِ آن پزشک.
>
> **بازسازی گذشته:** نوبت‌های آنلاینی که پیش از نگاشت دامنه‌ی شهری پرداخت شده‌اند نه `booking_representation_id` دارند و نه ردیف `FinancialBreakdown`. دستور زیر هر دو را از روی `payments.frontend_address` می‌سازد؛ بدون `--force` فقط گزارش می‌دهد و تاریخ ردیف مالی روی لحظه‌ی پرداخت می‌نشیند، نه لحظه‌ی اجرا:
>
> ```
> php bin/console app:representation:backfill-online-commission [--force]
> ```
این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین می‌شود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمی‌شود.
> **Permission (همه‌ی این بخش):** `ROLE_REPRESENTATION`
+3 -3
View File
@@ -128,7 +128,7 @@ Create a secretary for a doctor.
}
```
- `created`: ردیف‌های تازه‌ساخته/فعال‌شده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ پلنِ آن پزشک پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیه‌ی پزشکان ادامه می‌یابند.
- `created`: ردیف‌های تازه‌ساخته/فعال‌شده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ منشیِ آن محیط پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیه‌ی پزشکان ادامه می‌یابند.
**Permissions Structure:**
@@ -255,7 +255,7 @@ Create a secretary for a doctor.
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor **in this same environment** — همان منشی برای همان پزشک در کلینیکِ دیگر ۴۰۹ نمی‌گیرد |
| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached |
| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached — سقف در هر محیط جداست: محیط کلینیک با پلن کلینیک، مطب شخصی با پلن خود پزشک. شمارش به تفکیک **شخص** است، نه ردیف؛ منشیِ متصل به چند پزشکِ یک کلینیک یک نفر شمرده می‌شود. |
---
@@ -506,7 +506,7 @@ Get all secretaries across **all doctors** of a clinic.
| ----------------------- | -------- | ------------------------------------------------ |
| `added` | int | تعداد ردیف‌های افزوده/فعال‌شده |
| `removed` | int | تعداد ردیف‌های غیرفعال‌شده |
| `skipped_limit` | string[] | uuid پزشکانی که به سقفِ پلن رسیده‌اند (نادیده گرفته) |
| `skipped_limit` | string[] | uuid پزشکانی که افزودنشان از سقفِ منشیِ محیط عبور می‌کرد (نادیده گرفته) |
| `skipped_not_in_clinic` | string[] | uuid پزشکانی که عضو این کلینیک نیستند |
### Errors
+47 -3
View File
@@ -82,7 +82,13 @@
**Permission:** `IS_AUTHENTICATED_FULLY`
**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار می‌کند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا می‌کند و اشتراک همان entity برگردانده می‌شود.
**محیط اشتراک:** همان محیطی که کاربر **صاحبش** است، از `EntityContextResolver::ownedEntity()` — همان مرجعی که خرید اشتراک هم استفاده می‌کند، تا نمایش و پرداخت و اعطای ادمین روی یک محیط بنشینند.
کاربری که هم پزشک است و هم مالک کلینیک، دو محیط صاحب‌شده دارد. آنجا محیط فعال
(`UserActiveContext`) تعیین می‌کند اشتراک کدام‌یک خوانده شود. بدون محیط فعال، مطب
شخصی پیش‌فرض است.
**نکته:** این endpoint برای `ROLE_SECRETARY` هم کار می‌کند. منشی محیط صاحب‌شده ندارد، پس محیط فعالش خوانده می‌شود و اشتراک همان entity برمی‌گردد.
**Response 200:**
```json
@@ -101,13 +107,21 @@
"is_active": true
},
"used_trial": false,
"effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} }
"effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} },
"context_plan": { "max_secretaries": 3, "max_resources": 3, "features": {...} }
}
}
```
`is_granted` یعنی این اشتراک را ادمین بدون پرداخت اعطا کرده است.
`effective_plan` و `context_plan` دو محیط متفاوت را جواب می‌دهند و برای کاربری که فقط محیط خودش را دارد یکی هستند:
- `effective_plan` و `subscription` و `used_trial` مالِ محیطِ **مالکیت**‌اند. مبنای خرید و ارتقا همین است.
- `context_plan` مالِ محیطی است که کاربر همین حالا **در آن ایستاده**. سرور سقف منابع و قفل قابلیت‌ها را با همین محیط می‌سنجد، پس پنل هم باید سقف‌ها و `hasFeature` را از این بخواند.
پزشکِ مهمانِ یک کلینیک نمونهٔ واگرایی است: منابعی که می‌سازد از سهمیهٔ کلینیک میزبان کم می‌شود، ولی اشتراکِ خودش همان اشتراک شخصی می‌ماند. `context_plan` فقط سقف‌ها و `features` را دارد؛ فیلدهای هویتیِ پلنِ محیط دیگر (`name`, `level`, `uuid`) در آن نمی‌آید.
اگر اشتراک فعالی نداشت `subscription` برابر `null` است، اما `effective_plan` همیشه مقدار دارد: پلن اشتراک فعال، یا در نبود اشتراک، **پلن پیش‌فرض `free`**. فرانت‌اند برای تعیین دسترسی به امکانات (`hasFeature`) باید از `effective_plan` استفاده کند (نه `subscription`) تا کاربرانِ بدون اشتراک هم امکانات پلن free را داشته باشند. `subscription`/`hasPlan` صرفاً برای نمایش وضعیت اشتراک پولی است.
### پاسخ کاهش‌یافته برای کاربرِ بدون مجوزِ `subscription.view` (2026-08)
@@ -123,7 +137,8 @@
{"success":true,"data":{
"subscription": null,
"used_trial": false,
"effective_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 }
"effective_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 },
"context_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 }
}}
```
@@ -340,6 +355,35 @@ callback مشترک همهٔ درگاه‌ها و همهٔ نوع‌های پر
> پلن. پس اعطای پلنی پایین‌تر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش می‌دهد. پنل
> ادمین قبل از ثبت این حالت تأیید می‌گیرد؛ خودِ endpoint جلوی آن را نمی‌گیرد.
### DELETE /api/v1/admin/subscription/{uuid}
**Permission:** `ROLE_ADMIN` — حذف اشتراک، برای برگرداندن اعطای اشتباه
`uuid` همان `uuid` ردیف گزارش است. حذف سخت است، نه soft delete: رکورد از
`clinic_subscriptions` پاک می‌شود و پلن مؤثر مقصد به اشتراک فعال بعدی یا به `free`
برمی‌گردد.
اشتراکِ متصل به پرداخت حذف نمی‌شود. سند مالی‌اش باید بماند و مسیر درست آن استرداد
وجه است.
**Response 200**
```json
{
"success": true,
"data": null
}
```
| وضعیت | کد | حالت |
|-------|----|------|
| 404 | ERR_SUBSCRIPTION_NOT_FOUND | uuid یافت نشد |
| 409 | ERR_CONFLICT_001 | اشتراک به پرداخت متصل است |
| 401 | ERR_AUTH_001 | بدون توکن |
| 403 | — | توکن معتبر ولی بدون `ROLE_ADMIN` |
> مسیر `DELETE /api/v1/admin/subscription/period/{uuid}` جداست و دورهٔ پلن را
> غیرفعال می‌کند، نه اشتراکِ یک مقصد را.
### GET /api/v1/admin/subscription/active/{entityType}/{entityUuid}
**Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا
+24
View File
@@ -76,6 +76,30 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده
| SQL خام DBAL | ❌ |
| فرزندان 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` سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها».
### تغییر رفتار: ۴۰۴ به‌جای ۴۰۳
+239
View File
@@ -0,0 +1,239 @@
# ماژول دندانپزشکی ClinicPro
> **دامنه:** `clinicpro` — بک‌اند Symfony و پنل ادمین React
> **وضعیت:** سند طراحی. کد نوشته نشده.
> **تاریخ:** 2026-08
> **خارج از دامنه:** بیمه. فقط نقطهٔ اتصال رزرو می‌شود.
این سند حاصل یک جلسهٔ تصمیم‌گیری روی کد واقعی ریپو است.
هر بند «چه» را می‌گوید و «چرا» را کنارش.
تصمیم‌ها در بخش ۳ فهرست‌اند و بقیهٔ سند نتیجهٔ آن‌هاست.
منبع اولیه‌اش سند `clinicpro-dental-module-technical-spec.md` در ریشهٔ workspace بود.
آن سند بدون دسترسی به کد نوشته شده بود و بخش بزرگی از چیزی که پیشنهاد داده، از قبل ساخته شده است.
بخش ۲ تفاوت‌ها را نشان می‌دهد.
---
## ۱. مسئله
خواسته یک جمله است.
وقتی یک کلینیک حوزهٔ فعالیت «دندانپزشکی» را انتخاب می‌کند، باید دسته‌بندی‌ها و تنظیمات دندانپزشکی برایش ساخته شود و داشبورد مخصوص خودش را ببیند.
امروز انتخاب حوزهٔ فعالیت فقط یک کلید خارجی روی جدول کلینیک می‌نویسد.
هیچ دسته‌ای، هیچ خدمتی، هیچ تنظیمی ساخته نمی‌شود.
یعنی کلینیک بعد از انتخاب حوزه، دقیقاً همان‌قدر خالی است که قبلش بود.
سه تکهٔ خواسته:
۱. با انتخاب دندانپزشکی، کاتالوگ خدمات دندانپزشکی ساخته شود.
۲. دندانپزشک بتواند وضعیت دندان‌های بیمار را ثبت و ببیند.
۳. مدیر و پزشک و پذیرش، شاخص‌های دندانپزشکی را در داشبورد ببینند.
---
## ۲. آنچه امروز واقعاً هست
این بخش از روی کد نوشته شده، نه از روی مستندات.
### ۲.۱ حوزهٔ فعالیت — هست، ولی فقط برچسب است
| چیز | مسیر |
|---|---|
| موجودیت | `src/PracticeDomain/Entity/PracticeDomain.php` |
| کنترلر | `src/PracticeDomain/Controller/PracticeDomainController.php` |
| مستندات | `docs/api/practice-domain.md` |
| اتصال به کلینیک | `src/Clinic/Entity/Clinic.php` ستون `practice_domain_id` |
| صفحهٔ پنل | `assets/admin/pages/PracticeDomainSettingsPage.tsx` |
جدول سراسری است و در `GlobalTables::ENTITIES` ثبت شده.
انتخاب حوزه از `PATCH` روی کلینیک با کلید `practice_domain_uuid` انجام می‌شود.
**نکتهٔ مهم:** `Doctor` این فیلد را ندارد.
ولی مالکیت داده در کل پروژه دو حالته است، `entity_type` برابر `doctor` یا `clinic`.
یعنی مطب تک‌پزشک امروز اصلاً نمی‌تواند حوزهٔ فعالیت انتخاب کند.
این یک نقص عمومی است و فقط به دندانپزشکی مربوط نیست؛ کلینیک زیبایی تک‌پزشکه هم همین مشکل را دارد.
### ۲.۲ رفتار مخصوص هر حوزه — الگویش ساخته شده
| چیز | مسیر |
|---|---|
| اینترفیس | `src/Treatment/Workflow/TreatmentWorkflow.php` |
| رجیستری | `src/Treatment/Workflow/TreatmentWorkflowRegistry.php` |
| پیش‌فرض | `src/Treatment/Workflow/DefaultTreatmentWorkflow.php` |
| نمونهٔ حوزه‌ای | `src/Treatment/Workflow/LaserTreatmentWorkflow.php` |
| تصمیم ثبت‌شده | `docs/adr/0005-treatment-workflows-are-tagged-services.md` |
سرویس‌ها با تگ `app.treatment_workflow` ثبت می‌شوند و رجیستری بر اساس کد حوزه انتخاب می‌کند.
افزودن دندانپزشکی یعنی یک کلاس تازه، نه دست‌بردن در مسیر رزرو.
### ۲.۳ کاتالوگ خدمات — کامل است
| موجودیت | نقش |
|---|---|
| `ServiceSection` | بخش سازمانی کلینیک |
| `CatalogCategory` | تاکسونومی درختی خدمات تا عمق ۴ |
| `ServiceItem` | خدمت با قیمت، مدت، تعداد جلسه، دستهٔ کاتالوگ |
| `ItemGroup` و `ItemGroupMember` | گروه‌بندی آیتم |
| `ServiceItemRelation` | رابطهٔ بین خدمات |
| `ServiceBranchOverride` | بازنویسی قیمت در شعبه |
مسیرها در `src/ClinicService/`.
اندپوینت‌های موجود در `docs/api/clinic-services.md`.
صفحات پنل: `ClinicServicesPage.tsx` و `CatalogCategoriesPage.tsx`.
### ۲.۴ یونیت و صندلی — لازم نیست ساخته شود
سند اولیه پیشنهاد جدول `chairs` و ستون `chairId` روی نوبت داده بود.
هر دو از قبل هستند و بهترند:
| چیز | مسیر |
|---|---|
| نوع منبع با `CODE_ROOM` | `src/Resource/Entity/ResourceType.php` |
| خود منبع | `src/Resource/Entity/ClinicResource.php` |
| تقویم و استثنا | `src/Resource/Entity/ResourceCalendar.php` و `ResourceException.php` |
| اتصال به نوبت | `src/Appointment/Entity/Appointment.php` ستون `resource_id` |
| اشغال واقعی | `resource_occupancy` |
| تصمیم ثبت‌شده | `docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md` |
منبع، ظرفیت و زمان آماده‌سازی و زمان تمیزکاری هم دارد.
یعنی «۱۵ دقیقه بین دو بیمار برای ضدعفونی یونیت» بدون کد جدید قابل تنظیم است.
### ۲.۵ دوره درمان و جلسه — هست، ولی معنایش با دندانپزشکی یکی نیست
| موجودیت | معنی امروز |
|---|---|
| `TreatmentProtocol` | قالب دوره‌ای یک خدمت، تعریف‌شده توسط مدیر |
| `TreatmentCase` | یک بیمار، **یک خدمت**، از اولین رزرو تا پایان دوره |
| `TreatmentSession` | جلسهٔ شماره‌دار همان دوره، بدون هیچ ستون پولی |
| `TreatmentCaseArea` | ناحیهٔ بدن، اسنپ‌شات از `CatalogCategory` |
| `SessionAreaRecord` | آنچه اپراتور روی هر ناحیه انجام داد |
| `PatientSession` | سابقهٔ مالی یک ویزیت انجام‌شده |
| `SessionService` | ردیف خدمت همان ویزیت با قیمت |
واژگان در `CONTEXT.md` تعریف شده و صریحاً «Treatment Plan» را ممنوع کرده، چون بین قالب و دوره ابهام می‌ساخت.
فرق بنیادی: `TreatmentCase` یک خدمت دارد.
طرح درمان دندانپزشکی ده‌ها خدمت روی دندان‌های مختلف دارد که بیمار بخشی را می‌پذیرد.
این دو یکی نیستند و نباید یکی شوند.
### ۲.۶ داشبورد — چهار اندپوینت نقشی
`src/Dashboard/Controller/DashboardController.php` با ۸۰۳ خط و ۱۵ وابستگی، چهار مسیر دارد:
```
GET /api/v1/dashboard/clinic
GET /api/v1/dashboard/doctor
GET /api/v1/dashboard/secretary
GET /api/v1/dashboard/staff
```
هیچ‌کدام مفهوم حوزهٔ فعالیت را نمی‌شناسند.
### ۲.۷ آنچه واقعاً غایب است
فقط چهار چیز:
۱. حوزهٔ فعالیت روی مطب پزشک.
۲. ساخته‌شدن خودکار دادهٔ پیش‌فرض هنگام انتخاب حوزه.
۳. دندان به‌عنوان یک مفهوم — چارت، سطح، هدف‌گیری خدمت روی دندان.
۴. برآورد چندخدمتی و نرخ پذیرش آن.
هر چیز دیگری که سند اولیه پیشنهاد داده بود، از قبل هست.
---
## ۳. تصمیم‌ها
| # | تصمیم | چرا |
|---|---|---|
| ۱ | ماژول داده‌محور است، نه یک دامنهٔ موازی | موتور درمان و منبع و کاتالوگ از قبل هست. دامنهٔ موازی یعنی دو منبع حقیقت برای جلسه و اتاق. |
| ۲ | seed خودکار وقتی کاتالوگ خالی است، وگرنه دکمهٔ صریح با پیش‌نمایش | کلینیک خالی حالت اصلی است. کلینیک پرداده نباید بی‌اجازه ادغام شود، چون بعداً تشخیص «این ردیف را من ساختم یا سیستم» ممکن نیست. |
| ۳ | قالب پیش‌فرض‌ها در کد است، در فایل نسخه‌دار | محتوای محصول است نه دادهٔ کلینیک. باید در git تاریخچه و review و تست داشته باشد. تغییرش نادر است. |
| ۴ | دندان مفهوم مستقل است با شمارهٔ FDI از نوع `smallint` | FDI واقعیت جهانی است نه تاکسونومی هر کلینیک. دندان به‌عنوان دستهٔ کاتالوگ یعنی ۳۲ ردیف تکراری در هر کلینیک، و «سطح دندان» و «وضعیت ماندگار» جایی برای نشستن ندارند. |
| ۵ | فاز ۱ بدون برآورد چندخدمتی جلو می‌رود | زودتر به خروجی قابل استفاده می‌رسیم. هزینه‌اش این است که نرخ پذیرش تا فاز ۴ وجود ندارد و این باید در سند صریح بماند. |
| ۶ | داشبورد از یک registry متریک بر اساس حوزه سرو می‌شود | همان الگوی `TreatmentWorkflow` که پروژه از قبل دارد. شرط‌گذاری داخل کنترلر ۸۰۳ خطی، چهار مسیر کد را دوبرابر می‌کند. |
| ۷ | حوزهٔ فعالیت به سطح محیط منتقل می‌شود، هم کلینیک هم مطب پزشک | مطب تک‌پزشک بخش بزرگ بازار است. این نقص امروز حوزهٔ زیبایی را هم خراب می‌کند، پس اصلاحش عمومی است نه دندانی. |
| ۸ | شاخص‌ها در فاز ۱ زنده محاسبه می‌شوند | هیچ اندازه‌گیری‌ای نشان نداده کند است. جدول تجمیع یعنی job شبانه، مسیر backfill، و منبع حقیقت دومی که می‌تواند واگرا شود. |
| ۹ | چارت هم از خدمت ویزیت پر می‌شود هم دستی ویرایش می‌شود | چارت باید وضعیت‌هایی را نشان دهد که هرگز در این کلینیک فاکتور نشده‌اند، مثل دندان کشیده‌شدهٔ سال‌ها قبل. پس مشتق کامل ممکن نیست. ثبت فقط دستی هم یعنی ثبت دوباره و واگرایی از صورتحساب. |
| ۱۰ | ویژگی‌های دندانی خدمت در جدول توسعهٔ یک‌به‌یک می‌نشیند | `ServiceItem` مشترک همهٔ حوزه‌هاست. ستون دندانی روی آن یعنی هر حوزهٔ بعدی هم ستون خودش را اضافه می‌کند. |
| ۱۱ | بستهٔ seed شامل دسته‌ها، خدمات با قیمت صفر، نوع منبع یونیت و پروتکل‌هاست | دسته بدون خدمت کلینیک را دست‌خالی می‌گذارد. نمونهٔ یونیت ساختن، دادهٔ ساختگی در محیط واقعی جا می‌گذارد. تعداد یونیت را فقط خود مدیر می‌داند. |
| ۱۲ | نصب seed در یک جدول نگاشت در دامنهٔ Dental ثبت می‌شود؛ تغییر حوزه چیزی را حذف نمی‌کند | با تصمیم ۱۰ قرار شد جدول‌های مشترک آلوده نشوند. خدمتی که یک بار در ویزیت استفاده شده اصلاً حذف‌شدنی نیست، کلید خارجی `RESTRICT` است. |
| ۱۳ | چارت یک تب تازه در پروندهٔ بیمار است؛ شاخص‌های دندانی بخشی از همان داشبورد فعلی | دندانپزشک همیشه داخل پروندهٔ بیمار کار می‌کند. منوی جدا یعنی خروج مکرر از کانتکست بیمار. |
| ۱۴ | فهرست خدمات پیش‌نویس است و قبل از merge باید دندانپزشک تأییدش کند | فهرست غلط بدتر از نبودن فهرست است، چون پاک‌کردنش از ده‌ها کلینیک دیگر ممکن نیست. |
---
## ۴. واژگان تازه
این‌ها به `CONTEXT.md` اضافه شده‌اند. اینجا فقط خلاصه است.
**Tooth Chart** — نمای وضعیت جاری همهٔ دندان‌های یک بیمار در یک محیط. اسنپ‌شات است نه تاریخچه.
پرهیز از: dental chart record, odontogram record.
**Tooth Site** — یک دندان مشخص با شمارهٔ FDI، به‌همراه سطوح درگیر. هدف یک خدمت دندانی.
پرهیز از: Treatment Area, tooth record.
**Dental Preset** — بستهٔ دادهٔ پیش‌فرض حوزهٔ دندانپزشکی که با انتخاب حوزه در محیط نصب می‌شود.
پرهیز از: seed, fixture, template.
**Preset Install** — رکورد نصب یک کلید قالب روی یک ردیف واقعی در یک محیط. مبنای idempotency.
پرهیز از: migration, sync record.
---
## ۵. تصمیم‌های ثبت‌شده
| ADR | موضوع |
|---|---|
| `docs/adr/0007-practice-domain-is-tenant-level.md` | حوزهٔ فعالیت مال محیط است نه فقط کلینیک |
| `docs/adr/0008-teeth-are-not-treatment-areas.md` | دندان دستهٔ کاتالوگ نیست |
| `docs/adr/0009-dental-service-attributes-live-in-an-extension-table.md` | ویژگی دندانی خدمت در جدول جدا |
| `docs/adr/0010-domain-metrics-come-from-tagged-providers.md` | شاخص حوزه‌ای از provider تگ‌خورده |
---
## ۶. فازها
| فاز | محتوا | سند |
|---|---|---|
| ۱ | حوزه در سطح محیط، نصب پیش‌فرض‌ها، پروفایل دندانی خدمت | [phase-1-preset.md](phase-1-preset.md) |
| ۲ | چارت دندان، هدف‌گیری دندان روی خدمت ویزیت، پروجکتور | [phase-2-tooth-chart.md](phase-2-tooth-chart.md) |
| ۳ | registry متریک و شاخص‌های دندانپزشکی روی داشبورد | [phase-3-dashboard.md](phase-3-dashboard.md) |
| ۴ | برآورد درمان و نرخ پذیرش | [phase-4-treatment-estimate.md](phase-4-treatment-estimate.md) |
| ۵ | پریو، لابراتوار، استریلیزاسیون، مواد مصرفی | [phase-5-clinical-ops.md](phase-5-clinical-ops.md) |
محتوای پیشنهادی بستهٔ پیش‌فرض در [preset-content.md](preset-content.md).
هر فاز بدون تست موفق و خطا و مرزی، و بدون به‌روزرسانی `docs/api/`، تمام‌شده نیست.
---
## ۷. قواعد مشترک همهٔ فازها
از `CLAUDE.md` پروژه، اینجا فقط یادآوری:
- شناسه عددی به‌همراه `uuid` نسخهٔ ۴. `uuid` در پاسخ API، `id` هرگز.
- تایم‌استمپ از نوع `integer` یونیکس.
- نام جدول جمع و snake_case.
- پاسخ از `BaseController`، خطا با `AppException` و `ErrorCodes`.
- هر entity تازه باید جفت `entity_type` و `entity_id` داشته باشد، وگرنه `TenantSchemaCoverageTest` قرمز می‌شود.
- کوئری‌های لیست با `getArrayResult()`.
- در پنل: `SearchableSelect` به‌جای `select` بومی، `Switch` به‌جای checkbox، رنگ فقط از `var(--...)`.
- تاریخ در دیتابیس میلادی ذخیره، در نمایش جلالی.
---
## ۸. مرزهای رزروشده
**بیمه:** دامنهٔ `Insurance` از قبل کامل است و شامل `TenantServiceCoverage` و `TenantInsuranceCategoryCoverage` و `EntityInsurancePricing` می‌شود.
هیچ منطق بیمه‌ای در این ماژول نوشته نمی‌شود.
تنها اثرش این است که ردیف برآورد در فاز ۴ باید ستون `payer_type` داشته باشد تا بعداً سند بیمه رویش بنشیند.
**اپ Tauri:** در فاز ۱ تا ۳ هیچ تغییری نمی‌گیرد.
اگر بعداً چارت آفلاین لازم شد، `tooth_chart` و وضعیت دندان کاندیدهای خوبی هستند چون تک‌نویسنده و کم‌تعارض‌اند.
برآورد درمان چون مالی است نباید `last-write-wins` بگیرد.
این موضوع به سند sync ارجاع می‌شود، نه اینجا.
**سایت عمومی nobat724:** خدمات دندانپزشکی مثل هر خدمت دیگری در سایت دیده می‌شوند.
هیچ تغییر اختصاصی لازم نیست مگر اینکه بخواهیم انتخاب دندان در رزرو آنلاین باشد، که خارج از این سند است.
@@ -0,0 +1,273 @@
# فاز ۱ — حوزه در سطح محیط و نصب پیش‌فرض‌های دندانپزشکی
> پیش‌نیاز: ندارد.
> خروجی قابل تست: مطب یا کلینیک، حوزهٔ دندانپزشکی را انتخاب می‌کند و کاتالوگ خدمات دندانپزشکی‌اش ساخته می‌شود.
---
## ۱. هدف
سه چیز:
۱. مطب تک‌پزشک هم بتواند حوزهٔ فعالیت انتخاب کند، نه فقط کلینیک.
۲. با انتخاب دندانپزشکی، دسته‌بندی‌ها و خدمات و نوع منبع یونیت و پروتکل‌ها ساخته شوند.
۳. هر خدمت دندانی بداند روی دندان انجام می‌شود یا روی فک یا روی کل دهان.
---
## ۲. مدل داده
### ۲.۱ تغییر روی دامنهٔ موجود
روی `Doctor` یک رابطهٔ اختیاری به `PracticeDomain` اضافه می‌شود:
```
doctors.practice_domain_id → practice_domains.id nullable, ON DELETE SET NULL
```
مقدار `NULL` یعنی «تنظیم نشده» و همان رفتار امروز است. هرگز خطا نیست.
خواندن حوزهٔ محیط باید از یک نقطه باشد، نه دو `if` پراکنده:
```
src/PracticeDomain/Service/PracticeDomainResolver.php
```
ورودی‌اش `EntityContext` است و خروجی‌اش کد حوزه یا `null`.
دلیل جداکردنش: هر کسی که حوزه را لازم دارد نباید بداند محیط کلینیک است یا مطب.
### ۲.۲ موجودیت‌های تازه در دامنهٔ Dental
همه در `src/Dental/Entity`.
#### `DentalServiceProfile` — جدول `dental_service_profiles`
توسعهٔ یک‌به‌یک روی `ServiceItem`.
| ستون | نوع | توضیح |
|---|---|---|
| `id` | int | |
| `uuid` | string 36 | |
| `entity_type` و `entity_id` | | جفت محیط، از `TenantOwnedTrait` |
| `service_item_id` | int | یکتا، `ON DELETE CASCADE` |
| `target_scope` | string 20 | دامنهٔ هدف |
| `pricing_basis` | string 20 | مبنای واحد قیمت |
| `tooth_scope` | string 20 | دائمی، شیری، یا هر دو |
| `requires_lab` | bool | فاز ۵ از آن استفاده می‌کند |
| `created_at` و `updated_at` | int | |
مقدار `target_scope` تعیین می‌کند فرم ثبت خدمت در ویزیت چه چیزی بپرسد:
| مقدار | معنی | ورودی لازم در ویزیت |
|---|---|---|
| `none` | خدمت بدون هدف | هیچ |
| `tooth` | یک دندان | شمارهٔ دندان |
| `tooth_surface` | سطوح یک دندان | شمارهٔ دندان و حداقل یک سطح |
| `quadrant` | یک ناحیهٔ فک | کد ناحیه از ۱ تا ۴ |
| `arch` | یک فک | بالا یا پایین |
| `mouth` | کل دهان | هیچ |
مقادیر `pricing_basis`:
```
per_tooth | per_surface | per_canal | per_unit | per_arch | per_quadrant | per_session | flat
```
مقادیر `tooth_scope`:
```
any | permanent_only | primary_only
```
هر سه به‌صورت `enum` PHP در `src/Dental/Enum` تعریف می‌شوند و در ستون به‌صورت رشته ذخیره می‌شوند.
دلیل رشته‌بودن: افزودن مقدار تازه نباید migration بخواهد.
#### `PresetInstall` — جدول `dental_preset_installs`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | int | |
| `uuid` | string 36 | |
| `entity_type` و `entity_id` | | جفت محیط |
| `preset_code` | string 40 | مثلاً `dental` |
| `preset_version` | int | نسخهٔ قالبی که نصب شد |
| `template_key` | string 80 | کلید ثابت ردیف در قالب |
| `target_type` | string 30 | `catalog_category`, `service_item`, `resource_type`, `treatment_protocol` |
| `target_id` | int | شناسهٔ ردیف واقعی ساخته‌شده |
| `installed_at` | int | |
یکتایی: `(entity_type, entity_id, preset_code, template_key)`.
این جدول تنها مبنای idempotency است.
اجرای دوم seed، هر کلید قالبی را که اینجا ثبت شده دوباره نمی‌سازد.
**چرا این جدول و نه یک ستون `origin` روی کاتالوگ:**
جدول‌های کاتالوگ مشترک همهٔ حوزه‌هایند.
یک ستون منشأ روی آن‌ها یعنی هر حوزهٔ بعدی هم چیزی به جدول مشترک اضافه می‌کند.
جدول نگاشت با حذف ماژول تمیز برداشته می‌شود.
---
## ۳. قالب پیش‌فرض
مسیر: `src/Dental/Preset/DentalPreset.php`
یک کلاس `final` با آرایه‌های ثابت و یک `const VERSION`.
محتوایش در [preset-content.md](preset-content.md).
ساختار هر بخش:
```
GROUPS : [ template_key, name, sort_order, parent_key|null ]
SERVICES : [ template_key, name, group_key, duration_minutes, session_count,
target_scope, pricing_basis, tooth_scope, requires_lab ]
RESOURCE_TYPES : [ template_key, code, name, field_schema|null ]
PROTOCOLS : [ template_key, service_key, session_count, interval_days ]
```
قیمت همهٔ خدمات صفر است.
دلیل: قیمت جعلی که کسی اصلاحش نکند، به بیمار نشان داده می‌شود.
صفر بودن در پنل قابل دیدن و قابل فیلتر کردن است.
`VERSION` عدد صحیح است و با هر تغییر محتوا یکی زیاد می‌شود.
نسخه در `dental_preset_installs` ثبت می‌شود تا بعداً بشود گفت کدام محیط با کدام نسخه نصب شده.
---
## ۴. سرویس نصب
مسیر: `src/Dental/Service/DentalPresetInstaller.php`
```
install(EntityContext $context, bool $force = false): PresetInstallReport
preview(EntityContext $context): PresetInstallReport
```
قواعد:
- همه‌چیز در یک تراکنش. نصب نیمه‌کاره نداریم.
- هر ردیف قبل از ساخت، در `dental_preset_installs` جستجو می‌شود.
- ردیفی که کلیدش قبلاً نصب شده، دست نمی‌خورد. حتی اگر مدیر نامش را عوض کرده باشد.
- `preview` هیچ چیزی نمی‌نویسد و همان گزارش را بدون ساخت برمی‌گرداند.
- بخش سازمانی: اگر محیط هیچ `ServiceSection` نداشت، یکی با نام «دندانپزشکی» ساخته می‌شود، وگرنه اولین بخش فعال استفاده می‌شود. دلیل: `ServiceItem` بدون بخش `NOT NULL` نمی‌شود.
`PresetInstallReport` یک شیء ساده است با تعداد ساخته‌شده و تعداد ردشده به تفکیک نوع.
### تصمیم لحظهٔ اجرا
هنگام `PATCH` روی کلینیک یا پزشک، اگر حوزه به دندانپزشکی تغییر کرد:
```
کاتالوگ محیط خالی است → نصب خودکار، بدون پرسش
کاتالوگ محیط داده دارد → هیچ چیز ساخته نمی‌شود، پرچم «پیش‌فرض نصب‌نشده» برای پنل برمی‌گردد
```
تعریف «خالی»: هیچ `ServiceItem` و هیچ `CatalogCategory` فعالی در آن محیط نباشد.
---
## ۵. API
مستندات در `docs/api/dental.md` نوشته می‌شود. فایل تازه است.
```
GET /api/v1/dental/preset/status
→ { installed: bool, installed_version: int|null,
current_version: int, catalog_empty: bool }
GET /api/v1/dental/preset/preview
→ { groups: n, services: n, resource_types: n, protocols: n, skipped: n }
POST /api/v1/dental/preset/install
→ همان گزارش، بعد از نصب واقعی
```
دسترسی: `ROLE_CLINIC` یا `ROLE_DOCTOR`. محیط از `EntityContextResolver` می‌آید، نه از بدنهٔ درخواست.
خطاها:
| کد | HTTP | حالت |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | حوزهٔ فعالیت محیط دندانپزشکی نیست |
| `ERR_FORBIDDEN_001` | 403 | نقش مجاز نیست |
اندپوینت خدمت هم گسترش پیدا می‌کند:
```
POST /api/v1/service-item بدنه کلید اختیاری dental می‌گیرد
PATCH /api/v1/service-item/{uuid} همان
GET /api/v1/service-item/{uuid} پاسخ کلید dental دارد اگر پروفایل داشته باشد
```
`docs/api/clinic-services.md` همان جلسه به‌روز می‌شود.
---
## ۶. پنل ادمین
### صفحهٔ حوزهٔ فعالیت
فایل: `assets/admin/pages/PracticeDomainSettingsPage.tsx`
- برای کاربر پزشک هم کار کند، نه فقط کلینیک.
- بعد از انتخاب دندانپزشکی، وضعیت نصب پیش‌فرض نشان داده شود.
- اگر نصب نشده، کارت با پیش‌نمایش تعداد و دکمهٔ نصب.
- بعد از نصب موفق، پیام با تعداد ردیف ساخته‌شده و لینک به صفحهٔ خدمات.
### فرم خدمت
فایل: `assets/admin/pages/ClinicServicesPage.tsx`
- اگر حوزهٔ محیط دندانپزشکی است، بخش «ویژگی‌های دندانی» در فرم خدمت اضافه شود.
- سه انتخاب: دامنهٔ هدف، مبنای قیمت، نوع دندان. هر سه با `SearchableSelect`.
- سوییچ «نیاز به لابراتوار» با `Switch`.
- برای حوزه‌های دیگر این بخش اصلاً رندر نشود.
---
## ۷. تسک‌ها
| کد | تسک | فایل‌های اصلی | معیار پذیرش |
|---|---|---|---|
| DM1-01 | افزودن `practice_domain_id` به `Doctor` با migration | `src/Doctor/Entity/Doctor.php`, `migrations/` | migration روی دیتابیس تست اجرا و برگشت می‌خورد |
| DM1-02 | `PracticeDomainResolver` بر اساس `EntityContext` | `src/PracticeDomain/Service/` | تست: محیط کلینیک، محیط پزشک، محیط بدون حوزه |
| DM1-03 | اندپوینت تنظیم حوزه برای پزشک | `src/Doctor/Controller/`, `docs/api/practice-domain.md` | پزشک حوزه را ست می‌کند و در `GET` پروفایل می‌بیند |
| DM1-04 | `enum`های دندانی | `src/Dental/Enum/` | مقدار نامعتبر `AppException` می‌دهد |
| DM1-05 | موجودیت و مخزن `DentalServiceProfile` | `src/Dental/Entity/`, `src/Dental/Repository/` | `TenantSchemaCoverageTest` سبز |
| DM1-06 | موجودیت و مخزن `PresetInstall` | همان | یکتایی کلید قالب در محیط تست می‌شود |
| DM1-07 | فایل قالب `DentalPreset` با محتوای پیش‌نویس | `src/Dental/Preset/` | تست ساختاری: هر خدمت به گروه موجود اشاره می‌کند |
| DM1-08 | `DentalPresetInstaller` با `install` و `preview` | `src/Dental/Service/` | اجرای دوم هیچ ردیف تازه‌ای نمی‌سازد |
| DM1-09 | اتصال نصب خودکار به تغییر حوزه | `src/Clinic/Controller/ClinicController.php` و معادل پزشک | کاتالوگ خالی نصب می‌شود، کاتالوگ پرداده نمی‌شود |
| DM1-10 | سه اندپوینت `preset` | `src/Dental/Controller/DentalPresetController.php` | تست موفق، بدون دسترسی، حوزهٔ اشتباه |
| DM1-11 | گسترش `service-item` برای کلید `dental` | `src/ClinicService/Controller/ClinicServiceController.php` | ساخت خدمت با پروفایل و بدون آن، هر دو کار می‌کند |
| DM1-12 | `docs/api/dental.md` و به‌روزرسانی دو سند موجود | `docs/api/` | مسیرها و خطاها با کد یکی است |
| DM1-13 | کارت نصب پیش‌فرض در صفحهٔ حوزهٔ فعالیت | `assets/admin/pages/PracticeDomainSettingsPage.tsx` | تست: نصب‌نشده، نصب‌شده، حوزهٔ غیر دندانی |
| DM1-14 | بخش ویژگی دندانی در فرم خدمت | `assets/admin/pages/ClinicServicesPage.tsx` | برای حوزهٔ زیبایی رندر نمی‌شود |
| DM1-15 | بازبینی تخصصی فهرست خدمات توسط دندانپزشک | `preset-content.md` | بدون این، فاز ۱ بسته نمی‌شود |
---
## ۸. تست‌ها
- نصب روی محیط خالی: همهٔ ردیف‌ها ساخته می‌شوند.
- نصب دوم: صفر ردیف تازه، گزارش می‌گوید چند تا رد شد.
- نصب روی محیطی که حوزه‌اش دندانپزشکی نیست: خطای ۴۲۲.
- نصب توسط نقش بدون دسترسی: خطای ۴۰۳.
- شکست وسط نصب: هیچ ردیفی باقی نمی‌ماند، تراکنش برگشت می‌خورد.
- محیط پزشک و محیط کلینیک، هر دو مسیر.
- خدمتی که پروفایل دندانی ندارد، در حوزهٔ دندانپزشکی هم بدون خطا کار می‌کند.
---
## ۹. ریسک‌ها
**فهرست خدمات غلط.**
اثرش در همهٔ کلینیک‌های نصب‌کننده پخش می‌شود و جمع کردنش ممکن نیست.
مهار: تسک `DM1-15` مسدودکننده است.
**نصب خودکار روی محیطی که کاربر نمی‌خواست.**
مهار: فقط وقتی کاتالوگ خالی است، و ردیف‌ها با قیمت صفر و قابل غیرفعال کردن.
**تعریف «کاتالوگ خالی» ناپایدار.**
اگر کلینیکی یک دستهٔ آزمایشی ساخته باشد، نصب خودکار انجام نمی‌شود و کاربر گیج می‌شود.
مهار: پنل همیشه وضعیت نصب و دکمه را نشان می‌دهد، پس مسیر دوم همیشه در دسترس است.
@@ -0,0 +1,299 @@
# فاز ۲ — چارت دندان و هدف‌گیری دندان روی خدمت ویزیت
> پیش‌نیاز: فاز ۱.
> خروجی قابل تست: دندانپزشک وضعیت دندان‌های بیمار را می‌بیند، ثبت خدمت روی دندان انجام می‌دهد و چارت خودکار به‌روز می‌شود.
---
## ۱. هدف
سه چیز:
۱. هر بیمار در هر محیط یک چارت دندان داشته باشد.
۲. ثبت خدمت در ویزیت بتواند دندان و سطح را هدف بگیرد.
۳. چارت بعد از ثبت خدمت خودکار به‌روز شود، و وضعیت‌های قدیمی هم دستی قابل ثبت باشند.
---
## ۲. شماره‌گذاری دندان
استاندارد `FDI` دو رقمی، همان `ISO 3950`.
```
دائمی : 1118, 2128, 3138, 4148
شیری : 5155, 6165, 7175, 8185
```
ذخیره به‌صورت `smallint`، نه رشته.
دلیل: مقایسه و بازه و ایندکس روی عدد کار می‌کند و «۱۱» و «11» دو مقدار جدا نمی‌سازد.
اعتبارسنجی در یک نقطه:
```
src/Dental/Validator/ToothNumberValidator.php
```
استانداردهای `Universal` و `Palmer` در لایهٔ داده استفاده نمی‌شوند.
اگر بعداً لازم شد، فقط لایهٔ نمایش تبدیل می‌کند.
سطوح دندان:
```
M مزیال · D دیستال · O اکلوزال · B باکال · L لینگوال · P پالاتال · I اینسایزال
```
---
## ۳. مدل داده
همه در `src/Dental/Entity`.
### `ToothChart` — جدول `dental_tooth_charts`
| ستون | نوع | توضیح |
|---|---|---|
| `id`, `uuid` | | |
| `entity_type`, `entity_id` | | جفت محیط |
| `patient_record_id` | int | یکتا در هر محیط |
| `dentition_type` | string 20 | `permanent`, `primary`, `mixed` |
| `last_examined_at` | int, nullable | |
| `created_at`, `updated_at` | int | |
یکتایی: `(entity_type, entity_id, patient_record_id)`.
چارت با اولین نیاز ساخته می‌شود، نه با ساخت پرونده.
دلیل: پروندهٔ بیماری که هرگز درمان دندانی نمی‌گیرد نباید ردیف خالی بسازد.
### `ToothStatus` — جدول `dental_tooth_statuses`
| ستون | نوع | توضیح |
|---|---|---|
| `id`, `uuid` | | |
| `chart_id` | int | `ON DELETE CASCADE` |
| `tooth_number` | smallint | FDI |
| `condition` | string 30 | وضعیت کلی دندان |
| `surface_map` | json, nullable | وضعیت هر سطح |
| `note` | string 500, nullable | |
| `source` | string 20 | `manual` یا `visit` |
| `recorded_by_user_id` | int, nullable | |
| `updated_at` | int | |
یکتایی: `(chart_id, tooth_number)`.
مقادیر `condition`:
```
healthy | caries | filled | crown | bridge_pontic | root_canal
implant | missing | extracted | impacted | to_extract | unerupted
```
`surface_map` شکل ثابت دارد:
```json
{ "O": "filled", "M": "caries", "D": "healthy" }
```
**چرا وضعیت جدا از تاریخچه ذخیره می‌شود:**
رندر چارت باید با یک کوئری انجام شود.
اگر وضعیت هر بار از بازپخش تاریخچهٔ ویزیت‌ها ساخته شود، هر باز کردن تب یک محاسبهٔ سنگین است و وضعیت قبل از اولین مراجعه اصلاً قابل ثبت نیست.
هزینهٔ پذیرفته‌شده: این جدول باید بعد از هر ویزیت به‌روز شود و این کار فقط در یک کلاس انجام می‌شود، نه پراکنده در کنترلرها.
### `ToothStatusLog` — جدول `dental_tooth_status_logs`
هر تغییر وضعیت یک ردیف اضافه می‌کند. فقط افزودنی است.
| ستون | نوع |
|---|---|
| `id`, `uuid` | |
| `chart_id`, `tooth_number` | |
| `from_condition`, `to_condition` | string 30 |
| `surface_map_before`, `surface_map_after` | json, nullable |
| `session_service_id` | int, nullable |
| `changed_by_user_id` | int, nullable |
| `changed_at` | int |
دلیل وجودش: چارت سند پزشکی است.
«چه کسی دندان ۱۶ را کشیده‌شده علامت زد» باید قابل جواب دادن باشد.
---
## ۴. هدف‌گیری دندان روی خدمت ویزیت
### تغییر روی دامنهٔ موجود
روی `SessionService` سه ستون اختیاری اضافه می‌شود:
```
tooth_number smallint nullable
surfaces json nullable
target_code string 10 nullable کد فک یا ناحیه
```
**چرا اینجا و نه در جدول دندانی جدا:**
این‌ها ویژگی همان ردیف خدمتِ فاکتورشده‌اند.
جدا کردنشان یعنی برای هر ردیف فاکتور یک join اضافه، و امکان اینکه ردیف فاکتور بدون هدف بماند بدون اینکه کسی بفهمد.
برخلاف `ServiceItem` که تنظیمات است و مشترک همهٔ حوزه‌هاست، `SessionService` سند یک ویزیت است و این سه ستون بخشی از همان سند.
### اعتبارسنجی
در `src/Dental/Service/ToothTargetValidator.php`.
قاعده بر اساس `target_scope` پروفایل خدمت:
| `target_scope` | لازم | ممنوع |
|---|---|---|
| `none` و `mouth` | — | هر سه |
| `tooth` | `tooth_number` | `surfaces`, `target_code` |
| `tooth_surface` | `tooth_number` و حداقل یک سطح | `target_code` |
| `quadrant` | `target_code` از ۱ تا ۴ | `tooth_number`, `surfaces` |
| `arch` | `target_code` برابر `upper` یا `lower` | `tooth_number`, `surfaces` |
قاعدهٔ دوم: `tooth_scope` خدمت با شمارهٔ دندان بخواند.
خدمت `permanent_only` روی دندان ۵۱ خطا می‌دهد.
قاعدهٔ سوم: خدمتی که پروفایل دندانی ندارد، هیچ هدفی نمی‌پذیرد.
خطا با `ERR_VALIDATION_002` و نام فیلد.
### پروجکتور چارت
مسیر: `src/Dental/Service/ToothChartProjector.php`
بعد از ثبت یا ویرایش ردیف خدمت با هدف دندانی:
```
سرویس ترمیمی روی سطوح → همان سطوح در surface_map مقدار filled می‌گیرند
سرویس کشیدن دندان → condition برابر extracted
سرویس درمان ریشه → condition برابر root_canal
سرویس روکش → condition برابر crown
سرویس ایمپلنت → condition برابر implant
بقیه → وضعیت دست نمی‌خورد، فقط لاگ ثبت می‌شود
```
نگاشت خدمت به اثر، در همان `DentalPreset` تعریف می‌شود با کلید `chart_effect`.
دلیل: مدیر می‌تواند خدمت دلخواه بسازد و اثرش را انتخاب کند، بدون اینکه کد عوض شود.
حذف ردیف خدمت، وضعیت را به عقب برنمی‌گرداند.
دلیل: دندان کشیده‌شده با حذف یک ردیف فاکتور برنمی‌گردد.
به‌جایش یک لاگ با توضیح ثبت می‌شود و اصلاح دستی می‌ماند.
---
## ۵. API
`docs/api/dental.md` گسترش پیدا می‌کند.
```
GET /api/v1/dental/chart/{patientRecordUuid}
→ { chart: {...}, teeth: [ { tooth_number, condition, surfaces, note } ] }
PUT /api/v1/dental/chart/{patientRecordUuid}/tooth/{toothNumber}
→ ثبت یا اصلاح دستی وضعیت یک دندان
GET /api/v1/dental/chart/{patientRecordUuid}/tooth/{toothNumber}/history
→ لاگ تغییرات همان دندان
```
دسترسی:
| عملیات | clinic | doctor | secretary | staff |
|---|---|---|---|---|
| دیدن چارت | بله | بیماران خودش | خواندنی | نه |
| ویرایش دستی چارت | نه | بله | نه | نه |
| ثبت هدف دندانی در ویزیت | نه | بله | نه | نه |
خطاها:
| کد | HTTP | حالت |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | شمارهٔ دندان نامعتبر یا هدف ناسازگار |
| `ERR_NOT_FOUND_001` | 404 | پرونده در این محیط نیست |
| `ERR_FORBIDDEN_001` | 403 | نقش مجاز نیست |
اندپوینت ثبت خدمت ویزیت هم کلیدهای تازه می‌گیرد و `docs/api/patient.md` همان جلسه به‌روز می‌شود.
---
## ۶. پنل ادمین
### تب تازه
فایل: `assets/admin/pages/PatientDetailPage.tsx`
- کلید تب: `dental`، برچسب «چارت دندان».
- فقط وقتی حوزهٔ محیط دندانپزشکی است رندر می‌شود.
- بین «پرونده پزشکی» و «ضمیمه» می‌نشیند.
### کامپوننت چارت
فایل: `assets/admin/components/dental/ToothChart.tsx`
این تنها جایی است که ساخت کامپوننت تازه موجه است، چون هیچ کامپوننت موجودی این کار را نمی‌کند.
قواعد:
- `SVG` دست‌نویس، بدون کتابخانهٔ بیرونی.
- هر دندان یک گروه قابل کلیک با شمارهٔ FDI.
- هر سطح یک مسیر جدا، تا کلیک روی سطح جدا از کلیک روی دندان باشد.
- رنگ‌ها فقط از توکن‌های `styles.css`. هیچ رنگ ثابتی در کد کامپوننت نیست.
- چیدمان `RTL` و سازگار با تم تیره.
- فک بالا در ردیف بالا، فک پایین در ردیف پایین، سمت راست بیمار در سمت راست تصویر. این قرارداد در بالای فایل به‌صورت کامنت نوشته شود چون خطای رایج همین است.
- حالت شیری و مختلط: دندان‌های شیری در همان گرید، کوچکتر.
- بدون تعامل هم باید خوانا باشد، چون در چاپ پرونده استفاده می‌شود.
### فرم ثبت خدمت در ویزیت
فایل: `assets/admin/pages/EditSessionPage.tsx`
- بعد از انتخاب خدمت، اگر پروفایل دندانی دارد، انتخابگر هدف نشان داده شود.
- انتخاب دندان از روی همان `ToothChart` انجام شود، نه از یک `select` با ۳۲ گزینه.
- انتخاب سطح فقط وقتی `target_scope` برابر `tooth_surface` است.
---
## ۷. تسک‌ها
| کد | تسک | فایل‌های اصلی | معیار پذیرش |
|---|---|---|---|
| DM2-01 | `ToothNumberValidator` و ثابت‌های FDI | `src/Dental/Validator/` | همهٔ شماره‌های معتبر و نامعتبر تست می‌شوند |
| DM2-02 | موجودیت `ToothChart` | `src/Dental/Entity/` | یکتایی پرونده در محیط |
| DM2-03 | موجودیت `ToothStatus` با `surface_map` | همان | شکل json اعتبارسنجی می‌شود |
| DM2-04 | موجودیت `ToothStatusLog` | همان | فقط افزودنی، بدون متد حذف |
| DM2-05 | سه ستون هدف روی `SessionService` با migration | `src/Patient/Entity/SessionService.php` | ردیف بدون هدف مثل قبل کار می‌کند |
| DM2-06 | `ToothTargetValidator` | `src/Dental/Service/` | هر پنج حالت `target_scope` تست می‌شود |
| DM2-07 | `ToothChartProjector` و نگاشت `chart_effect` | `src/Dental/Service/`, `src/Dental/Preset/` | ثبت کشیدن دندان، وضعیت را عوض می‌کند و لاگ می‌زند |
| DM2-08 | سه اندپوینت چارت | `src/Dental/Controller/DentalChartController.php` | موفق، بدون دسترسی، پروندهٔ محیط دیگر |
| DM2-09 | گسترش ثبت خدمت ویزیت برای هدف دندانی | `src/Patient/Controller/PatientController.php` | هدف ناسازگار ۴۲۲ می‌دهد |
| DM2-10 | کامپوننت `ToothChart` | `assets/admin/components/dental/` | تست: کلیک دندان، کلیک سطح، حالت فقط‌خواندنی |
| DM2-11 | تب چارت در پروندهٔ بیمار | `assets/admin/pages/PatientDetailPage.tsx` | برای حوزهٔ غیر دندانی رندر نمی‌شود |
| DM2-12 | انتخابگر هدف در فرم ثبت خدمت | `assets/admin/pages/EditSessionPage.tsx` | خدمت بدون پروفایل، انتخابگر نشان نمی‌دهد |
| DM2-13 | به‌روزرسانی `docs/api/dental.md` و `docs/api/patient.md` | `docs/api/` | مسیرها با کد یکی است |
---
## ۸. تست‌ها
- ثبت خدمت روی دندان شیری با خدمت `permanent_only`: خطای ۴۲۲.
- ثبت خدمت `tooth_surface` بدون سطح: خطای ۴۲۲.
- ثبت خدمت `arch` با شمارهٔ دندان: خطای ۴۲۲.
- ثبت کشیدن دندان: وضعیت `extracted` و یک ردیف لاگ.
- ویرایش دستی وضعیت: منبع `manual` ثبت می‌شود.
- خواندن چارت بیمار محیط دیگر: خطای ۴۰۴، نه ۴۰۳. دلیل: نباید وجود پرونده در محیط دیگر لو برود.
- منشی چارت را می‌بیند ولی نمی‌تواند ویرایش کند.
- چارت بیماری که هیچ درمانی نگرفته: ساخته می‌شود و همهٔ دندان‌ها `healthy` برمی‌گردند بدون اینکه ۳۲ ردیف در دیتابیس ساخته شود.
---
## ۹. ریسک‌ها
**واگرایی چارت از فاکتور.**
اگر کاربر خدمت را ثبت کند ولی هدف را خالی بگذارد، چارت به‌روز نمی‌شود و کسی نمی‌فهمد.
مهار: برای خدمتی که پروفایل دندانی دارد، هدف اجباری است و ردیف بدون هدف اصلاً ذخیره نمی‌شود.
**تعداد ردیف وضعیت.**
اگر برای هر بیمار ۳۲ ردیف ساخته شود، جدول سریع بزرگ می‌شود.
مهار: فقط دندان‌هایی که وضعیتشان از `healthy` فاصله گرفته ردیف می‌گیرند. بقیه در پاسخ API از پیش‌فرض ساخته می‌شوند.
**سمت چپ و راست جابه‌جا.**
خطای رایج در چارت دندان و در سند پزشکی خطرناک است.
مهار: قرارداد جهت در کامنت بالای کامپوننت، و یک تست که دندان ۱۱ را در جای درست ادعا می‌کند.
@@ -0,0 +1,200 @@
# فاز ۳ — داشبورد دندانپزشکی
> پیش‌نیاز: فاز ۱ و ۲.
> خروجی قابل تست: مدیر و پزشک و پذیرش، شاخص‌های دندانپزشکی را در همان داشبورد فعلی خودشان می‌بینند.
---
## ۱. هدف
شاخص‌های مخصوص حوزهٔ فعالیت به داشبورد نقشی اضافه شوند، بدون دست‌زدن به منطق داشبورد عمومی و بدون کند کردن داشبورد حوزه‌های دیگر.
---
## ۲. الگو
همان الگوی `TreatmentWorkflow` که در `docs/adr/0005` ثبت شده.
```
src/Dashboard/Metric/DomainMetricProvider.php اینترفیس، با تگ app.domain_metric_provider
src/Dashboard/Metric/DomainMetricRegistry.php انتخاب بر اساس کد حوزه
src/Dashboard/Metric/MetricRequest.php بازه، نقش، محیط، فیلترها
src/Dashboard/Metric/MetricSet.php خروجی استاندارد
src/Dental/Metric/DentalMetricProvider.php پیاده‌سازی دندانپزشکی
```
اینترفیس:
```php
interface DomainMetricProvider
{
public function supports(?string $practiceDomainCode): bool;
/** @return list<string> کلید شاخص‌هایی که این نقش می‌بیند */
public function keysFor(string $role): array;
public function collect(MetricRequest $request): MetricSet;
}
```
محیطی که حوزه‌اش `null` است یا provider ندارد، هیچ بخش تازه‌ای نمی‌گیرد.
برخلاف `TreatmentWorkflowRegistry` اینجا پیاده‌سازی پیش‌فرض لازم نیست؛ نبودن provider یعنی بخش دندانی رندر نمی‌شود.
دلیل: شاخص خالی بدتر از نبودن بخش است.
هر متد `collect` باید تعداد کوئری ثابت داشته باشد، مستقل از تعداد شاخص.
یعنی شاخص‌های هم‌منبع در یک کوئری جمع شوند.
---
## ۳. رجیستری شاخص‌ها
هر شاخص یک کلید ثابت دارد.
فرانت هیچ فرمولی محاسبه نمی‌کند و فقط مصرف‌کنندهٔ عدد است.
دلیل: اگر تعریف «تولید» در دو جا نوشته شود، دیر یا زود دو عدد متفاوت نشان داده می‌شود.
### شاخص‌های قابل محاسبه در فاز ۳
| کلید | تعریف | منبع |
|---|---|---|
| `production` | جمع مبلغ ناخالص ویزیت‌های انجام‌شده در بازه | `patient_sessions` |
| `collection` | جمع پرداخت‌های ثبت‌شده در بازه | `payments` و `session_payments` |
| `collection_rate` | وصولی تقسیم بر تولید | مشتق |
| `avg_per_visit` | تولید تقسیم بر تعداد ویزیت | مشتق |
| `production_per_doctor` | تولید به تفکیک پزشک | `patient_sessions` |
| `service_mix` | سهم هر گروه کاتالوگ از تولید | `session_services` و `service_catalog_categories` |
| `chair_utilization` | دقایق رزروشدهٔ منابع نوع اتاق تقسیم بر دقایق ظرفیت | `resource_occupancy` و `resource_calendars` |
| `no_show_rate` | نوبت‌های حاضرنشده تقسیم بر کل نوبت‌ها | `appointments` |
| `cancellation_rate` | نوبت‌های لغوشده تقسیم بر کل | `appointments` |
| `new_patients` | بیماران با اولین ویزیت در بازه | `patient_sessions` |
| `ar_outstanding` | جمع بدهی معوق بیماران | `patient_sessions` |
| `treatments_by_group` | تعداد خدمت انجام‌شده به تفکیک گروه دندانی | `session_services` |
| `teeth_treated` | تعداد دندان‌های درمان‌شدهٔ یکتا در بازه | `session_services` |
### شاخص‌هایی که در این فاز وجود ندارند
| کلید | چرا |
|---|---|
| `case_acceptance_rate` | ورودی‌اش برآورد درمان است که فاز ۴ ساخته می‌شود |
| `unscheduled_treatment` | همان |
| `redo_rate` | نیازمند حالت درمان مجدد روی ردیف برآورد |
| `lab_cost_ratio` | فاز ۵ |
| `consumable_cost_ratio` | فاز ۵ |
| `recall_response_rate` | نیازمند سازوکار ریکال که پروژه هنوز ندارد |
این فهرست عمداً در سند مانده تا کسی فکر نکند فراموش شده‌اند.
### دو تعریف که نباید قاطی شوند
**تولید** جمع مبلغ کاری است که انجام شده.
**وصولی** جمع پولی است که رسیده.
این دو از دو جدول متفاوت می‌آیند و هیچ‌کدام نباید از دیگری استنتاج شود.
فاصلهٔ بینشان همان چیزی است که نرخ وصول را معنادار می‌کند.
---
## ۴. نماها
| نقش | شاخص‌های صفحهٔ اصلی |
|---|---|
| مدیر کلینیک یا مطب | `collection`, `collection_rate`, `chair_utilization`, `new_patients`, `ar_outstanding`, `service_mix` |
| پزشک | تولید شخصی، `avg_per_visit` خودش، `treatments_by_group` خودش، `teeth_treated` |
| پذیرش | اشغال یونیت امروز، `no_show_rate`, `cancellation_rate`, صف نوبت امروز |
حداکثر شش کارت در صفحهٔ اصلی هر نقش.
بقیه پشت drill-down.
دلیل: داشبورد با بیست کارت خوانده نمی‌شود و کاربر به‌جای تصمیم، اسکرول می‌کند.
فیلتر مشترک: بازهٔ تاریخ جلالی، پزشک، گروه خدمت، یونیت.
---
## ۵. API
اندپوینت‌های نقشی موجود دست نمی‌خورند.
پاسخشان یک کلید تازه می‌گیرد:
```
GET /api/v1/dashboard/clinic
→ { ..., domain_metrics: { code: "dental", metrics: { ... } } | null }
```
اگر محیط حوزه ندارد یا provider ندارد، مقدار `null` است.
یک اندپوینت تازه برای بازه و روند:
```
GET /api/v1/dashboard/domain-metrics?from&to&doctorUuid?&resourceUuid?&groupUuid?
→ { code, metrics: { <key>: { value, previous_value, change_percent } } }
GET /api/v1/dashboard/domain-metrics/trend?metric=production&from&to&interval=day|week|month
→ { points: [ { date, value } ] }
```
`date` میلادی برمی‌گردد و تبدیل جلالی در فرانت انجام می‌شود.
دلیل: رشتهٔ جلالی در پاسخ، مرتب‌سازی و بازه‌گیری را در فرانت می‌شکند.
بازهٔ پیش‌فرض سی روز.
حداکثر بازهٔ مجاز یک سال، وگرنه خطای ۴۲۲.
دلیل: بدون سقف، یک درخواست می‌تواند کل جدول ویزیت را اسکن کند.
`docs/api/dashboard.md` همان جلسه به‌روز می‌شود.
---
## ۶. پنل ادمین
فایل: `assets/admin/pages/DashboardPage.tsx`
- بخش «شاخص‌های دندانپزشکی» بعد از کارت‌های عمومی، فقط وقتی `domain_metrics` مقدار دارد.
- کارت‌ها با `StatCard` موجود.
- روند با `Recharts` که پروژه از قبل دارد.
- drill-down با `DataTable` موجود.
- انتخاب بازه با `PersianDateInput` موجود.
- فیلتر پزشک و یونیت با `SearchableSelect`.
- `TanStack Query` با `staleTime` معقول، چون این اعداد ثانیه‌ای عوض نمی‌شوند.
هیچ کامپوننت تازه‌ای ساخته نمی‌شود.
---
## ۷. تسک‌ها
| کد | تسک | فایل‌های اصلی | معیار پذیرش |
|---|---|---|---|
| DM3-01 | اینترفیس و رجیستری `DomainMetricProvider` | `src/Dashboard/Metric/` | محیط بدون حوزه، `null` می‌گیرد |
| DM3-02 | `MetricRequest` و `MetricSet` | همان | بازهٔ بزرگتر از یک سال ۴۲۲ می‌دهد |
| DM3-03 | `DentalMetricProvider` بخش مالی | `src/Dental/Metric/` | تولید و وصولی با داده‌ی ساختگی درست است |
| DM3-04 | بخش اشغال یونیت | همان | منبع بدون تقویم، صفر می‌دهد نه خطا |
| DM3-05 | بخش نوبت، حاضرنشده و لغو | همان | مخرج صفر، `null` می‌دهد نه تقسیم بر صفر |
| DM3-06 | بخش ترکیب خدمات و دندان‌های درمان‌شده | همان | خدمت بدون گروه در «سایر» می‌رود |
| DM3-07 | اتصال `domain_metrics` به چهار اندپوینت نقشی | `src/Dashboard/Controller/DashboardController.php` | داشبورد حوزه‌های دیگر کوئری اضافه نمی‌زند |
| DM3-08 | اندپوینت بازه و روند | همان | فیلترها ترکیبی کار می‌کنند |
| DM3-09 | بخش دندانی در صفحهٔ داشبورد | `assets/admin/pages/DashboardPage.tsx` | برای حوزهٔ زیبایی رندر نمی‌شود |
| DM3-10 | نمودار روند و drill-down | همان | خالی بودن داده، حالت خالی نشان می‌دهد نه خطا |
| DM3-11 | به‌روزرسانی `docs/api/dashboard.md` | `docs/api/` | نمونهٔ پاسخ با کد یکی است |
---
## ۸. تست‌ها
- محیط بدون حوزه: `domain_metrics` برابر `null` و هیچ کوئری اضافه‌ای اجرا نمی‌شود.
- محیط زیبایی: همان.
- محیط دندانپزشکی بدون هیچ ویزیت: همهٔ شاخص‌ها صفر یا `null`، بدون خطا.
- تقسیم بر صفر در هر نرخ: `null` برمی‌گردد و فرانت خط تیره نشان می‌دهد.
- پزشک فقط عدد خودش را می‌بیند، حتی با فیلتر پزشک دیگر.
- منشی به شاخص‌های مالی دسترسی ندارد.
- بازهٔ بزرگتر از یک سال: ۴۲۲.
- تست کارایی: تعداد کوئری با افزایش تعداد شاخص ثابت می‌ماند.
---
## ۹. ریسک‌ها
**کندی داشبورد با رشد داده.**
تصمیم فاز ۳ محاسبهٔ زنده است.
مهار: سقف بازه، ایندکس روی ستون‌های تاریخ و محیط، و یک تست کارایی که تعداد کوئری را قفل می‌کند.
اگر بعداً کند شد، جدول تجمیع پشت همین سرویس اضافه می‌شود بدون تغییر API.
**دو عدد متفاوت برای یک شاخص.**
مهار: هیچ فرمولی در فرانت نوشته نمی‌شود.
@@ -0,0 +1,218 @@
# فاز ۴ — برآورد درمان و نرخ پذیرش
> پیش‌نیاز: فاز ۱ تا ۳.
> خروجی قابل تست: دندانپزشک برآورد چندخدمتی می‌سازد، بیمار تصمیم می‌گیرد، و نرخ پذیرش در داشبورد دیده می‌شود.
---
## ۱. چرا این فاز جدا افتاد
در جلسهٔ تصمیم‌گیری قرار شد فاز ۱ بدون برآورد جلو برود تا زودتر به خروجی برسیم.
هزینه‌اش این است که تا این فاز، چهار شاخص وجود ندارند:
نرخ پذیرش، درمان زمان‌بندی‌نشده، درمان مجدد، و ارزش معوق طرح.
---
## ۲. نام‌گذاری
واژهٔ انگلیسی `Treatment Plan` در `CONTEXT.md` ممنوع است، چون بین `TreatmentProtocol` و `TreatmentCase` ابهام می‌ساخت.
واژهٔ این مفهوم:
**Treatment Estimate** — فهرست پیشنهادی خدمات روی دندان‌های مشخص، با قیمت، که به بیمار ارائه می‌شود و بیمار کل یا بخشی از آن را می‌پذیرد.
در فارسی همان «طرح درمان» است، چون زبان روزمرهٔ دندانپزشک همین است.
جدایی نام انگلیسی و فارسی عمدی است: کد باید بدون ابهام باشد، رابط کاربری باید آشنا باشد.
---
## ۳. مدل داده
### `TreatmentEstimate` — جدول `dental_treatment_estimates`
| ستون | نوع | توضیح |
|---|---|---|
| `id`, `uuid` | | |
| `entity_type`, `entity_id` | | جفت محیط |
| `patient_record_id` | int | |
| `doctor_id` | int, nullable | پزشک ارائه‌دهنده |
| `title` | string 150 | |
| `status` | string 20 | |
| `total_rials` | int | جمع همهٔ ردیف‌ها |
| `accepted_rials` | int | جمع ردیف‌های پذیرفته‌شده |
| `presented_at` | int, nullable | لحظهٔ ارائه به بیمار |
| `decided_at` | int, nullable | لحظهٔ تصمیم بیمار |
| `expires_at` | int, nullable | |
| `created_at`, `updated_at` | int | |
### `TreatmentEstimateItem` — جدول `dental_treatment_estimate_items`
| ستون | نوع | توضیح |
|---|---|---|
| `id`, `uuid` | | |
| `estimate_id` | int | `ON DELETE CASCADE` |
| `service_item_id` | int | `ON DELETE RESTRICT` |
| `name_snapshot` | string 200 | نام خدمت در لحظهٔ ارائه |
| `tooth_number` | smallint, nullable | |
| `surfaces` | json, nullable | |
| `target_code` | string 10, nullable | |
| `quantity` | smallint | |
| `unit_price_rials` | int | |
| `amount_rials` | int | |
| `status` | string 20 | |
| `phase` | smallint | فاز درمان، برای اولویت‌بندی |
| `sort_order` | smallint | |
| `payer_type` | string 20 | پیش‌فرض `self_pay` |
| `insurance_ref_id` | int, nullable | فقط رزرو شده، بدون منطق |
| `session_service_id` | int, nullable | وقتی انجام شد به ردیف فاکتور وصل می‌شود |
`name_snapshot` و `unit_price_rials` عمدی‌اند.
همان دلیلی که `TreatmentCaseArea` اسنپ‌شات می‌گیرد و در `docs/adr/0002` ثبت شده:
برآوردی که به بیمار داده شده، سند است و با تغییر تعرفهٔ فردا نباید بازنویسی شود.
دو ستون `payer_type` و `insurance_ref_id` تنها نقطهٔ اتصال بیمه‌اند.
در این فاز هیچ محاسبه‌ای رویشان نوشته نمی‌شود.
---
## ۴. ماشین حالت
### برآورد
```
draft ──▶ presented ──▶ accepted ──▶ in_progress ──▶ completed
│ │ │
├──▶ partially_accepted ─────┤
├──▶ rejected └──▶ cancelled
└──▶ expired
```
قواعد گذار:
- `draft → presented`: حداقل یک ردیف و جمع بزرگتر از صفر. `presented_at` ثبت می‌شود.
- `presented → accepted | partially_accepted | rejected`: با تصمیم بیمار. `decided_at` ثبت می‌شود و `accepted_rials` از جمع ردیف‌های پذیرفته‌شده حساب می‌شود.
- `presented → expired`: با یک job زمان‌بندی‌شده بعد از N روز. پیش‌فرض پیشنهادی ۹۰ روز، قابل تنظیم در `Config`.
- `accepted → in_progress`: با اولین ردیفی که انجام می‌شود.
- `→ completed`: وقتی همهٔ ردیف‌های پذیرفته‌شده انجام یا لغو شده‌اند. توسط پروجکتور، نه دستی.
### ردیف
```
proposed ──▶ accepted ──▶ scheduled ──▶ done
│ │ │ │
└▶ rejected └▶ cancelled └▶ cancelled └▶ redo ──▶ scheduled
```
`redo` حالت مستقل است، نه حذف رکورد.
دلیل: ورودی شاخص کیفیت است.
اگر درمان مجدد با ویرایش رکورد قبلی جایگزین شود، آن شاخص برای همیشه از بین می‌رود.
---
## ۵. چرا `expired` لازم است
نرخ پذیرش باید مخرجش برآوردهایی باشد که در آن بازه **ارائه** شده‌اند، نه برآوردهایی که در آن بازه **تصمیم‌گیری** شده‌اند.
اگر مخرج بر اساس تصمیم باشد، برآوردهایی که هنوز جواب نگرفته‌اند از مخرج بیرون می‌مانند و نرخ به‌صورت مصنوعی بالا می‌رود.
`expired` همان چیزی است که برآورد بی‌جواب قدیمی را از حالت معلق در می‌آورد.
---
## ۶. اتصال به موتور موجود
ردیف پذیرفته‌شده وقتی زمان‌بندی می‌شود:
- اگر خدمتش پروتکل فعال دارد، همان مسیر موجود `TreatmentCaseStarter` یک `TreatmentCase` باز می‌کند.
- اگر ندارد، فقط یک نوبت ساخته می‌شود.
هیچ مسیر رزرو تازه‌ای نوشته نمی‌شود.
وقتی ردیف انجام شد و در ویزیت فاکتور شد، `session_service_id` پر می‌شود و وضعیت ردیف `done` می‌گیرد.
از همان‌جا پروجکتور فاز ۲ چارت را به‌روز می‌کند.
هیچ ستون پولی از برآورد به `TreatmentSession` نمی‌رود.
قاعدهٔ `docs/adr/0006` سر جایش می‌ماند.
---
## ۷. API
```
GET /api/v1/dental/estimates?patientRecordUuid=&status=
POST /api/v1/dental/estimate
GET /api/v1/dental/estimate/{uuid}
PATCH /api/v1/dental/estimate/{uuid}
POST /api/v1/dental/estimate/{uuid}/present
POST /api/v1/dental/estimate/{uuid}/decision
DELETE /api/v1/dental/estimate/{uuid}
POST /api/v1/dental/estimate/{uuid}/items
PATCH /api/v1/dental/estimate-item/{uuid}
DELETE /api/v1/dental/estimate-item/{uuid}
POST /api/v1/dental/estimate-item/{uuid}/schedule
```
دسترسی:
| عملیات | clinic | doctor | secretary |
|---|---|---|---|
| ساخت و ویرایش برآورد | نه | بله | نه |
| ارائه به بیمار | نه | بله | بله |
| ثبت تصمیم بیمار | بله | بله | بله |
| زمان‌بندی ردیف پذیرفته‌شده | بله | بله | بله |
دلیل اینکه ثبت تصمیم را منشی هم دارد: تصمیم بیمار معمولاً پشت میز پذیرش گفته می‌شود.
`docs/api/dental.md` گسترش پیدا می‌کند.
---
## ۸. شاخص‌های تازه در داشبورد
| کلید | تعریف |
|---|---|
| `case_acceptance_rate` | جمع پذیرفته‌شده تقسیم بر جمع ارائه‌شده، در بازهٔ ارائه |
| `unscheduled_treatment` | جمع مبلغ ردیف‌های پذیرفته‌شده بدون نوبت |
| `redo_rate` | ردیف‌های درمان مجدد تقسیم بر ردیف‌های انجام‌شده |
| `estimate_backlog` | جمع مبلغ برآوردهای ارائه‌شدهٔ بی‌جواب |
اضافه‌شدنشان به `DentalMetricProvider` است، بدون تغییر اینترفیس.
---
## ۹. پنل ادمین
- تب تازه در پروندهٔ بیمار: «طرح درمان».
- ساخت ردیف با انتخاب خدمت و انتخاب دندان از روی همان `ToothChart` فاز ۲.
- نمای چاپی برای دادن به بیمار.
- ثبت تصمیم به‌صورت ردیف‌به‌ردیف با `Switch`، نه یک دکمهٔ کلی. دلیل: پذیرش جزئی حالت رایج است.
- کارت‌های تازه در بخش دندانی داشبورد.
---
## ۱۰. تسک‌ها
| کد | تسک | معیار پذیرش |
|---|---|---|
| DM4-01 | موجودیت‌های برآورد و ردیف | `TenantSchemaCoverageTest` سبز |
| DM4-02 | ماشین حالت برآورد در یک کلاس جدا | گذار غیرمجاز `AppException` می‌دهد |
| DM4-03 | ماشین حالت ردیف | همان |
| DM4-04 | محاسبهٔ جمع و جمع پذیرفته‌شده در پروجکتور | ویرایش ردیف، جمع را همگام نگه می‌دارد |
| DM4-05 | job انقضا با مهلت قابل تنظیم | برآورد قدیمی `expired` می‌شود، برآورد پذیرفته‌شده نه |
| DM4-06 | اندپوینت‌های برآورد | همهٔ حالت‌های دسترسی تست می‌شوند |
| DM4-07 | زمان‌بندی ردیف و اتصال به `TreatmentCaseStarter` | خدمت پروتکل‌دار دوره باز می‌کند، بقیه فقط نوبت |
| DM4-08 | اتصال ردیف به `SessionService` هنگام انجام | وضعیت `done` و به‌روزرسانی چارت |
| DM4-09 | چهار شاخص تازه | مخرج صفر، `null` می‌دهد |
| DM4-10 | تب طرح درمان در پنل | پذیرش جزئی درست ثبت می‌شود |
| DM4-11 | نمای چاپی | در تم تیره هم درست چاپ می‌شود |
| DM4-12 | مستندات API | مسیرها با کد یکی است |
---
## ۱۱. تصمیم‌های باز
این‌ها قبل از شروع فاز ۴ باید جواب بگیرند:
۱. مهلت انقضای برآورد چند روز باشد؟ پیشنهاد ۹۰ روز.
۲. درمان مجدد هزینه‌دار است یا صفر؟ روی شاخص تولید اثر مستقیم دارد.
۳. قیمت ردیف برآورد از تعرفهٔ لحظهٔ ارائه می‌آید یا قابل ویرایش دستی است؟ پیشنهاد: پیش‌فرض از تعرفه، قابل ویرایش با ثبت لاگ.
۴. آیا یک بیمار می‌تواند همزمان دو برآورد ارائه‌شده داشته باشد؟ پیشنهاد: بله، ولی داشبورد باید هشدار بدهد.
@@ -0,0 +1,232 @@
# فاز ۵ — پریو، لابراتوار، استریلیزاسیون، مواد مصرفی و تصاویر
> پیش‌نیاز: فاز ۱ تا ۴.
> خروجی قابل تست: شاخص‌های هزینه و کیفیت، و ثبت بالینی کامل‌تر.
این فاز چهار موضوع مستقل دارد.
هرکدام جداگانه قابل اجراست و ترتیبشان اجباری نیست.
---
## ۱. مواد مصرفی — عمدتاً موجود است
`Inventory` و `SessionConsumable` از قبل هستند.
| موجودیت | مسیر |
|---|---|
| `InventoryItem` | `src/Inventory/Entity/InventoryItem.php` |
| `InventoryPackage` و `InventoryPackageItem` | همان پوشه |
| `SessionConsumable` | `src/Patient/Entity/SessionConsumable.php` |
| `ServiceItemConsumable` | `src/ClinicService/Entity/ServiceItemConsumable.php` |
قیمت در `SessionConsumable` اسنپ‌شات می‌شود، مثل `SessionService`.
`ServiceItem` هم می‌تواند به یک بستهٔ مصرفی وصل شود.
**پس کار این فاز فقط این است:**
- بستهٔ پیش‌فرض دندانپزشکی به `DentalPreset` اضافه شود: کامپوزیت، ماده بی‌حسی، فایل روتاری، سوزن، ماسک، دستکش.
- شاخص `consumable_cost_ratio` به `DentalMetricProvider` اضافه شود.
هیچ موجودیت تازه‌ای لازم نیست.
اگر کسی جدول مصرف مواد دندانپزشکی جدا ساخت، منبع حقیقت دوم ساخته است.
### تسک‌ها
| کد | تسک | معیار پذیرش |
|---|---|---|
| DM5-01 | بستهٔ مصرفی دندانپزشکی در قالب پیش‌فرض | نصب دوم چیزی تکرار نمی‌کند |
| DM5-02 | شاخص `consumable_cost_ratio` | مخرج صفر، `null` می‌دهد |
---
## ۲. لابراتوار — تازه است
### مدل داده
`Lab` — جدول `dental_labs`
| ستون | نوع |
|---|---|
| `id`, `uuid` | |
| `entity_type`, `entity_id` | جفت محیط |
| `title` | string 150 |
| `phone` | string 20, nullable |
| `active` | bool |
| `created_at`, `updated_at` | int |
`LabOrder` — جدول `dental_lab_orders`
| ستون | نوع | توضیح |
|---|---|---|
| `id`, `uuid` | | |
| `entity_type`, `entity_id` | | |
| `lab_id` | int | |
| `patient_record_id` | int | |
| `estimate_item_id` | int, nullable | ردیف برآوردی که این سفارش برایش است |
| `tooth_numbers` | json | دندان‌های درگیر |
| `description` | string 500 | |
| `status` | string 20 | |
| `cost_rials` | int | |
| `sent_at`, `due_at`, `received_at` | int, nullable | |
| `created_at`, `updated_at` | int | |
### ماشین حالت
```
draft ─▶ sent ─▶ in_lab ─▶ ready ─▶ received ─▶ delivered
└────▶ returned_for_fix ─▶ in_lab
```
`due_at` مبنای هشدار تأخیر است.
یک job روزانه سفارش‌های گذشته از موعد و در حالت غیرنهایی را برای داشبورد علامت می‌زند.
اتصال به `estimate_item_id` اختیاری است ولی توصیه‌شده.
بدون آن، بهای تمام‌شدهٔ آن ردیف قابل محاسبه نیست و شاخص حاشیهٔ سود بی‌معنا می‌شود.
### API
```
GET /api/v1/dental/labs
POST /api/v1/dental/lab
PATCH /api/v1/dental/lab/{uuid}
GET /api/v1/dental/lab-orders?status=&overdue=
POST /api/v1/dental/lab-order
PATCH /api/v1/dental/lab-order/{uuid}
POST /api/v1/dental/lab-order/{uuid}/transition
```
دسترسی: هر چهار نقش می‌بینند و ثبت می‌کنند. لابراتوار کار مشترک درمانگاه است.
### شاخص‌ها
| کلید | تعریف |
|---|---|
| `lab_cost_ratio` | جمع هزینهٔ لابراتوار تقسیم بر تولید |
| `lab_overdue_count` | تعداد سفارش گذشته از موعد |
| `lab_turnaround_days` | میانگین فاصلهٔ ارسال تا دریافت |
### تسک‌ها
| کد | تسک | معیار پذیرش |
|---|---|---|
| DM5-03 | موجودیت `Lab` و مخزن | یکتایی نام در محیط |
| DM5-04 | موجودیت `LabOrder` و ماشین حالت | گذار غیرمجاز `AppException` |
| DM5-05 | اندپوینت‌های لابراتوار | همهٔ حالت‌های دسترسی |
| DM5-06 | job هشدار تأخیر | سفارش نهایی‌شده علامت نمی‌خورد |
| DM5-07 | سه شاخص لابراتوار | مخرج صفر |
| DM5-08 | صفحهٔ لابراتوار در پنل | فیلتر وضعیت و تأخیر |
---
## ۳. چارت پریودنتال — تازه است
### مدل داده
`PeriodontalExam` — جدول `dental_periodontal_exams`
| ستون | نوع |
|---|---|
| `id`, `uuid` | |
| `chart_id` | int |
| `examined_at` | int |
| `examined_by_user_id` | int, nullable |
| `note` | string 500, nullable |
`PeriodontalMeasurement` — جدول `dental_periodontal_measurements`
| ستون | نوع | توضیح |
|---|---|---|
| `exam_id` | int | `ON DELETE CASCADE` |
| `tooth_number` | smallint | |
| `site` | smallint | ۱ تا ۶ |
| `pocket_depth` | smallint | میلی‌متر |
| `recession` | smallint | |
| `bleeding_on_probing` | bool | |
| `mobility` | smallint | ۰ تا ۳ |
**چرا معاینه جدا از اندازه‌گیری:**
پریو دنباله‌ای است. مقایسهٔ معاینهٔ امروز با شش ماه پیش تمام ارزش این چارت است.
اگر اندازه‌ها روی خود دندان بازنویسی شوند، آن مقایسه از بین می‌رود.
این دقیقاً قرینهٔ `ToothStatus` است که عمداً فقط وضعیت جاری را نگه می‌دارد.
ثبت کامل یک معاینه ۱۹۲ عدد است.
پس فرم باید صفحه‌کلیدمحور باشد و با `Tab` پیش برود، وگرنه کسی استفاده‌اش نمی‌کند.
### تسک‌ها
| کد | تسک | معیار پذیرش |
|---|---|---|
| DM5-09 | دو موجودیت پریو | یکتایی دندان و سایت در معاینه |
| DM5-10 | اندپوینت ثبت و خواندن معاینه | ثبت دسته‌ای در یک درخواست |
| DM5-11 | فرم پریو صفحه‌کلیدمحور | حرکت با `Tab` بین سایت‌ها |
| DM5-12 | نمای مقایسهٔ دو معاینه | اختلاف با رنگ نشان داده می‌شود |
---
## ۴. استریلیزاسیون — تازه است
### مدل داده
`SterilizationCycle` — جدول `dental_sterilization_cycles`
| ستون | نوع |
|---|---|
| `id`, `uuid` | |
| `entity_type`, `entity_id` | |
| `device_resource_id` | int, nullable |
| `program` | string 50 |
| `started_at`, `finished_at` | int |
| `chemical_indicator_ok` | bool |
| `biological_test_at` | int, nullable |
| `result` | string 20 |
| `operator_user_id` | int, nullable |
| `note` | string 500, nullable |
اتوکلاو به‌عنوان `ClinicResource` تعریف می‌شود، نه یک جدول دستگاه تازه.
دلیل: نوع منبع از قبل قابل تعریف است و تقویم و دسترسی‌اش هم همان‌جاست.
در این فاز، سیکل استریل به گردش کار درمان گره نمی‌خورد.
فقط ثبت و گزارش است.
گره‌زدن ست ابزار به جلسهٔ درمان کار بزرگی است و باید جدا تصمیم‌گیری شود.
### تسک‌ها
| کد | تسک | معیار پذیرش |
|---|---|---|
| DM5-13 | موجودیت سیکل استریل | ثبت بدون دستگاه هم ممکن است |
| DM5-14 | اندپوینت ثبت و فهرست | فیلتر بازه و نتیجه |
| DM5-15 | یادآور تست بیولوژیک هفتگی | نبود تست در هفته، هشدار داشبورد |
| DM5-16 | صفحهٔ استریلیزاسیون در پنل | گزارش قابل چاپ |
---
## ۵. تصاویر بالینی — روی سیستم موجود
`PatientAttachment` از قبل هست و به پرونده وصل است.
کار این بخش فقط افزودن دو ستون اختیاری است:
```
tooth_numbers json nullable
image_type string 20 nullable periapical | bitewing | opg | cbct | photo
```
**چرا ستون روی همان جدول و نه جدول دندانی جدا:**
برخلاف ویژگی خدمت که تنظیمات مشترک همهٔ حوزه‌هاست، ضمیمه سند خود پرونده است و هر حوزه‌ای می‌تواند تصویر داشته باشد.
جدول جدا یعنی یک ضمیمه در دو جا و دو مسیر آپلود.
### تسک‌ها
| کد | تسک | معیار پذیرش |
|---|---|---|
| DM5-17 | دو ستون روی `PatientAttachment` | ضمیمهٔ بدون دندان مثل قبل کار می‌کند |
| DM5-18 | فیلتر ضمیمه بر اساس دندان در تب چارت | کلیک روی دندان، تصاویرش را نشان می‌دهد |
---
## ۶. رضایت آگاهانه — خارج از این سند
فرم رضایت آگاهانه در همهٔ حوزه‌ها لازم است، نه فقط دندانپزشکی.
ساختنش داخل ماژول دندانپزشکی یعنی حوزهٔ بعدی باید دوباره بسازدش.
پیشنهاد: سند جدا، در سطح پرونده بیمار.
@@ -0,0 +1,245 @@
# محتوای بستهٔ پیش‌فرض دندانپزشکی
> **وضعیت: پیش‌نویس.**
> این فهرست توسط دندانپزشک تأیید نشده است.
> تسک `DM1-15` مسدودکننده است و بدون آن فاز ۱ بسته نمی‌شود.
دلیل سختگیری: این فهرست در همهٔ کلینیک‌های نصب‌کننده کپی می‌شود.
اصلاح یک نام غلط بعد از نصب در پنجاه کلینیک، ممکن نیست.
قیمت همهٔ خدمات صفر است و مدیر باید تعرفهٔ خودش را وارد کند.
---
## ۱. گروه‌های خدمات
سیزده گروه، همه در سطح ریشه.
| کلید قالب | نام | ترتیب |
|---|---|---|
| `dx` | تشخیص و معاینه | ۱ |
| `radiology` | رادیولوژی | ۲ |
| `preventive` | پیشگیری | ۳ |
| `restorative` | ترمیمی | ۴ |
| `endodontics` | درمان ریشه | ۵ |
| `periodontics` | جراحی لثه و پریو | ۶ |
| `oral_surgery` | جراحی دهان و فک | ۷ |
| `fixed_prostho` | پروتز ثابت | ۸ |
| `removable_prostho` | پروتز متحرک | ۹ |
| `implant` | ایمپلنت | ۱۰ |
| `orthodontics` | ارتودنسی | ۱۱ |
| `pediatric` | دندانپزشکی کودکان | ۱۲ |
| `cosmetic` | زیبایی | ۱۳ |
درخت تک‌سطحی است.
دلیل: عمق بیشتر بدون نیاز واقعی، فقط پیمایش را سخت می‌کند و `CatalogCategory` تا عمق ۴ را همیشه اجازه می‌دهد اگر بعداً لازم شد.
---
## ۲. خدمات
ستون‌ها:
- **هدف** مقدار `target_scope`
- **مبنا** مقدار `pricing_basis`
- **دقیقه** مدت پیش‌فرض نوبت
- **جلسه** تعداد جلسهٔ پیش‌فرض
- **اثر چارت** مقدار `chart_effect` که پروجکتور فاز ۲ استفاده می‌کند
### تشخیص و معاینه
| کلید | نام | هدف | مبنا | دقیقه | جلسه | اثر چارت |
|---|---|---|---|---|---|---|
| `dx_exam` | معاینه و مشاوره | `none` | `flat` | ۱۵ | ۱ | — |
| `dx_emergency` | ویزیت اورژانس | `none` | `flat` | ۲۰ | ۱ | — |
| `dx_full_chart` | معاینهٔ کامل و چارت‌نگاری | `mouth` | `flat` | ۳۰ | ۱ | — |
| `dx_perio_chart` | چارت پریودنتال | `mouth` | `flat` | ۳۰ | ۱ | — |
### رادیولوژی
| کلید | نام | هدف | مبنا | دقیقه | جلسه | اثر چارت |
|---|---|---|---|---|---|---|
| `rad_pa` | رادیوگرافی پری‌اپیکال | `tooth` | `per_tooth` | ۱۰ | ۱ | — |
| `rad_bw` | رادیوگرافی بایت‌وینگ | `quadrant` | `per_quadrant` | ۱۰ | ۱ | — |
| `rad_opg` | رادیوگرافی پانورامیک | `mouth` | `flat` | ۱۵ | ۱ | — |
| `rad_cbct` | سی‌بی‌سی‌تی | `mouth` | `flat` | ۲۰ | ۱ | — |
### پیشگیری
| کلید | نام | هدف | مبنا | دقیقه | جلسه | اثر چارت |
|---|---|---|---|---|---|---|
| `prev_scaling` | جرم‌گیری | `mouth` | `flat` | ۳۰ | ۱ | — |
| `prev_scaling_arch` | جرم‌گیری یک فک | `arch` | `per_arch` | ۲۰ | ۱ | — |
| `prev_polish` | پالیش و بروساژ | `mouth` | `flat` | ۲۰ | ۱ | — |
| `prev_fluoride` | فلوراید تراپی | `mouth` | `flat` | ۱۵ | ۱ | — |
| `prev_fissure_sealant` | فیشورسیلانت | `tooth` | `per_tooth` | ۱۵ | ۱ | `filled` |
### ترمیمی
| کلید | نام | هدف | مبنا | دقیقه | جلسه | اثر چارت |
|---|---|---|---|---|---|---|
| `rest_composite_1` | ترمیم کامپوزیت یک سطحی | `tooth_surface` | `per_surface` | ۳۰ | ۱ | `filled` |
| `rest_composite_2` | ترمیم کامپوزیت دو سطحی | `tooth_surface` | `per_surface` | ۴۵ | ۱ | `filled` |
| `rest_composite_3` | ترمیم کامپوزیت سه سطحی | `tooth_surface` | `per_surface` | ۶۰ | ۱ | `filled` |
| `rest_amalgam` | ترمیم آمالگام | `tooth_surface` | `per_surface` | ۳۰ | ۱ | `filled` |
| `rest_buildup` | بازسازی تاج | `tooth` | `per_tooth` | ۴۵ | ۱ | `filled` |
| `rest_post_core` | پست و کور | `tooth` | `per_tooth` | ۶۰ | ۱ | `filled` |
### درمان ریشه
| کلید | نام | هدف | مبنا | دقیقه | جلسه | اثر چارت |
|---|---|---|---|---|---|---|
| `endo_single` | درمان ریشه تک‌کاناله | `tooth` | `per_canal` | ۶۰ | ۱ | `root_canal` |
| `endo_multi` | درمان ریشه چندکاناله | `tooth` | `per_canal` | ۹۰ | ۲ | `root_canal` |
| `endo_retreat` | درمان مجدد ریشه | `tooth` | `per_canal` | ۹۰ | ۲ | `root_canal` |
| `endo_pulpotomy` | پالپوتومی | `tooth` | `per_tooth` | ۴۵ | ۱ | `root_canal` |
| `endo_apicoectomy` | آپیکواکتومی | `tooth` | `per_tooth` | ۹۰ | ۱ | `root_canal` |
### جراحی لثه و پریو
| کلید | نام | هدف | مبنا | دقیقه | جلسه | اثر چارت |
|---|---|---|---|---|---|---|
| `perio_srp` | جرم‌گیری عمقی و تسطیح ریشه | `quadrant` | `per_quadrant` | ۴۵ | ۱ | — |
| `perio_flap` | جراحی فلپ | `quadrant` | `per_quadrant` | ۹۰ | ۱ | — |
| `perio_gingivectomy` | ژنژیوکتومی | `quadrant` | `per_quadrant` | ۶۰ | ۱ | — |
| `perio_crown_lengthening` | افزایش طول تاج | `tooth` | `per_tooth` | ۶۰ | ۱ | — |
| `perio_graft` | پیوند لثه | `quadrant` | `per_quadrant` | ۹۰ | ۱ | — |
### جراحی دهان و فک
| کلید | نام | هدف | مبنا | دقیقه | جلسه | اثر چارت |
|---|---|---|---|---|---|---|
| `surg_extraction` | کشیدن دندان ساده | `tooth` | `per_tooth` | ۳۰ | ۱ | `extracted` |
| `surg_extraction_surgical` | کشیدن دندان جراحی | `tooth` | `per_tooth` | ۶۰ | ۱ | `extracted` |
| `surg_wisdom` | جراحی دندان عقل نهفته | `tooth` | `per_tooth` | ۹۰ | ۱ | `extracted` |
| `surg_root_remnant` | خارج کردن ریشهٔ باقی‌مانده | `tooth` | `per_tooth` | ۴۵ | ۱ | `extracted` |
| `surg_biopsy` | نمونه‌برداری | `mouth` | `flat` | ۴۵ | ۱ | — |
### پروتز ثابت
| کلید | نام | هدف | مبنا | دقیقه | جلسه | لابراتوار | اثر چارت |
|---|---|---|---|---|---|---|---|
| `fixed_pfm_crown` | روکش پرسلن روی فلز | `tooth` | `per_unit` | ۶۰ | ۲ | بله | `crown` |
| `fixed_zirconia_crown` | روکش زیرکونیا | `tooth` | `per_unit` | ۶۰ | ۲ | بله | `crown` |
| `fixed_bridge_unit` | هر واحد بریج | `tooth` | `per_unit` | ۶۰ | ۲ | بله | `crown` |
| `fixed_inlay_onlay` | اینله و آنله | `tooth` | `per_unit` | ۶۰ | ۲ | بله | `filled` |
| `fixed_temp_crown` | روکش موقت | `tooth` | `per_unit` | ۳۰ | ۱ | خیر | `crown` |
### پروتز متحرک
| کلید | نام | هدف | مبنا | دقیقه | جلسه | لابراتوار |
|---|---|---|---|---|---|---|
| `remov_complete_denture` | دست دندان کامل | `arch` | `per_arch` | ۶۰ | ۵ | بله |
| `remov_partial_acrylic` | پارسیل آکریلی | `arch` | `per_arch` | ۶۰ | ۴ | بله |
| `remov_partial_frame` | پارسیل فریم فلزی | `arch` | `per_arch` | ۶۰ | ۵ | بله |
| `remov_reline` | ریلاین | `arch` | `per_arch` | ۳۰ | ۱ | بله |
| `remov_repair` | تعمیر پروتز | `arch` | `per_arch` | ۳۰ | ۱ | بله |
### ایمپلنت
| کلید | نام | هدف | مبنا | دقیقه | جلسه | لابراتوار | اثر چارت |
|---|---|---|---|---|---|---|---|
| `impl_fixture` | کاشت فیکسچر | `tooth` | `per_unit` | ۹۰ | ۱ | خیر | `implant` |
| `impl_abutment` | اباتمنت | `tooth` | `per_unit` | ۴۵ | ۱ | بله | `implant` |
| `impl_crown` | روکش روی ایمپلنت | `tooth` | `per_unit` | ۶۰ | ۲ | بله | `implant` |
| `impl_bone_graft` | پیوند استخوان | `tooth` | `per_unit` | ۹۰ | ۱ | خیر | — |
| `impl_sinus_lift` | سینوس لیفت | `quadrant` | `per_quadrant` | ۱۲۰ | ۱ | خیر | — |
### ارتودنسی
| کلید | نام | هدف | مبنا | دقیقه | جلسه |
|---|---|---|---|---|---|
| `ortho_consult` | مشاورهٔ ارتودنسی | `none` | `flat` | ۳۰ | ۱ |
| `ortho_fixed` | ارتودنسی ثابت دو فک | `mouth` | `flat` | ۶۰ | ۱۸ |
| `ortho_fixed_single_arch` | ارتودنسی ثابت یک فک | `arch` | `per_arch` | ۶۰ | ۱۲ |
| `ortho_adjust` | ویزیت تنظیم | `mouth` | `per_session` | ۲۰ | ۱ |
| `ortho_retainer` | پلاک نگهدارنده | `arch` | `per_arch` | ۳۰ | ۱ |
### دندانپزشکی کودکان
| کلید | نام | هدف | مبنا | دقیقه | جلسه | نوع دندان | اثر چارت |
|---|---|---|---|---|---|---|---|
| `ped_exam` | معاینهٔ کودک | `none` | `flat` | ۲۰ | ۱ | `any` | — |
| `ped_filling` | ترمیم دندان شیری | `tooth_surface` | `per_surface` | ۳۰ | ۱ | `primary_only` | `filled` |
| `ped_pulpotomy` | پالپوتومی شیری | `tooth` | `per_tooth` | ۴۵ | ۱ | `primary_only` | `root_canal` |
| `ped_ssc` | روکش استیل زنگ‌نزن | `tooth` | `per_unit` | ۴۵ | ۱ | `primary_only` | `crown` |
| `ped_extraction` | کشیدن دندان شیری | `tooth` | `per_tooth` | ۲۰ | ۱ | `primary_only` | `extracted` |
| `ped_space_maintainer` | فضانگهدار | `quadrant` | `per_quadrant` | ۳۰ | ۱ | `primary_only` | — |
### زیبایی
| کلید | نام | هدف | مبنا | دقیقه | جلسه | لابراتوار | اثر چارت |
|---|---|---|---|---|---|---|---|
| `cosm_bleaching_office` | بلیچینگ مطبی | `mouth` | `flat` | ۶۰ | ۱ | خیر | — |
| `cosm_bleaching_home` | بلیچینگ خانگی | `mouth` | `flat` | ۳۰ | ۱ | بله | — |
| `cosm_veneer_composite` | ونیر کامپوزیت | `tooth` | `per_unit` | ۶۰ | ۱ | خیر | `filled` |
| `cosm_veneer_porcelain` | لمینت سرامیکی | `tooth` | `per_unit` | ۶۰ | ۲ | بله | `crown` |
| `cosm_gum_contouring` | اصلاح طرح لبخند لثه | `arch` | `per_arch` | ۶۰ | ۱ | خیر | — |
جمع: هفتاد و یک خدمت.
---
## ۳. نوع منبع
| کلید | کد | نام |
|---|---|---|
| `unit` | `dental_unit` | یونیت دندانپزشکی |
| `sterilizer` | `autoclave` | اتوکلاو |
نوع منبع ساخته می‌شود، ولی هیچ منبعی ساخته نمی‌شود.
تعداد یونیت را فقط خود مدیر می‌داند.
نوع `autoclave` فقط در فاز ۵ استفاده می‌شود، ولی چون تعریف نوع منبع ارزان است، همان اول ساخته می‌شود تا مدیر بتواند دستگاهش را ثبت کند.
---
## ۴. پروتکل‌ها
فقط برای خدماتی که واقعاً چندجلسه‌ای‌اند.
| کلید | خدمت | جلسه | فاصله روز |
|---|---|---|---|
| `proto_endo_multi` | `endo_multi` | ۲ | ۷ |
| `proto_endo_retreat` | `endo_retreat` | ۲ | ۷ |
| `proto_fixed_crown` | `fixed_pfm_crown` | ۲ | ۱۰ |
| `proto_zirconia` | `fixed_zirconia_crown` | ۲ | ۱۰ |
| `proto_denture` | `remov_complete_denture` | ۵ | ۷ |
| `proto_partial_frame` | `remov_partial_frame` | ۵ | ۷ |
| `proto_impl_crown` | `impl_crown` | ۲ | ۱۴ |
| `proto_ortho_fixed` | `ortho_fixed` | ۱۸ | ۲۸ |
پزشک سرپرست پروتکل هنگام نصب مشخص نمی‌شود.
اگر محیط فقط یک پزشک دارد همان انتخاب می‌شود، وگرنه خالی می‌ماند و مدیر باید تکمیل کند.
دلیل: انتخاب خودکار پزشک اشتباه، مسئولیت بالینی را به کسی نسبت می‌دهد که قبول نکرده.
---
## ۵. مواد مصرفی — فاز ۵
| کلید | نام | واحد |
|---|---|---|
| `cons_composite` | کامپوزیت | سرنگ |
| `cons_bond` | باندینگ | میلی‌لیتر |
| `cons_anesthetic` | کارپول بی‌حسی | عدد |
| `cons_needle` | سوزن تزریق | عدد |
| `cons_rotary_file` | فایل روتاری | عدد |
| `cons_gutta` | گوتاپرکا | عدد |
| `cons_glove` | دستکش | جفت |
| `cons_mask` | ماسک | عدد |
| `cons_suction_tip` | ساکشن یک‌بار مصرف | عدد |
| `cons_impression` | ماده قالب‌گیری | گرم |
---
## ۶. چک‌لیست بازبینی تخصصی
دندانپزشک بازبین باید این‌ها را جواب بدهد:
۱. نام هر خدمت با زبان رایج مطب می‌خواند یا اصطلاح کتابی است؟
۲. مدت پیش‌فرض هر خدمت واقع‌بینانه است؟
۳. مبنای قیمت هر خدمت درست است؟ مثلاً درمان ریشه به‌ازای کانال قیمت می‌خورد یا به‌ازای دندان؟
۴. کدام خدمت جا افتاده که در هر مطب هست؟
۵. کدام خدمت اضافه است و در مطب عمومی استفاده نمی‌شود؟
۶. اثر چارت هر خدمت درست است؟
۷. تعداد جلسه و فاصلهٔ پروتکل‌ها منطقی است؟
+14 -2
View File
@@ -1534,6 +1534,18 @@
"1532": "Community 1532",
"1533": "Community 1533",
"1534": "Community 1534",
"1535": "Community 1535",
"1536": "Community 1536"
"1536": "Community 1536",
"1537": "Community 1537",
"1538": "Community 1538",
"1540": "Community 1540",
"1546": "Community 1546",
"1553": "Community 1553",
"1557": "Community 1557",
"1558": "Community 1558",
"1559": "Community 1559",
"1560": "Community 1560",
"1561": "Community 1561",
"1562": "Community 1562",
"1568": "Community 1568",
"1569": "Community 1569"
}
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
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "label": "reference.php", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1"}, {"id": "config_reference_app", "label": "App", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1571"}, {"id": "config_reference_app_config", "label": ".config()", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1578"}, {"id": "config_reference_routes", "label": "Routes", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1651"}, {"id": "config_reference_routes_config", "label": ".config()", "file_type": "code", "source_file": "config/reference.php", "source_location": "L1658"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "target": "paramconfigurator", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L7", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "target": "config_reference_app", "relation": "contains", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1571", "weight": 1.0}, {"source": "config_reference_app", "target": "config_reference_app_config", "relation": "method", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1578", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_config_reference_php", "target": "config_reference_routes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1651", "weight": 1.0}, {"source": "config_reference_routes", "target": "config_reference_routes_config", "relation": "method", "confidence": "EXTRACTED", "source_file": "config/reference.php", "source_location": "L1658", "weight": 1.0}], "raw_calls": [{"caller_nid": "config_reference_app_config", "callee": "AppReference", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/config/reference.php", "source_location": "L1581", "receiver": null}]}
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_docs_adr_0008_teeth_are_not_treatment_areas_md", "label": "0008-teeth-are-not-treatment-areas.md", "file_type": "document", "source_file": "docs/adr/0008-teeth-are-not-treatment-areas.md", "source_location": "L1"}, {"id": "adr_0008_teeth_are_not_treatment_areas_teeth_are_not_treatment_areas", "label": "Teeth are not Treatment Areas", "file_type": "document", "source_file": "docs/adr/0008-teeth-are-not-treatment-areas.md", "source_location": "L1"}, {"id": "adr_0008_teeth_are_not_treatment_areas_consequences", "label": "Consequences", "file_type": "document", "source_file": "docs/adr/0008-teeth-are-not-treatment-areas.md", "source_location": "L11"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_adr_0008_teeth_are_not_treatment_areas_md", "target": "adr_0008_teeth_are_not_treatment_areas_teeth_are_not_treatment_areas", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/adr/0008-teeth-are-not-treatment-areas.md", "source_location": "L1", "weight": 1.0}, {"source": "adr_0008_teeth_are_not_treatment_areas_teeth_are_not_treatment_areas", "target": "adr_0008_teeth_are_not_treatment_areas_consequences", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/adr/0008-teeth-are-not-treatment-areas.md", "source_location": "L11", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
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

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