Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.
- drop PriceList/PriceListItem, their repositories and the seven
/api/v1/price-list(s) endpoints; PricingController keeps only quote and
the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
/service-items/{uuid}/tariffs endpoints; creating or repricing a service
no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
duration columns, which DurationCalculator and ServiceSelectionValidator
still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
tariff modal and the service detail tariffs tab; useAppointmentInvoice
moves to its own hook file
Migration drops price_lists, price_list_items, service_tariffs and the
override price column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.
What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".
BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.
The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.
Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service page could not say which category a service belongs to, so the
containment edges defined in settings had nothing to match against.
A Categories tab now selects one — and only selects. Creating, renaming and
deleting stay in Settings > Categories: if every page could create one,
"whole body" would exist three times with three spellings and the
includes edge would stop catching anything.
PATCH /api/v1/service-item/{uuid} carries the choice as
catalog_category_uuid. Absent field leaves the current category alone, null
clears it, and a category from another environment is refused with 422 —
the uuid arrives in the request body where TenantFilter does not reach.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Categories are the taxonomy both services and resources select from, but
until now they could only be reached through the service catalog, so every
environment ended up with its own spelling of "whole body".
- Settings > Categories page: global CRUD plus the "includes" edge
- POST/GET/DELETE /api/v1/service-category/{uuid}/includes — a DAG, kept
separate from `parent` because "hand" sits under both "whole body" and
"upper limb"; a cycle is refused with 422
- PUT /api/v1/resource/{uuid}/categories — full replacement, and a category
from another environment is rejected explicitly since the uuid arrives in
the request body where TenantFilter does not reach
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps against the spec. Resources could not be categorised at all — only
services carried a catalog category — so "this device is for hands and feet"
was unsayable. And CatalogCategory::$parent is a tree built for menu ordering:
one parent per category. Laser areas overlap, so "hand" belongs under both
"whole body" and "upper limb" at once, which a tree cannot express.
Containment is therefore a separate directed acyclic graph
(catalog_category_includes) sitting beside the display hierarchy, and resources
join the existing clinic-wide categories through a many-to-many rather than
growing a parallel list of their own.
CategoryClosureResolver walks it transitively: whole body includes lower body
includes foot, so whole body includes foot without anyone writing that pair
down. The walk reads every edge of the environment in one query and traverses
in memory — a query per level would tie round-trips to graph depth. The visited
set doubles as the cycle guard, so even data that already contains a loop
cannot hang the traversal, and assertNoCycle refuses to create one.
Selection now rejects picking an area together with a category that contains
it: "whole body laser" and "hand laser" in one appointment is a 422 with a
Persian message naming both. This replaces hand-written incompatible_with pairs
for the area case — defined once on the category instead of per item pair —
while that relation stays for incompatibilities that have nothing to do with
areas.
Nine tests, including the two-parents case a tree could not hold, the cycle
refusal, the self-edge, and the empty-graph boundary. TenantSchemaCoverageTest
caught the new edge entity as unclassified; it is registered as an aggregate
child of the parent category, which is what the constructor already enforces.
Suite 1286 green, phpstan at its 14-error baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chain the spec asks for, plus the branch level that already has data:
resource+option, resource+service, branch override, then the item's own value.
Duration and price resolve independently. If they resolved together the first
override would silently swallow the other value — a resource that only differs
in how long it takes would also drop the branch's tariff.
Each resolved value carries where it came from. Without that, the panel cannot
label a number "from the branch" or "service default", and "why this number?"
becomes a four-table investigation.
Two rules worth stating: null means inherit while zero is a real value, so a
free service keeps its zero instead of inheriting the parent's price; and an
inactive offering is skipped whole, since "this resource does not perform this
right now" is not the same as "I have no opinion on the numbers".
The parent service is passed in rather than looked up from the item's group.
The booking flow already holds both, and a reverse query would be a second way
to answer a question that already has an answer in hand.
Eight tests: one per level with the other levels populated so the winner is
provable, plus independent resolution, the inactive skip, zero, and resolving
the service itself without a parent.
Suite 1272 green, phpstan at its 14-error baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Removed package consumption flags and related properties from PriceQuote.
- Eliminated unused domain event publishing for policies and waitlist in Schedule.
- Cleaned up BookingEngineSeeder by removing package and policy related logic.
- Updated SeedScenariosCommand to reflect removal of policies from output.
- Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration.
- Removed domain event assertions from tests related to resource blocking.
The task 09 architecture specified FieldRegistry, OperatorRegistry, six engine
classes and a stored specificity. What shipped was a single PolicySchema
constant list, six operators, one resolver and a specificity recomputed on
every booking. Each shortcut was defensible on its own; together they left the
starred risk the task itself recorded — a field can be advertised in the form
and supplied by nobody, and the rule silently never matches.
OperatorRegistry now holds all eleven operators. The five that were missing are
real capability, not ceremony: greater_or_equal and less_or_equal make boundary
rules expressible without off-by-one, not_in is the natural way to write an
exclusion, between stops "18 to 65" needing two clauses, and days_since is the
documented operator for "more than N days since" — until now every caller
computed that by hand. between is inclusive at both ends because that is what
the Persian phrasing means and what the user will type.
FieldRegistry is now the single source: it builds the form schema and extracts
the value, so a field that exists in one and not the other is impossible. It
also declares which categories each field belongs to, which is what the closed
list per category used to do separately. Adding it immediately caught its own
first case — last_visit_at was advertised and supplied nowhere, so the guard
now populates it and days_since has something to read.
The six engines are thin on purpose. They give the call site a type — "the
pricing engine" rather than "the resolver with the string pricing" — and a
place for evaluateIsolated, which the sandbox needs to answer "what would this
one rule do". Conflict resolution and effect combination stay in
PolicyResolver: six copies of that would be six places to break.
specificity is a stored column now, computed on save with the documented
weights, and the migration backfills existing rows with the same formula. Left
at zero they would all have tied and the ordering would have changed overnight.
Field names stay as they are rather than moving to the document's dotted names
(patient.age). Stored condition_json rows point at the current names on live
clinic policies; renaming them is a data migration, and the mapping is not
one-to-one — implementation_notes.md says as much.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 06's engine could only be switched on through the API, and nothing
checked whether the environment was ready for it. Since the mode choice is
irreversible, picking it with no resources defined would lock a clinic into a
state where no appointment is ever computable.
Backend now refuses that: resource mode requires at least one active resource,
with a message that says what to define first. Same shape as the existing
service-mode guard, applied on both save paths.
The panel shows the same conditions as a ✓/✗ list before the choice is made,
each unmet one linking to where it gets fixed — a 422 after an irreversible
decision is the wrong place to learn about a prerequisite.
Also adds the search step (minimum 5 minutes) and extends the existing mode
cards to three rather than building a parallel component.
No strategy picker: task 06 never built the strategies, and an empty menu reads
worse than an absent one.
GET /api/v1/service-items now returns has_segments, computed with one aggregate
query for the whole list rather than one per service.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.
Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited
Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount
The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.
Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.
Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.
additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.
ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.
Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.
Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.
Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.
1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.
The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services.
- Created StaffMyServicesPage to display assigned services for staff users.
- Added migration to link clinic staff rows to user accounts for ROLE_STAFF access.
- Defined StaffPermissions class for static permissions related to staff role.
- Introduced StaffRouteGuardSubscriber to restrict API access for staff users.
- Developed StaffAccountService for managing staff user accounts and linking them to clinic staff.
- Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment.
- Implemented tests for staff dashboard access to ensure proper permissions and access control.
- Created tests for staff login context to verify correct environment visibility based on user roles.
Phase 7 concluded that aggregate children needed no column of their own,
because every repository query anchors to its root. That was true of the
repositories, and it missed the case where the anchor never happens:
$item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']);
A lookup by uuid is itself an unanchored query, and TenantFilter cannot help
when the table has no column to filter on. All three leaks phase 7 found had
exactly this shape, including the one that put another environment's service
price on a patient's invoice.
Measuring which children are actually loaded that way gives eight of the
twenty-five — service_items (15 call sites), patient_sessions (7),
session_payments, patient_notes, patient_calls, patient_messages,
patient_attachments, patient_medical_records. They now carry their own pair
and leave AGGREGATE_CHILDREN; the other seventeen are only ever traversed
from their root and stay as they were.
The pair is derived from the root inside the constructor rather than passed
in, so no creation site can forget it and the value has one source. A root
never changes environment, so the copy is written once and cannot drift.
This is defence at the data layer rather than at the entry point: a forgotten
guard now returns nothing instead of another environment's row. The existing
TenantOwnershipChecker guards stay as the outer layer.
Verified against an imported production database: 8 tables backfilled, zero
rows unmatched, zero rows inconsistent with their root. Dropping the column
again turns the leak test red.
Tests: 911 backend (+5). PHPStan unchanged at 17.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 7 was scoped to guard aggregate children, which the Doctrine filter cannot
reach. Measuring first — as the plan required — moved the target: all 22 children
and their 20 repositories were already sound. Every list query anchors on its
root, and ServiceItemRepository even joins service_sections and filters on the
pair by hand. A repository-level guard would have found nothing.
The real exposure was one layer up. Where a uuid arrives from a request body or
query string, the entity it names is loaded by uuid alone, and the filter is no
help: aggregate children have no tenant column, and a panel user who never chose
an environment is not filtered at all. Three leaks, each proven by removing the
fix and watching the new tests go red:
- GET /api/v1/appointment-service-slots accepted service_item_uuids from any
environment. Existence, bookable state and duration leaked through the error
messages and the returned slots. The booking path in the same controller had
guarded this since it was written; the slot path never did.
- POST /api/v1/my/appointment attached service_section_uuid, service_item_uuid,
staff_uuid and the service list without any check, and persisted them onto the
appointment. A write, not just a read.
- PatientService did the same in all three of its loops — pricing, session
create, session update — so another environment's service price entered the
invoice and its SessionService row was stored, staff included.
TenantOwnershipChecker is the single place that answers "does this belong to the
current environment?". It reads getEntityType()/getEntityId(), so ServiceItem now
delegates that pair to its section: an aggregate child exposing the tenant it
inherits. An entity that exposes no pair throws rather than returning false —
silence here builds an always-closed guard, which is its own bug.
TenantLookupInventoryTest keeps a per-file count of these lookups. It earned its
place immediately: the first run found more sites than the manual grep had, and
reviewing them turned up the third PatientService loop. StaffController looked
unguarded until read properly — ownsStaff sits two lines below the null check.
One assertion was wrong before it was right: the create-path test read
`$session['services'] ?? []`, which passes vacuously. It now counts the stored
rows through the repository, and fails without the fix.
Tests: 879 passing. PHPStan unchanged at its 17 pre-existing errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).
Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.
- add ServiceCategory enum + GET /api/v1/service-categories as the single source
of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
category_coverages (needs insurances.update) and returns the effective
percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
and align the client-side BillingCalculator mirror in CreateStep
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Services are referenced by appointments, sessions, invoices and payment history,
so deleting one orphans/corrupts those records (deleting a section cascaded to
its services too). Make deletion impossible:
- Backend: DELETE /service-item/{uuid} and DELETE /service-section/{uuid} now
always return 409 (ERR_SERVICE_ITEM_IN_USE) with a message pointing to
deactivate; no rows are touched. Deactivate stays via PATCH active=false.
- Frontend: removed the section delete button, its confirm dialog, the delete
mutation, and the now-unused delete state/flag/icon from ClinicServicesPage.
Section and item deactivate toggles are unchanged.
Tests: ServiceItemDeleteCleanupTest rewritten — delete of item and section both
rejected (409) and the row survives. docs/api/clinic-services.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clinic-member-doctor permission system (ClinicDoctorPermission) lagged the
secretary system: only 6 resources, enforced in ~6 places, dead toggles
(services.update never checked), and a sidebar showing just appointments+patients.
Bring it to parity so a clinic owner can control exactly what each member doctor
does — while an independent doctor stays completely unrestricted.
Coverage: add insurances, addresses, inventory, tags, staff, discounts, sms to
ClinicDoctorPermission::DEFAULT_PERMISSIONS + DoctorPermissionsModal
(subscription/clinic_doctors stay owner-only by design).
New App\Clinic\Security\ClinicDoctorAccessChecker (parallel to
SecretaryAccessChecker):
- denyUnlessGranted(user, resource, action): 403 only for a clinic-member doctor
in the clinic context; owner/admin/secretary/independent-doctor pass through.
- memberClinicId(user): resolves the member doctor to the CLINIC's tenant so the
role-based controllers (Inventory/Tag/Staff/Discount/Sms) stop showing them
their personal tenant in clinic context.
Enforcement wired into 10 controllers alongside the existing secretary gates:
ClinicService (services), Insurance (insurances), Patient (patients+payments),
Staff, Discount, Inventory, Tag, SmsWallet, Payment, PaymentMethod.
Frontend: the guest-doctor sidebar branch now exposes every permitted resource
(gated by can()) plus a «تنظیمات» entry; both settings navs (PurchaseSubscription
Sidebar + SETTINGS_MENU) are now permission-filtered for a scope=clinic doctor,
not just secretaries; my-payments route gets the missing payments permission.
CRUD-button gating already applies (usePermissions is role-agnostic).
Tests: ClinicDoctorPermissionEnforcementTest (member denied/allowed +
independent-doctor-unrestricted); guest-doctor sidebar gating. Backend 375 pass,
frontend 503 pass. docs/api/clinic.md updated with the full resource set +
enforcement notes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Secretaries could reach neither the services module (EntityContextResolver
does not recognise a secretary as clinic owner, so they resolved to
`unknown` → 403) nor had any toggle to grant it. Add `services` as a
first-class secretary permission resource, enforced end-to-end.
Backend
- DoctorSecretary::DEFAULT_PERMISSIONS: new `services` resource (default-deny).
- SecretaryAccessChecker::resolveOwnerEntity(): reusable owner (clinic/doctor)
resolution from the secretary's active context, for controllers whose data
is fetched by [entityType, entityId] and whose generic resolver is not
secretary-aware.
- ClinicServiceController: resolveEntity() is now secretary-aware; every action
(sections, items, tariffs — 13 total) guards with `services` view/create/
update/delete via denyUnlessGranted, ahead of the subscription gate.
Frontend
- SecretaryPermissions type + MySecretariesPage + SecretariesPage: `services`
section so owners can grant it.
- Sidebar (secretary branch): services / inventory / tags menu items gated by
can(resource, 'view').
- RoleRoute: a secretary now needs the page's `permission` to open it (direct
URL entry included); clinic-services, inventory, tags-settings routes accept
secretary + permission gate.
Tests
- SecretaryResourceEnforcementTest: services denied-by-default, allowed-when-
granted, create-denied-while-view-granted.
- Sidebar.test: secretary menu gating for services/inventory/tags.
Docs: secretary.md + clinic-services.md updated with the `services` resource
and the resolveOwnerEntity note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Consolidated the calculation of patient and insurance shares into a single method using BillingCalculator.
- Introduced new fields in PatientSession to store breakdown of insurance shares and patient share.
- Updated the API responses to include the new fields for consistency across payment, invoice, and claims dashboard.
- Added migration to backfill existing sessions with appropriate values for the new fields.
- Implemented tests to ensure the correctness of the new logic and verify that the breakdown sums to the gross total.
- Redesigned the claims dashboard to provide a more user-friendly overview of patient claims and their statuses.
APP_DEBUG=0 in .env means doctrine.dbal.profiling, which defaults to
%kernel.debug%, was off in tests too, so doctrine.debug_data_holder was never
registered. Every test calling countQueries() errored out — all four N+1
regression tests had been dead for as long as they have existed. Turning
profiling on for when@test brings the harness back.
Three of the four passed immediately. The fourth was a real N+1: the
service-coverage endpoint batch-fetched its ServiceItem entities to avoid one
find() per row, but ServiceItem maps staffMembers as fetch: EAGER, so hydrating
N items fired N extra collection loads and the batch bought nothing. Six
coverage rows cost 11 queries where one row cost 6.
ServiceItemRepository::findUuidsByIds() returns the id => uuid map as a scalar
query, so no entity is hydrated and no eager collection is touched.
Also adds the query-count assertion for next_available_at that could not be
written while the harness was broken. Confirmed it fails against the previous
per-day implementation (40 queries for 2 locations, 113 for 6) and passes now.
Suite: 411 tests, 2 failures — both pre-existing and unrelated
(LowTierFixesTest, PatientWalletSessionSettleTest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.
feat(command): create AssignScheduleClinicCommand to move schedules
- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.
feat(context): implement EntityContext and EntityContextResolver
- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.
test: add ServiceModeContextTest for appointment scheduling
- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
- Introduced `consumables` field in `ServiceItem` to allow multiple individual items alongside inventory packages.
- Created `ServiceItemConsumable` entity to manage individual consumable items linked to a service.
- Updated `ServiceItemController` to handle CRUD operations for consumables.
- Enhanced `ServiceDetailPage` and `ServiceItemFormModal` to display and manage consumables.
- Added tests to ensure functionality for adding, updating, and validating consumables.
- Updated API documentation to reflect changes in service item structure and consumables.
- Added `inventory_package_uuid` and `inventory_package_title` fields to the `ServiceItem` interface.
- Updated API documentation to reflect new fields in service item responses.
- Implemented methods in `ClinicServiceController` to handle inventory package associations.
- Created `ServiceItemAuditLog` entity and repository for tracking changes to service items.
- Added functionality to log changes to service items, including inventory package associations.
- Implemented tests for attaching/detaching inventory packages and auditing changes.
- Created database migrations for new fields and audit log table.
- Implement PriceInput component tests to validate Persian and Arabic numeral handling, input formatting, and controlled behavior.
- Create ServiceDetailPage component with detailed service information, including pricing, insurance coverage, and editing capabilities.
- Add API tests for service item detail retrieval and coverage synchronization with insurance contracts.
- Ensure proper error handling and user feedback for service item retrieval and coverage management.
- patient_debt_rials now = session remaining (final - discount - paid), so the
card status and invoice status stay consistent with is_paid even after a
session is edited post-invoicing (no more '0 remaining but تکمیل پرداخت').
- InvoiceSummaryModal: remaining subtracts discount; the فاکتور status reflects
actual settlement (تسویه شده / بدهکار), and the payments table shows a
«مجموع پرداختیها» total row.
- Add GET /api/v1/service-items (all owner services) and make the service
picker searchable across all services without first choosing a section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Introduced a new booking mode in WeeklySchedule to support service-based appointments.
- Updated SlotCalculatorService to calculate available start times based on selected service durations and buffer times.
- Enhanced AppointmentController to handle service items during booking, calculating slot_end on the server side.
- Implemented validation to ensure at least one bookable service exists for doctors in service mode.
- Added new API endpoint to retrieve available appointment slots based on selected services.
- Updated MyAppointmentsController to accept service items during appointment creation.
- Modified ServiceItem entity to include a bookable flag, allowing services to be marked for scheduling.
- Created migration to add bookable column to service_items table.
- Added tests for service-based slot calculations and validation logic.
Render the clinic services page (sections → services) inside the settings
sub-navigation shell (SettingsLayout, "خدمات" active) to match the Figma
settings design. Restyle section cards to show the service count and a
status toggle with edit/delete actions, and service cards with labelled
price/duration and personnel chips.
A service can now have multiple personnel: add an additive many-to-many
ServiceItem↔ClinicStaff (staffMembers, EAGER) while keeping the legacy
single `staff` column mirrored for backward compatibility. Endpoints accept
`staff_uuids[]` (falling back to the legacy single `staff_uuid`) and return
`staff_members[]`; the section list now reports `items_count`.
Backfill-safe: pre-migration rows fall back to the single staff in toArray.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure clinic-services from two-pane to Figma two-view flow:
sections card grid (name, edit, activate/deactivate, 'بخش جدید' modal) →
click a section → its services card grid with breadcrumb back. Service
card matches Figma: overflow menu, active badge, base price, average
time badge, staff chip. Add duration_minutes to ServiceItem (migration
+ create/update + form field). Update clinic-services.md.
(Version20260713050014 syncs pre-existing entity/schema drift.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tariff and TenantServiceCoverage reference a service item by a raw int (no FK),
so deleting an item orphaned its tariffs and tenant-coverage config. Delete them
in deleteItem() before removing the item. (The in-use FK guard for invoice/claim
usage is preserved.)
Remaining M20 refs (ClinicStaff/SmsWallet/DoctorAddress.clinicId/Claim.insurance_id
on rare owner deletions) are accepted as harmless unreferenced rows; SmsWallet is
intentionally retained as a financial record. Documented in docs/audit-backlog.md.
Regression: tests/ClinicService/ServiceItemDeleteCleanupTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- M2 GET /insurance/{id}: was unguarded; now owner-or-admin (403 otherwise) —
stops reading another doctor's negotiated price by id enumeration.
- M3 GET /clinic-pro/doctor-address/{id}: add the same owner/admin check the
sibling PATCH/DELETE already had.
- M4 POST/PATCH /service-item: staff_uuid must belong to the caller's tenant
(entity_type/entity_id) → 422; stops binding another tenant's staff.
- M5 appointment-settings list endpoints (date-override/holidays/
available-locations): add the per-doctor ownership check the sibling
single-record endpoints already enforce.
Regressions (6 negative cases fail without the fixes):
DoctorInsuranceOwnershipTest, DoctorAddressOwnershipTest,
ServiceItemStaffOwnershipTest, AppointmentSettingsListOwnershipTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Updated NewSessionPage to calculate patient share based on insurance coverage rules.
- Refactored billing calculations to utilize new patientShareOf function for service items.
- Enhanced API documentation to reflect changes in service coverage structure.
- Implemented ServiceInsuranceModal for managing insurance coverage per service.
- Added UI components for displaying and editing insurance coverage details.
- Removed obsolete toggle switch styles and adjusted CSS for new components.
- Ensured backend endpoints support both service_item_id and service_item_uuid for flexibility.
- Added StaffController for managing clinic staff, including listing, creating, updating, and toggling staff status.
- Created ClinicStaff entity and repository for staff data handling.
- Developed SubscriptionController to manage subscription plans and periods, including trial subscriptions.
- Introduced SubscriptionPlan, SubscriptionPeriod, and ClinicSubscription entities for subscription management.
- Implemented SubscriptionService for handling subscription logic, including trial activation and subscription creation from payments.
- Added necessary repositories for subscription entities to facilitate data access and manipulation.