diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 14dceb49..e046a1b7 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -77,17 +77,6 @@ import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; import ResourceBookingPage from './pages/ResourceBookingPage'; import PriceListsPage from './pages/PriceListsPage'; -import ResourceUtilizationPage from './pages/ResourceUtilizationPage'; -import PlanAccuracyPage from './pages/PlanAccuracyPage'; -import CancellationPolicyPage from './pages/CancellationPolicyPage'; -import WaitlistPage from './pages/WaitlistPage'; -import CourseProtocolsPage from './pages/CourseProtocolsPage'; -import TreatmentCoursePage from './pages/TreatmentCoursePage'; -import PackagesPage from './pages/PackagesPage'; -import PatientPackageLedgerPage from './pages/PatientPackageLedgerPage'; -import PoliciesPage from './pages/PoliciesPage'; -import PolicyFormPage from './pages/PolicyFormPage'; -import PolicySimulationPage from './pages/PolicySimulationPage'; import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage'; import BranchRoomsPage from './pages/BranchRoomsPage'; import ResourcesPage from './pages/ResourcesPage'; @@ -300,17 +289,6 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> } /> } /> } /> diff --git a/assets/admin/components/CancelAppointmentDialog.tsx b/assets/admin/components/CancelAppointmentDialog.tsx index 085a72f4..e7ce7390 100644 --- a/assets/admin/components/CancelAppointmentDialog.tsx +++ b/assets/admin/components/CancelAppointmentDialog.tsx @@ -1,24 +1,17 @@ import React, { useState } from 'react'; import ConfirmDialog from './ui/ConfirmDialog'; -import { useCancellationPreview, useCancelAppointment } from '../hooks/useCancellation'; -import { formatRial } from '../lib/utils'; +import { useCancelAppointment } from '../hooks/useCancellation'; interface Props { open: boolean; appointmentUuid: string; - /** چه کسی لغو می‌کند — جریمه برای لغو کلینیک همیشه صفر است. */ + /** چه کسی لغو می‌کند — وضعیت نهایی نوبت از همین می‌آید. */ by?: 'user' | 'doctor'; onClose: () => void; onCancelled?: () => void; } -/** - * لغو نوبت با نمایش پیامد مالی **قبل از** تأیید. - * - * پیش‌نمایش از همان محاسبه‌ای می‌آید که خودِ لغو انجام می‌دهد، پس عددی که اپراتور - * می‌بیند همان است که کسر می‌شود. دکمهٔ لغوِ بی‌پیش‌نمایش یعنی اپراتور جریمهٔ بیمار را - * بعد از وقوعش کشف می‌کند. - */ +/** لغو نوبت با تأیید و دلیل اختیاری. */ export default function CancelAppointmentDialog({ open, appointmentUuid, @@ -27,7 +20,6 @@ export default function CancelAppointmentDialog({ onCancelled, }: Props) { const [reason, setReason] = useState(''); - const { preview, loading } = useCancellationPreview(open ? appointmentUuid : undefined, by); const cancel = useCancelAppointment(); const close = () => { @@ -56,54 +48,6 @@ export default function CancelAppointmentDialog({ } onCancel={close} > -
- {loading ? ( - در حال محاسبهٔ پیامد لغو… - ) : !preview ? ( - - پیامد مالی لغو در دسترس نیست؛ لغو انجام می‌شود ولی مبلغ را دستی بررسی کنید. - - ) : ( - <> -
- جریمهٔ لغو - 0 ? 'var(--danger)' : 'var(--success)' }}> - {preview.penalty_rials > 0 ? formatRial(preview.penalty_rials) : 'بدون جریمه'} - -
- -
- مبلغ پرداخت‌شده - {formatRial(preview.paid_rials)} -
- -
- بازگشت اعتبار پکیج - {preview.credit_refundable ? 'بله' : 'خیر'} -
- - {preview.within_free_window && ( -
در بازهٔ لغو رایگان است.
- )} - - {preview.notes.map((note, i) => ( -
- {note} -
- ))} - - )} -
-
); } - - -/** - * پکیج‌های بیمار. - * - * مانده از دفتر می‌آید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان می‌دهد تا - * معلوم باشد عدد از کجا آمده. - */ -function PackagesTab({ uuid }: { uuid: string }) { - const { patientPackages, loading, sell } = usePatientPackages(uuid); - const { packages } = usePackages(); - const [selected, setSelected] = useState(''); - - const active = packages.filter((p) => p.active); - - return ( -
-
-
- - setSelected(String(v ?? ''))} - options={active.map((p) => ({ - value: p.uuid, - label: `${p.name} — ${p.session_count} جلسه`, - }))} - placeholder="انتخاب پکیج" - /> -
- -
- - {loading ? ( -
در حال بارگذاری…
- ) : patientPackages.length === 0 ? ( -
- این بیمار هنوز پکیجی نخریده است -
- ) : ( - patientPackages.map((p) => ( -
-
- {p.package_name} - - خرید {formatDate(p.purchased_at)} - {p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`} - -
- - - مانده: {p.balance} از {p.session_count} - - - {p.expired && منقضی} - {!p.expired && p.balance === 0 && ( - - اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه می‌شود. - - )} - - - دفتر اعتبار - -
- )) - )} -
- ); -} - -/** - * دوره‌های درمان بیمار. - * - * پیشرفت از سرور می‌آید («۳ از ۸»)؛ فرانت نمی‌شمارد، چون وضعیت جلسات آن‌جاست. - */ -function CoursesTab({ uuid }: { uuid: string }) { - const { courses, loading, start } = usePatientCourses(uuid); - const { protocols } = useCourseProtocols(); - const [selected, setSelected] = useState(''); - - const active = protocols.filter((p) => p.active); - - return ( -
-
-
- - setSelected(String(v ?? ''))} - options={active.map((p) => ({ - value: p.uuid, - label: `${p.service_name} — ${p.session_count} جلسه`, - }))} - placeholder="انتخاب پروتکل" - /> -
- -
- - {loading ? ( -
در حال بارگذاری…
- ) : courses.length === 0 ? ( -
- این بیمار دورهٔ درمانی ندارد -
- ) : ( - courses.map((c) => ( -
-
- {c.service_name} - شروع {formatDate(c.started_at)} -
- - - جلسهٔ {c.progress.completed} از {c.progress.total} - - - {c.status === 'active' ? ( - در جریان - ) : ( - {c.status === 'completed' ? 'تمام‌شده' : 'رهاشده'} - )} - - - جزئیات دوره - -
- )) - )} -
- ); -} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 0f58a52a..0aa316ba 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1106,35 +1106,6 @@ export interface PolicyClause { value: unknown; } -export interface PolicyCondition { - match?: 'all' | 'any'; - conditions?: PolicyClause[]; -} - -export interface PolicyEffect { - type: string; - value?: unknown; - reason?: string; -} - -export interface Policy { - uuid: string; - category: PolicyCategory; - name: string; - condition: PolicyCondition; - effects: PolicyEffect[]; - priority: number; - version: number; - active: boolean; - valid_from: number | null; - valid_to: number | null; - address_uuid: string | null; - service_uuid: string | null; - catalog_category_uuid: string | null; - specificity: number; - versions?: PolicyVersionLog[]; -} - export interface PolicyVersionLog { version: number; snapshot: Record; @@ -1175,13 +1146,6 @@ export interface PolicyTemplateInput { max?: number; } -export interface PolicyTemplate { - key: string; - title: string; - description: string; - category: PolicyCategory; - inputs: PolicyTemplateInput[]; -} export interface SimulationRow { appointment_uuid: string; @@ -1205,7 +1169,6 @@ export interface PolicySimulationRun { warning: string | null; } -// ── پکیج و اعتبار جلسات (تسک ۱۱) ───────────────────────────────────────────── export interface PackageDefinition { uuid: string; @@ -1219,70 +1182,9 @@ export interface PackageDefinition { created_at: number; } -export interface PatientPackage { - uuid: string; - package_uuid: string; - package_name: string; - patient_uuid: string; - session_count: number; - price_paid_rials: number; - purchased_at: number; - valid_to: number | null; - expired: boolean; - /** همیشه از جمع دفتر می‌آید — هیچ ستونی در دیتابیس نیست */ - balance: number; -} -export interface CreditLedgerRow { - uuid: string; - kind: 'purchase' | 'consume' | 'refund' | 'adjustment' | 'expiry'; - delta: number; - appointment_uuid: string | null; - service_uuid: string | null; - service_name: string | null; - reason: string | null; - created_by: string | null; - created_at: number; - /** در UI محاسبه‌شده نیست — سرور همان جمع تجمعی را می‌دهد */ - running_balance: number; -} -export interface CreditLedger { - package: PatientPackage; - rows: CreditLedgerRow[]; -} -// ── دورهٔ درمان (تسک ۱۲) ────────────────────────────────────────────────────── - -export interface CourseProtocolStep { - session_number: number; - params: Record; - override_duration_minutes: number | null; -} - -export interface CourseProtocol { - uuid: string; - service_uuid: string; - service_name: string; - session_count: number; - min_days: number; - ideal_days: number; - max_days: number; - prefer_same_resource: boolean; - active: boolean; - steps: CourseProtocolStep[]; - created_at: number; -} - -export interface CourseSessionRow { - uuid: string; - session_number: number; - params: Record; - appointment_uuid: string | null; - slot_start: number | null; - status: 'planned' | 'booked' | 'completed' | 'skipped'; - completed_at: number | null; -} export interface CourseProgress { completed: number; @@ -1295,29 +1197,6 @@ export interface CourseProgress { last_completed_at: number | null; } -export interface TreatmentCourse { - uuid: string; - patient_uuid: string; - service_uuid: string; - service_name: string; - protocol_uuid: string; - session_count: number; - min_days: number; - ideal_days: number; - max_days: number; - patient_package_uuid: string | null; - preferred_resource_uuid: string | null; - preferred_resource_name: string | null; - package_balance?: number | null; - package_shortfall?: number | null; - status: 'active' | 'completed' | 'abandoned'; - abandon_reason: string | null; - started_at: number; - completed_at: number | null; - progress: CourseProgress; - sessions?: CourseSessionRow[]; -} - export interface NextSlotSuggestion { session_number: number | null; params: Record; @@ -1327,57 +1206,7 @@ export interface NextSlotSuggestion { warning: string | null; } -// ── لغو و لیست انتظار (تسک ۱۳) ─────────────────────────────────────────────── -export interface CancellationPolicy { - uuid: string; - service_uuid: string | null; - service_name: string | null; - free_window_hours: number; - penalty_mode: 'none' | 'percent' | 'fixed'; - penalty_value: number; - deposit_refundable: boolean; - credit_refundable: boolean; - no_show_threshold: number; - risk_tag_uuid: string | null; - active: boolean; - created_at: number; -} - -export interface CancellationPreview { - penalty_rials: number; - deposit_refundable: boolean; - credit_refundable: boolean; - within_free_window: boolean; - notes: string[]; - paid_rials: number; -} - -export interface CancellationResult extends Omit { - appointment_uuid: string; - status: string; - released_resources: number; - waitlist_notified: number; - penalty_charged: boolean; -} - -export interface WaitlistEntry { - uuid: string; - patient_uuid: string; - service_uuid: string; - service_name: string; - branch_id: number | null; - desired_from: number; - desired_to: number; - preferred_day_parts: string[]; - priority: number; - status: 'waiting' | 'notified' | 'converted' | 'expired'; - notified_at: number | null; - notify_count: number; - created_at: number; -} - -// ── گزارش‌ها (تسک ۱۴) ──────────────────────────────────────────────────────── export interface UtilizationRow { resource_uuid: string; diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index b72649d9..70427707 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -23,11 +23,6 @@ framework: 'App\Sms\Message\SendSmsMessage': async 'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default 'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default - 'App\Shared\Event\Message\PublishDomainEventsMessage': scheduler_default - 'App\Waitlist\Message\ExpireWaitlistMessage': scheduler_default - # مصرف‌کنندهٔ رویداد async است، وگرنه یک consumer کند خودِ تخلیهٔ صندوق را - # کند می‌کند و شکستش ردیفی را «ناموفق» علامت می‌زند که در واقع تحویل شده بود. - 'App\Shared\Event\Message\DomainEventMessage': async when@test: framework: diff --git a/migrations/Version20260801170043.php b/migrations/Version20260801170043.php new file mode 100644 index 00000000..424a2fc4 --- /dev/null +++ b/migrations/Version20260801170043.php @@ -0,0 +1,112 @@ +addSql('SET FOREIGN_KEY_CHECKS = 0'); + + foreach ([ + 'policy_simulation_runs', + 'policy_version_logs', + 'policies', + 'session_credit_ledger', + 'patient_packages', + 'package_services', + 'packages', + 'course_sessions', + 'course_protocol_steps', + 'course_protocols', + 'treatment_courses', + 'no_show_records', + 'cancellation_policies', + 'waitlist_entries', + 'domain_events', + ] as $table) { + $this->addSql(sprintf('DROP TABLE IF EXISTS %s', $table)); + } + + $this->addSql('SET FOREIGN_KEY_CHECKS = 1'); + + // ستون نوبت که به جلسهٔ دوره اشاره می‌کرد. + if ($schema->getTable('appointments')->hasColumn('course_session_id')) { + $this->addSql('ALTER TABLE appointments DROP FOREIGN KEY FK_6A41727ABEDDA25C'); + $this->addSql('DROP INDEX IDX_6A41727ABEDDA25C ON appointments'); + $this->addSql('ALTER TABLE appointments DROP course_session_id'); + } + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE cancellation_policies (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, free_window_hours SMALLINT DEFAULT 24 NOT NULL, penalty_mode VARCHAR(10) CHARACTER SET utf8mb4 DEFAULT \'none\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, penalty_value INT DEFAULT 0 NOT NULL, deposit_refundable TINYINT DEFAULT 0 NOT NULL, credit_refundable TINYINT DEFAULT 1 NOT NULL, no_show_threshold SMALLINT DEFAULT 3 NOT NULL, risk_tag_uuid VARCHAR(36) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, service_item_id INT DEFAULT NULL, INDEX idx_cancel_policies_tenant (entity_type, entity_id, active), UNIQUE INDEX UNIQ_52BE2A1D17F50A6 (uuid), INDEX IDX_52BE2A1DDEB00C2 (service_item_id), UNIQUE INDEX uniq_cancel_policy_scope (entity_type, entity_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE course_protocols (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_count SMALLINT NOT NULL, min_days SMALLINT NOT NULL, ideal_days SMALLINT NOT NULL, max_days SMALLINT NOT NULL, prefer_same_resource TINYINT DEFAULT 1 NOT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, service_item_id INT NOT NULL, INDEX idx_protocols_tenant (entity_type, entity_id, active), UNIQUE INDEX UNIQ_E60D42A0D17F50A6 (uuid), UNIQUE INDEX uniq_protocol_service (service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE course_protocol_steps (id INT AUTO_INCREMENT NOT NULL, session_number SMALLINT NOT NULL, params JSON DEFAULT NULL, override_duration_minutes SMALLINT DEFAULT NULL, protocol_id INT NOT NULL, UNIQUE INDEX uniq_step (protocol_id, session_number), INDEX IDX_58A0726CCD59258 (protocol_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE course_sessions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_number SMALLINT NOT NULL, params JSON DEFAULT NULL, status VARCHAR(12) CHARACTER SET utf8mb4 DEFAULT \'planned\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, completed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, course_id INT NOT NULL, appointment_id INT DEFAULT NULL, INDEX idx_sessions_tenant (entity_type, entity_id, status), UNIQUE INDEX uniq_session_appointment (appointment_id), UNIQUE INDEX UNIQ_33D4F045D17F50A6 (uuid), INDEX IDX_33D4F045591CC992 (course_id), UNIQUE INDEX uniq_course_session (course_id, session_number), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE domain_events (id BIGINT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(60) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, payload JSON NOT NULL, occurred_at INT NOT NULL, published_at INT DEFAULT NULL, attempts SMALLINT DEFAULT 0 NOT NULL, last_error VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, INDEX idx_de_name (name, occurred_at), INDEX idx_de_tenant (entity_type, entity_id, occurred_at), UNIQUE INDEX UNIQ_3CE45B83D17F50A6 (uuid), INDEX idx_de_pending (published_at, occurred_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE no_show_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, recorded_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, patient_record_id INT NOT NULL, appointment_id INT NOT NULL, recorded_by INT DEFAULT NULL, INDEX idx_no_show_tenant (entity_type, entity_id, recorded_at), INDEX IDX_C37342082D4278B (recorded_by), UNIQUE INDEX UNIQ_C373420D17F50A6 (uuid), INDEX idx_no_show_patient (patient_record_id, recorded_at), UNIQUE INDEX uniq_no_show_appointment (appointment_id), INDEX IDX_C373420EB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(200) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_count SMALLINT NOT NULL, price_rials BIGINT NOT NULL, validity_days SMALLINT DEFAULT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, UNIQUE INDEX UNIQ_9BB5C0A7D17F50A6 (uuid), INDEX idx_packages_tenant (entity_type, entity_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE package_services (id INT AUTO_INCREMENT NOT NULL, package_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_97B37507F44CABFF (package_id), INDEX IDX_97B37507DDEB00C2 (service_item_id), UNIQUE INDEX uniq_pkg_service (package_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE patient_packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_count SMALLINT NOT NULL, price_paid_rials BIGINT NOT NULL, purchased_at INT NOT NULL, valid_to INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, package_id INT NOT NULL, patient_record_id INT NOT NULL, payment_id INT DEFAULT NULL, INDEX IDX_AC3098024C3A3BB (payment_id), UNIQUE INDEX UNIQ_AC309802D17F50A6 (uuid), INDEX idx_pp_patient (patient_record_id, valid_to), INDEX idx_pp_tenant (entity_type, entity_id, purchased_at), INDEX IDX_AC309802F44CABFF (package_id), INDEX IDX_AC309802EB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE policies (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, category VARCHAR(20) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(200) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, condition_json JSON NOT NULL, effects JSON NOT NULL, priority SMALLINT DEFAULT 0 NOT NULL, valid_from INT DEFAULT NULL, valid_to INT DEFAULT NULL, version SMALLINT DEFAULT 1 NOT NULL, active TINYINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, address_id INT DEFAULT NULL, service_item_id INT DEFAULT NULL, catalog_category_id INT DEFAULT NULL, specificity SMALLINT DEFAULT 0 NOT NULL, INDEX IDX_E08BBFCE3F2BC4C (catalog_category_id), UNIQUE INDEX UNIQ_E08BBFCED17F50A6 (uuid), INDEX idx_policy_tenant_category (entity_type, entity_id, category, active), INDEX IDX_E08BBFCEF5B7AF75 (address_id), INDEX idx_policy_validity (valid_from, valid_to), INDEX IDX_E08BBFCEDDEB00C2 (service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE policy_simulation_runs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, policy_version SMALLINT NOT NULL, sample_size SMALLINT NOT NULL, affected_count SMALLINT NOT NULL, severity VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, report JSON NOT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, policy_id INT NOT NULL, run_by INT DEFAULT NULL, INDEX IDX_F993F7C82D29E3C6 (policy_id), INDEX IDX_F993F7C84114BD6 (run_by), INDEX idx_psr_tenant (entity_type, entity_id, created_at), INDEX idx_psr_policy (policy_id, policy_version, created_at), UNIQUE INDEX UNIQ_F993F7C8D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE policy_version_logs (id INT AUTO_INCREMENT NOT NULL, version SMALLINT NOT NULL, snapshot JSON NOT NULL, created_at INT NOT NULL, policy_id INT NOT NULL, INDEX IDX_B27F258B2D29E3C6 (policy_id), UNIQUE INDEX uniq_policy_version (policy_id, version), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE session_credit_ledger (id BIGINT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, kind VARCHAR(15) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, delta SMALLINT NOT NULL, reason VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, created_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, patient_package_id INT NOT NULL, appointment_id INT DEFAULT NULL, service_item_id INT DEFAULT NULL, created_by INT DEFAULT NULL, INDEX idx_scl_package (patient_package_id, created_at), INDEX IDX_6B3C6BD6428C0D10 (patient_package_id), UNIQUE INDEX UNIQ_6B3C6BD6D17F50A6 (uuid), INDEX IDX_6B3C6BD6DDEB00C2 (service_item_id), UNIQUE INDEX uniq_scl_consume (appointment_id, kind), INDEX idx_scl_appt (appointment_id), INDEX idx_scl_tenant (entity_type, entity_id, created_at), INDEX IDX_6B3C6BD6DE12AB56 (created_by), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE treatment_courses (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, session_count SMALLINT NOT NULL, min_days SMALLINT NOT NULL, ideal_days SMALLINT NOT NULL, max_days SMALLINT NOT NULL, status VARCHAR(12) CHARACTER SET utf8mb4 DEFAULT \'active\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, active_course_key VARCHAR(64) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, abandon_reason VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, started_at INT NOT NULL, completed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, patient_record_id INT NOT NULL, service_item_id INT NOT NULL, protocol_id INT NOT NULL, patient_package_id INT DEFAULT NULL, preferred_resource_id INT DEFAULT NULL, INDEX IDX_D1639880CB4D0F54 (preferred_resource_id), INDEX IDX_D1639880DDEB00C2 (service_item_id), UNIQUE INDEX UNIQ_D1639880D17F50A6 (uuid), INDEX idx_courses_tenant (entity_type, entity_id, status, started_at), INDEX IDX_D1639880CCD59258 (protocol_id), UNIQUE INDEX UNIQ_D1639880DEBE4F6D (active_course_key), INDEX idx_courses_patient (patient_record_id, status), INDEX IDX_D1639880428C0D10 (patient_package_id), INDEX IDX_D1639880EB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('CREATE TABLE waitlist_entries (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, branch_id INT DEFAULT NULL, desired_from INT NOT NULL, desired_to INT NOT NULL, preferred_day_parts JSON DEFAULT NULL, priority SMALLINT DEFAULT 0 NOT NULL, status VARCHAR(12) CHARACTER SET utf8mb4 DEFAULT \'waiting\' NOT NULL COLLATE `utf8mb4_unicode_520_ci`, notified_at INT DEFAULT NULL, notify_count SMALLINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, patient_record_id INT NOT NULL, service_item_id INT NOT NULL, converted_appointment_id INT DEFAULT NULL, INDEX IDX_E74550EEDDEB00C2 (service_item_id), INDEX idx_waitlist_tenant (entity_type, entity_id, status, created_at), INDEX IDX_E74550EE4B79C8F8 (converted_appointment_id), UNIQUE INDEX UNIQ_E74550EED17F50A6 (uuid), INDEX idx_waitlist_patient (patient_record_id, status), INDEX idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to), INDEX IDX_E74550EEEB76A733 (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('ALTER TABLE cancellation_policies ADD CONSTRAINT `FK_52BE2A1DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE course_protocols ADD CONSTRAINT `FK_E60D42A0DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE course_protocol_steps ADD CONSTRAINT `FK_58A0726CCD59258` FOREIGN KEY (protocol_id) REFERENCES course_protocols (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE course_sessions ADD CONSTRAINT `FK_33D4F045591CC992` FOREIGN KEY (course_id) REFERENCES treatment_courses (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE course_sessions ADD CONSTRAINT `FK_33D4F045E5B533F9` FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT `FK_C37342082D4278B` FOREIGN KEY (recorded_by) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT `FK_C373420E5B533F9` FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT `FK_C373420EB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE package_services ADD CONSTRAINT `FK_97B37507DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id)'); + $this->addSql('ALTER TABLE package_services ADD CONSTRAINT `FK_97B37507F44CABFF` FOREIGN KEY (package_id) REFERENCES packages (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT `FK_AC3098024C3A3BB` FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT `FK_AC309802EB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id)'); + $this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT `FK_AC309802F44CABFF` FOREIGN KEY (package_id) REFERENCES packages (id)'); + $this->addSql('ALTER TABLE policies ADD CONSTRAINT `FK_E08BBFCE3F2BC4C` FOREIGN KEY (catalog_category_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE policies ADD CONSTRAINT `FK_E08BBFCEDDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE policies ADD CONSTRAINT `FK_E08BBFCEF5B7AF75` FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE policy_simulation_runs ADD CONSTRAINT `FK_F993F7C82D29E3C6` FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE policy_simulation_runs ADD CONSTRAINT `FK_F993F7C84114BD6` FOREIGN KEY (run_by) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE policy_version_logs ADD CONSTRAINT `FK_B27F258B2D29E3C6` FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6428C0D10` FOREIGN KEY (patient_package_id) REFERENCES patient_packages (id)'); + $this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6DE12AB56` FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT `FK_6B3C6BD6E5B533F9` FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880428C0D10` FOREIGN KEY (patient_package_id) REFERENCES patient_packages (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880CB4D0F54` FOREIGN KEY (preferred_resource_id) REFERENCES clinic_resources (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880CCD59258` FOREIGN KEY (protocol_id) REFERENCES course_protocols (id)'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880DDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id)'); + $this->addSql('ALTER TABLE treatment_courses ADD CONSTRAINT `FK_D1639880EB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id)'); + $this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT `FK_E74550EE4B79C8F8` FOREIGN KEY (converted_appointment_id) REFERENCES appointments (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT `FK_E74550EEDDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT `FK_E74550EEEB76A733` FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE appointments ADD course_session_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE appointments ADD CONSTRAINT `FK_6A41727ABEDDA25C` FOREIGN KEY (course_session_id) REFERENCES course_sessions (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_6A41727ABEDDA25C ON appointments (course_session_id)'); + } +} diff --git a/src/Appointment/Availability/Controller/AvailabilityController.php b/src/Appointment/Availability/Controller/AvailabilityController.php index 49796365..f0c7a076 100644 --- a/src/Appointment/Availability/Controller/AvailabilityController.php +++ b/src/Appointment/Availability/Controller/AvailabilityController.php @@ -40,7 +40,6 @@ class AvailabilityController extends BaseController private readonly WeeklyScheduleRepository $schedules, private readonly DoctorRepository $doctors, private readonly BranchResolver $branches, - private readonly \App\Course\Repository\TreatmentCourseRepository $courses, ) {} #[Route('/api/v1/appointment-availability', name: 'appointment_availability', methods: ['POST'])] @@ -111,7 +110,6 @@ class AvailabilityController extends BaseController $step, null, $this->strategyFor($data['doctor_uuid'] ?? null), - $this->preferredResourceIds($user, $data['course_uuid'] ?? null), ); return $this->success([ @@ -194,37 +192,6 @@ class AvailabilityController extends BaseController return null; } - /** - * منبع ترجیحیِ یک دورهٔ درمان — «همان اپراتور جلسهٔ قبل». - * - * ترجیح است نه فیلتر: اگر آزاد نباشد، استراتژی به ترتیب پایه برمی‌گردد و رزرو - * انجام می‌شود. اجبار یعنی بیمار دو هفته منتظر بماند. - * - * @return list - */ - private function preferredResourceIds(User $user, mixed $courseUuid): array - { - if (!is_string($courseUuid) || $courseUuid === '') { - return []; - } - - $course = $this->courses->findByUuid($courseUuid); - - if ($course === null) { - return []; - } - - [$entityType, $entityId] = $this->branches->pair($user); - - if ($course->getEntityType() !== $entityType || $course->getEntityId() !== $entityId) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404); - } - - $preferred = $course->getPreferredResource(); - - return $preferred === null ? [] : [(int) $preferred->getId()]; - } - private function assertResourceMode(mixed $doctorUuid, DoctorAddress $address): void { if (!is_string($doctorUuid) || $doctorUuid === '') { diff --git a/src/Appointment/Availability/Controller/ResourceBlockController.php b/src/Appointment/Availability/Controller/ResourceBlockController.php index 54c6bf74..f96ae4ff 100644 --- a/src/Appointment/Availability/Controller/ResourceBlockController.php +++ b/src/Appointment/Availability/Controller/ResourceBlockController.php @@ -10,8 +10,6 @@ use App\Resource\Entity\ClinicResource; use App\Resource\Repository\ClinicResourceRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; -use App\Shared\Event\DomainEventPublisher; -use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; use App\Shared\Tenant\TenantOwnershipChecker; use Doctrine\ORM\EntityManagerInterface; @@ -45,7 +43,6 @@ class ResourceBlockController extends BaseController private readonly ResourceOccupancyRepository $occupancy, private readonly BranchResolver $branches, private readonly TenantOwnershipChecker $ownership, - private readonly DomainEventPublisher $domainEvents, private readonly EntityManagerInterface $em, ) {} @@ -99,17 +96,6 @@ class ResourceBlockController extends BaseController $this->em->persist($block); - $this->domainEvents->record( - $resource->getEntityType(), - $resource->getEntityId(), - DomainEvents::RESOURCE_BLOCKED, - [ - 'resource_uuid' => $resource->getUuid(), - 'block_uuid' => $block->getUuid(), - 'starts_at' => $startsAt, - 'ends_at' => $endsAt, - ], - ); $this->em->flush(); @@ -136,18 +122,6 @@ class ResourceBlockController extends BaseController ); } - // پیش از `remove` ثبت می‌شود چون بعد از آن، uuid و بازه فقط در حافظه‌اند و - // خواندنشان از یک entity حذف‌شده به رفتار Doctrine وابسته می‌ماند. - $this->domainEvents->record( - $entityType, - $entityId, - DomainEvents::RESOURCE_RELEASED, - [ - 'block_uuid' => $block->getUuid(), - 'starts_at' => $block->getStartsAt(), - 'ends_at' => $block->getEndsAt(), - ], - ); $this->em->remove($block); $this->em->flush(); diff --git a/src/Appointment/Booking/Controller/BookingController.php b/src/Appointment/Booking/Controller/BookingController.php index 71f80866..2ffb07e6 100644 --- a/src/Appointment/Booking/Controller/BookingController.php +++ b/src/Appointment/Booking/Controller/BookingController.php @@ -13,8 +13,6 @@ use App\Auth\Repository\UserRepository; use App\Doctor\Repository\DoctorRepository; use App\Pricing\Entity\PriceSnapshot; use App\Pricing\Service\PriceSnapshotService; -use App\Package\Service\PackageConsumptionService; -use App\Policy\Service\BookingPolicyGuard; use App\Pricing\Service\PricingEngine; use App\Appointment\Plan\Service\AppointmentPlanBuilder; use App\Auth\Entity\User; @@ -25,8 +23,6 @@ use App\Resource\Entity\ClinicResource; use App\Resource\Repository\ClinicResourceRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; -use App\Shared\Event\DomainEventPublisher; -use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; use App\Shared\Tenant\TenantOwnershipChecker; use Doctrine\ORM\EntityManagerInterface; @@ -56,38 +52,11 @@ class BookingController extends BaseController private readonly PricingEngine $pricing, private readonly PriceSnapshotService $snapshots, private readonly BranchResolver $branches, - private readonly BookingPolicyGuard $guard, - private readonly PackageConsumptionService $packages, private readonly TenantOwnershipChecker $ownership, private readonly AppointmentSegmentRepository $segments, - private readonly DomainEventPublisher $domainEvents, private readonly EntityManagerInterface $em, ) {} - /** - * پرچم‌هایی که فقط در همین درخواست وجود دارند و جایی ذخیره نمی‌شوند - * (مثل رضایت والدین که اپراتور همان لحظه می‌گیرد). - * - * @param array $data - * @return array - */ - private function requestFlags(array $data): array - { - $flags = []; - - foreach (['has_parental_consent'] as $flag) { - if (isset($data[$flag])) { - $flags[$flag] = (bool) $data[$flag]; - } - } - - if (is_string($data['patient_gender'] ?? null)) { - $flags['patient_gender'] = $data['patient_gender']; - } - - return $flags; - } - #[Route('/api/v1/appointment-hold', name: 'appointment_hold_create', methods: ['POST'])] public function create(#[CurrentUser] User $user, Request $request): JsonResponse { @@ -128,9 +97,6 @@ class BookingController extends BaseController is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null, ); - // قوانین وابسته به بیمار پیش از گرفتن صندلی اجرا می‌شوند، نه هنگام ثبت نهایی. - $this->guard->assertEligible($user, $service, $selected, $address, $this->requestFlags($data)); - $this->guard->assertSpacing($user, $service, $address, (int) $data['start']); $assignment = $this->resolveAssignment($user, $data['assignment']); $this->assertAssignmentCoversPlan($plan, $assignment); @@ -255,19 +221,6 @@ class BookingController extends BaseController $this->booking->confirm($hold, $appointment); $released = $this->booking->cancel($appointment); - // `confirm` و `cancel` هرکدام رویداد خودشان را ثبت کرده‌اند؛ این سومی می‌گوید آن دو - // یک جابه‌جایی بوده‌اند نه یک لغو و یک رزروِ بی‌ربط. مصرف‌کننده‌ای که فقط - // `AppointmentCancelled` را بشنود، برای بیماری که هنوز نوبت دارد پیام لغو می‌فرستد. - $this->domainEvents->recordAndFlush( - $appointment->getEntityType(), - $appointment->getEntityId(), - DomainEvents::APPOINTMENT_RESCHEDULED, - [ - 'appointment_uuid' => $appointment->getUuid(), - 'previous_start' => $previousStart, - 'new_start' => $hold->getStartsAt(), - ], - ); return $this->success([ 'appointment_uuid' => $appointment->getUuid(), @@ -305,8 +258,6 @@ class BookingController extends BaseController $address, $hold->getStartsAt(), is_array($data['policy'] ?? null) ? $data['policy'] : [], - // پروندهٔ بیمار در همین محیط — پکیج کلینیک الف در کلینیک ب معنا ندارد. - $this->packages->patientRecordFor($appointment), ); return $this->snapshots->record($appointment, $quote); diff --git a/src/Appointment/Booking/Service/BookingService.php b/src/Appointment/Booking/Service/BookingService.php index 0843de74..1f84d3e0 100644 --- a/src/Appointment/Booking/Service/BookingService.php +++ b/src/Appointment/Booking/Service/BookingService.php @@ -6,12 +6,7 @@ use App\Appointment\Availability\Entity\ResourceOccupancy; use App\Appointment\Booking\Entity\AppointmentHold; use App\Appointment\Booking\Entity\AppointmentSegment; use App\Appointment\Entity\Appointment; -use App\Package\Service\CreditLedgerService; -use App\Course\Service\CourseSessionLinker; -use App\Package\Service\PackageConsumptionService; use App\Shared\Constant\ErrorCodes; -use App\Shared\Event\DomainEventPublisher; -use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; use Doctrine\ORM\EntityManagerInterface; @@ -26,10 +21,6 @@ final class BookingService { public function __construct( private readonly HoldService $holds, - private readonly PackageConsumptionService $packages, - private readonly CreditLedgerService $credits, - private readonly CourseSessionLinker $courseSessions, - private readonly DomainEventPublisher $events, private readonly EntityManagerInterface $em, ) {} @@ -61,21 +52,9 @@ final class BookingService $this->writeSegments($hold, $appointment); $hold->markConfirmed($now); - // رویداد در همان flushِ ثبت نوبت می‌رود؛ اگر این تراکنش برگردد، رویدادی هم - // نمی‌ماند که کسی به آن واکنش نشان دهد. - $this->events->record( - $appointment->getEntityType(), - $appointment->getEntityId(), - DomainEvents::APPOINTMENT_BOOKED, - ['appointment_uuid' => $appointment->getUuid(), 'hold_uuid' => $hold->getUuid()], - $now, - ); $this->em->flush(); - // مصرف اعتبار **اینجا**ست نه در پیش‌نمایش قیمت: تنها لحظه‌ای که نوبت واقعاً - // وجود دارد. کلید یکتای دفتر هم تضمین می‌کند اجرای دوباره جلسهٔ دوم نخورد. - $this->packages->consumeFor($appointment); return $appointment; } @@ -119,18 +98,9 @@ final class BookingService $this->holds->release($occupancies); - // ردیف `consume` **حذف نمی‌شود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند. - $this->credits->refund($appointment); - // جلسهٔ دوره به `planned` برمی‌گردد؛ بقیهٔ جلسات دست‌نخورده می‌مانند. - $this->courseSessions->unlink($appointment); - $this->events->recordAndFlush( - $appointment->getEntityType(), - $appointment->getEntityId(), - DomainEvents::APPOINTMENT_CANCELLED, - ['appointment_uuid' => $appointment->getUuid(), 'released_resources' => count($occupancies)], - ); + $this->em->flush(); return count($occupancies); } diff --git a/src/Appointment/Booking/Service/HoldService.php b/src/Appointment/Booking/Service/HoldService.php index 10b715ef..ba4fd193 100644 --- a/src/Appointment/Booking/Service/HoldService.php +++ b/src/Appointment/Booking/Service/HoldService.php @@ -9,8 +9,6 @@ use App\Appointment\Plan\ValueObject\AppointmentPlan; use App\Auth\Entity\User; use App\Resource\Entity\ClinicResource; use App\Shared\Constant\ErrorCodes; -use App\Shared\Event\DomainEventPublisher; -use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; @@ -36,7 +34,6 @@ use Doctrine\ORM\EntityManagerInterface; final class HoldService { public function __construct( - private readonly DomainEventPublisher $events, private readonly EntityManagerInterface $em, ) {} @@ -98,15 +95,6 @@ final class HoldService throw $e; } - // بعد از اینکه **همهٔ** منابع گرفته شدند، نه پیش از آن: رزروی که وسط کار - // شکسته، رویدادی هم ندارد. - $this->events->recordAndFlush( - $entityType, - $entityId, - DomainEvents::HOLD_CREATED, - ['hold_uuid' => $hold->getUuid(), 'starts_at' => $startsAt, 'resources' => count($taken)], - $now, - ); return $hold; } diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 79c8b05d..0855b6da 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -45,7 +45,6 @@ class AppointmentController extends BaseController private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance, private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator, private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService, - private readonly \App\Shared\Event\DomainEventPublisher $domainEvents, private readonly \Psr\Log\LoggerInterface $logger, ) {} @@ -73,27 +72,6 @@ class AppointmentController extends BaseController )); } - /** - * ثبت رویداد دامنهٔ «نوبت انجام شد». - * - * جدا از `AppointmentEvent` است و جایگزینش نمی‌شود: آن، تایم‌لاینِ خوانده‌شده توسط - * اپراتور است و این، صندوق خروجی برای مصرف‌کننده‌های بیرونی. هر دو مسیرِ تغییر - * وضعیت (اندپوینت اختصاصی و `PATCH`) بعد از ذخیرهٔ موفق به اینجا می‌رسند، چون - * رویدادِ کاری که هنوز ذخیره نشده، دروغ است. - */ - private function recordCompletion(Appointment $appointment): void - { - $this->domainEvents->recordAndFlush( - $appointment->getEntityType(), - $appointment->getEntityId(), - \App\Shared\Event\DomainEvents::APPOINTMENT_COMPLETED, - [ - 'appointment_uuid' => $appointment->getUuid(), - 'slot_start' => $appointment->getSlotStart(), - ], - ); - } - // ── Public: available slots ─────────────────────────────────────────────── #[OA\Get( @@ -978,7 +956,6 @@ class AppointmentController extends BaseController } if ($newStatus === Appointment::STATUS_COMPLETED) { - $this->recordCompletion($appointment); } return $this->success(['data' => $appointment->toArray()]); @@ -1254,7 +1231,6 @@ class AppointmentController extends BaseController } if ($completed) { - $this->recordCompletion($appointment); } return $this->success(['data' => $appointment->toArray()]); diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 61c6d6a7..9b7dfd7d 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -225,13 +225,6 @@ class Appointment #[ORM\Column(name: 'service_buffer_minutes', type: 'smallint', nullable: true)] private ?int $serviceBufferMinutes = null; - /** - * پیوند به جلسهٔ دوره — عمداً دوطرفه است تا لیست نوبت‌ها بدون JOIN بفهمد این نوبت - * جزو یک دوره است. فقط `CourseSessionLinker` می‌نویسدش. - */ - #[ORM\ManyToOne(targetEntity: \App\Course\Entity\CourseSession::class)] - #[ORM\JoinColumn(name: 'course_session_id', nullable: true, onDelete: 'SET NULL')] - private ?\App\Course\Entity\CourseSession $courseSession = null; #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -295,8 +288,6 @@ class Appointment public function setClinic(?\App\Clinic\Entity\Clinic $v): self { $this->clinic = $v; return $this; } public function setPatientName(?string $v): self { $this->patientName = $v; return $this; } - public function getCourseSession(): ?\App\Course\Entity\CourseSession { return $this->courseSession; } - public function setCourseSession(?\App\Course\Entity\CourseSession $v): self { $this->courseSession = $v; return $this; } public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; } public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; } public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; } diff --git a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php index cd591d1b..49ab80f8 100644 --- a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php +++ b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php @@ -16,10 +16,6 @@ use App\Resource\Entity\ResourceType; use App\Resource\Repository\ClinicResourceRepository; use App\Resource\Repository\ResourceTypeRepository; use App\Shared\Constant\ErrorCodes; -use App\Policy\Entity\Policy; -use App\Policy\Service\PolicySchema; -use App\Policy\Engine\ResourcePolicyEngine; -use App\Policy\Engine\TimingPolicyEngine; use App\Shared\Exception\AppException; /** @@ -31,8 +27,6 @@ use App\Shared\Exception\AppException; final class AppointmentPlanBuilder { public function __construct( - private readonly TimingPolicyEngine $timingPolicies, - private readonly ResourcePolicyEngine $resourcePolicies, private readonly SegmentTemplateRepository $templates, private readonly ClinicResourceRepository $resources, private readonly ResourceTypeRepository $types, @@ -111,14 +105,6 @@ final class AppointmentPlanBuilder array $segments, int $total, ): AppointmentPlan { - // ── قوانین دستهٔ «زمان» ──────────────────────────────────────────── - // اثرها روی **مجموع** نوبت اعمال می‌شوند نه روی یک بخش: «حداقل ۶۰ دقیقه» - // یعنی کل جلسه، و کوتاه کردنِ یک بخش برای رسیدن به آن معنا ندارد. - $total = $this->applyTimingPolicies($service, $selectedItems, $address, $segments, $total); - - // ── قوانین دستهٔ «منبع» ───────────────────────────────────────────── - $segments = $this->applyResourcePolicies($service, $selectedItems, $address, $segments); - if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, @@ -131,61 +117,6 @@ final class AppointmentPlanBuilder return new AppointmentPlan($segments, $total); } - /** - * قوانین «زمان»: حداقل مدت (بیشترین برنده) و افزودن مدت (جمع). - * - * @param ServiceItem[] $selectedItems - * @param list $segments به‌صورت ارجاع تغییر می‌کند - */ - private function applyTimingPolicies( - ServiceItem $service, - array $selectedItems, - DoctorAddress $address, - array &$segments, - int $total, - ): int { - $outcome = $this->timingPolicies->evaluate( - $address->tenantEntityType(), - $address->tenantEntityId(), - [ - 'service_uuid' => $service->getUuid(), - 'item_count' => count($selectedItems), - 'catalog_category' => $service->getCatalogCategory()?->getUuid(), - ], - $address, - $service, - ); - - if ($outcome->effects === []) { - return $total; - } - - $extra = (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0); - $minimum = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0); - $target = max($total + $extra, $minimum); - - if ($target === $total || $segments === []) { - return $total; - } - - // مدت اضافه به **آخرین** بخش می‌رود: آفست بخش‌های قبلی نباید عوض شود، وگرنه - // برنامه‌ای که کاربر تأیید کرده زیر پایش جابه‌جا می‌شود. - $last = $segments[count($segments) - 1]; - $grown = $last->durationMinutes + ($target - $total); - - $segments[count($segments) - 1] = new PlannedSegment( - sequence: $last->sequence, - name: $last->name, - offsetMinutes: $last->offsetMinutes, - durationMinutes: $grown, - patientPresent: $last->patientPresent, - mergeable: $last->mergeable, - requirements: $last->requirements, - ); - - return $target; - } - /** * الگوهای سرویس اصلی **به‌علاوهٔ** الگوهای آیتم‌های انتخاب‌شده. * @@ -291,158 +222,10 @@ final class AppointmentPlanBuilder return $counts; } - /** - * قوانین «منبع»: نقشی که قانون لازم می‌داند، اگر الگو نداشته باشد، اضافه می‌شود. - * - * نقشِ اضافه‌شده به **اولین بخشی که بیمار حاضر است** می‌چسبد، نه به همهٔ بخش‌ها: - * «سرپرست لازم است» یعنی سرپرست در جلسه حضور داشته باشد، نه اینکه تمام مدتِ - * آماده‌سازی هم اشغال شود. - * - * ممنوعیت هم اینجا خوانده می‌شود: قانونی که می‌گوید این ترکیب در این شعبه انجام - * نمی‌شود، پیش از رسیدن به موتور دسترس‌پذیری جلوی کار را می‌گیرد. - * - * @param ServiceItem[] $selectedItems - * @param list $segments - * @return list - */ - private function applyResourcePolicies( - ServiceItem $service, - array $selectedItems, - DoctorAddress $address, - array $segments, - ): array { - if ($segments === []) { - return $segments; - } - - $outcome = $this->resourcePolicies->evaluate( - $address->tenantEntityType(), - $address->tenantEntityId(), - [ - 'service_uuid' => $service->getUuid(), - 'catalog_category' => $service->getCatalogCategory()?->getUuid(), - 'item_count' => count($selectedItems), - ], - $address, - $service, - ); - - if ($outcome->isForbidden()) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - implode(' ', $outcome->forbidReasons), - 422, - 'service_uuid', - ); - } - - $required = (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []); - - if ($required === []) { - return $segments; - } - - $present = []; - foreach ($segments as $segment) { - foreach ($segment->requirements as $requirement) { - $present[$requirement->role] = true; - } - } - - $targetIndex = $this->firstPatientPresentIndex($segments); - $extra = []; - - foreach ($required as $code) { - if (!is_string($code) || isset($present[$code])) { - continue; - } - - $extra[] = $this->requirementForRole($code, $address, $outcome->appliedPolicies); - } - - if ($extra === []) { - return $segments; - } - - $target = $segments[$targetIndex]; - - $segments[$targetIndex] = new PlannedSegment( - sequence: $target->sequence, - name: $target->name, - offsetMinutes: $target->offsetMinutes, - durationMinutes: $target->durationMinutes, - patientPresent: $target->patientPresent, - mergeable: $target->mergeable, - requirements: [...$target->requirements, ...$extra], - ); - - return array_values($segments); - } - - /** @param list $segments */ - private function firstPatientPresentIndex(array $segments): int - { - foreach ($segments as $index => $segment) { - if ($segment->patientPresent) { - return $index; - } - } - - return 0; - } - /** * قانونی که نقشِ ناشناخته یا بی‌منبع می‌خواهد **خطاست، نه بی‌اثر**: در سکوت رد * کردنش یعنی کلینیک فکر کند قانونش اجرا می‌شود در حالی که هیچ‌وقت نشده. */ - /** - * @param list> $appliedPolicies برای اینکه پیام بگوید **کدام** قانون - */ - private function requirementForRole(string $code, DoctorAddress $address, array $appliedPolicies = []): PlannedRequirement - { - // بدون نام قانون، اپراتور می‌داند چه چیزی کم است ولی نه چرا لازم شده — و بین ده - // قانون فعال باید حدس بزند کدام را خاموش کند. - $names = array_values(array_filter(array_map( - static fn (array $p): ?string => is_string($p['name'] ?? null) ? $p['name'] : null, - $appliedPolicies, - ))); - $because = $names === [] ? '' : sprintf(' (قانون: %s)', implode('، ', $names)); - - $type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code); - - if ($type === null) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است%s', $code, $because), - 422, - 'requirements', - ); - } - - $eligible = array_values($this->resources->findEligible($address, $type, [])); - - if ($eligible === []) { - throw new AppException( - ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE, - sprintf('هیچ %s در شعبهٔ «%s» موجود نیست%s', $type->getName(), $address->getName() ?? '—', $because), - 422, - 'requirements', - ); - } - - return new PlannedRequirement( - role: $type->getCode(), - roleName: $type->getName(), - count: 1, - occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE, - constraints: [], - eligible: $eligible, - skillName: null, - setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()), - cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()), - ); - } - /** @return list */ private function planRequirements( SegmentTemplate $template, diff --git a/src/ClinicService/Service/ServiceSelectionValidator.php b/src/ClinicService/Service/ServiceSelectionValidator.php index e2ef3f42..b92ead61 100644 --- a/src/ClinicService/Service/ServiceSelectionValidator.php +++ b/src/ClinicService/Service/ServiceSelectionValidator.php @@ -9,8 +9,6 @@ use App\ClinicService\Entity\ServiceItemRelation; use App\ClinicService\Repository\ItemGroupMemberRepository; use App\ClinicService\Repository\ServiceItemRelationRepository; use App\Doctor\Entity\DoctorAddress; -use App\Policy\Entity\Policy; -use App\Policy\Engine\SelectionPolicyEngine; use App\ClinicService\Repository\ServiceBranchOverrideRepository; /** @@ -30,7 +28,6 @@ final class ServiceSelectionValidator private readonly ServiceItemRelationRepository $relations, private readonly ServiceBranchOverrideRepository $overrides, private readonly DurationCalculator $durations, - private readonly SelectionPolicyEngine $selectionPolicies, ) {} /** @@ -43,7 +40,6 @@ final class ServiceSelectionValidator $errors = [ ...$this->groupErrors($selected, $groups), ...$this->relationErrors($selected), - ...$this->policyErrors($selected, $address), ]; $overrides = $address === null @@ -62,52 +58,6 @@ final class ServiceSelectionValidator ]; } - /** - * ممنوعیت‌های دستهٔ «انتخاب» — لایه‌ای روی گروه و رابطه، نه جایگزینشان. - * - * گروه و رابطه ساختار ثابتِ کاتالوگ‌اند؛ قانون چیزی است که کلینیک بدون دست زدن - * به کاتالوگ روشن و خاموش می‌کند. بدون شعبه اجرا نمی‌شود چون محیط از آدرس - * می‌آید و بی‌آن هیچ محیطی برای جست‌وجو نیست. - * - * @param ServiceItem[] $selected - * @return list> - */ - private function policyErrors(array $selected, ?DoctorAddress $address): array - { - if ($address === null || $selected === []) { - return []; - } - - $facts = [ - 'item_count' => count($selected), - 'item_uuids' => array_map(static fn (ServiceItem $i): string => $i->getUuid(), $selected), - ]; - - $errors = []; - - // هر آیتم جداگانه حل می‌شود: قانونی که دامنه‌اش یک سرویس خاص است فقط وقتی - // معنا دارد که همان سرویس در انتخاب باشد، و پیام خطا باید بگوید کدام. - foreach ($selected as $item) { - $outcome = $this->selectionPolicies->evaluate( - $address->tenantEntityType(), - $address->tenantEntityId(), - $facts + ['catalog_category' => $item->getCatalogCategory()?->getUuid()], - $address, - $item, - ); - - foreach ($outcome->forbidReasons as $reason) { - $errors[] = [ - 'code' => 'policy_forbidden', - 'items' => [$item->getUuid()], - 'message' => $reason, - ]; - } - } - - return $errors; - } - /** * @param ServiceItem[] $selected * @param ItemGroup[] $groups diff --git a/src/Pricing/Controller/PricingController.php b/src/Pricing/Controller/PricingController.php index ee7e7536..6886e3eb 100644 --- a/src/Pricing/Controller/PricingController.php +++ b/src/Pricing/Controller/PricingController.php @@ -36,7 +36,6 @@ class PricingController extends BaseController private readonly PriceSnapshotRepository $snapshots, private readonly ServiceItemRepository $items, private readonly PricingEngine $engine, - private readonly PatientRecordRepository $patients, private readonly BranchResolver $branches, private readonly TenantOwnershipChecker $ownership, private readonly EntityManagerInterface $em, @@ -212,27 +211,7 @@ class PricingController extends BaseController $at = is_numeric($data['at'] ?? null) ? (int) $data['at'] : time(); $policy = is_array($data['policy'] ?? null) ? $data['policy'] : []; - // بیمار اختیاری است: بدون او پکیج معنا ندارد و قیمت همان قیمت کامل است. - $patient = is_string($data['patient_uuid'] ?? null) - ? $this->requirePatient($user, $data['patient_uuid']) - : null; - - return $this->success($this->engine->quote($service, $items, $address, $at, $policy, $patient)->toArray()); - } - - private function requirePatient(User $user, string $uuid): \App\Patient\Entity\PatientRecord - { - $patient = $this->patients->findOneBy(['uuid' => $uuid]); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($patient === null - || $patient->getEntityType() !== $entityType - || $patient->getEntityId() !== $entityId - ) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); - } - - return $patient; + return $this->success($this->engine->quote($service, $items, $address, $at, $policy)->toArray()); } /** diff --git a/src/Pricing/Service/PricingEngine.php b/src/Pricing/Service/PricingEngine.php index 1cbd5c35..486606ca 100644 --- a/src/Pricing/Service/PricingEngine.php +++ b/src/Pricing/Service/PricingEngine.php @@ -6,14 +6,10 @@ use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Repository\ServiceBranchOverrideRepository; use App\ClinicService\Repository\TariffRepository; use App\Doctor\Entity\DoctorAddress; -use App\Package\Service\PackageConsumptionService; use App\Patient\Entity\PatientRecord; use App\Pricing\Repository\PriceListItemRepository; use App\Pricing\Repository\PriceListRepository; use App\Pricing\ValueObject\PriceQuote; -use App\Policy\Entity\Policy; -use App\Policy\Engine\PricingPolicyEngine; -use App\Policy\Service\PolicySchema; use App\Representation\Service\JalaliDateService; /** @@ -38,8 +34,6 @@ use App\Representation\Service\JalaliDateService; final class PricingEngine { public function __construct( - private readonly PricingPolicyEngine $pricingPolicies, - private readonly PackageConsumptionService $packages, private readonly PriceListRepository $priceLists, private readonly PriceListItemRepository $priceListItems, private readonly ServiceBranchOverrideRepository $overrides, @@ -62,7 +56,6 @@ final class PricingEngine DoctorAddress $address, int $at, array $policy = [], - ?PatientRecord $patient = null, ): PriceQuote { $entityType = $address->tenantEntityType(); $entityId = $address->tenantEntityId(); @@ -80,22 +73,6 @@ final class PricingEngine $subtotal = $base + $itemsTotal; - // ── پکیج ────────────────────────────────────────────────────────────── - // پکیج **قیمت پایهٔ سرویس** را می‌پوشاند، نه آیتم‌های اضافه: «شش جلسه لیزر» - // یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود. - $usable = $patient === null ? null : $this->packages->firstUsable($patient, $service, $at); - $covered = 0; - - if ($usable !== null) { - $covered = min($base, $subtotal); - $subtotal -= $covered; - } - - // ── تخفیف ───────────────────────────────────────────────────────────── - // قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست می‌نشینند، نه به‌جایش: تخفیفی - // که اپراتور دستی می‌دهد و تخفیفی که قانون می‌دهد هر دو واقعی‌اند. - $policy = $this->mergePolicyDiscounts($service, $items, $address, $at, $subtotal, $policy, $sources); - [$discount, $discounts] = $this->discountFor($subtotal, $policy); // تخفیف بیشتر از مبلغ، مبلغ را **صفر** می‌کند نه منفی: بدهی منفی یعنی کلینیک @@ -125,13 +102,6 @@ final class PricingEngine $deposit = max(0, min($deposit, $final)); - if ($covered > 0) { - $discounts[] = [ - 'label' => sprintf('پوشش پکیج «%s»', $usable?->getPackage()->getName() ?? '—'), - 'rials' => $covered, - 'kind' => 'package', - ]; - } return new PriceQuote( baseRials: $base, @@ -144,61 +114,9 @@ final class PricingEngine depositRials: $deposit, discounts: $discounts, sources: $sources, - packageWillBeConsumed: $usable !== null, - packageUuid: $usable?->getUuid(), ); } - /** - * اثر قوانین «قیمت» را به سیاست درخواست اضافه می‌کند. - * - * شناسه و **نسخهٔ** هر قانون در `sources` ثبت می‌شود تا فاکتور بتواند سه ماه بعد - * بگوید کدام نسخه رویش اعمال شده بود. - * - * @param ServiceItem[] $items - * @param array $policy - * @param array $sources - * @return array - */ - private function mergePolicyDiscounts( - ServiceItem $service, - array $items, - DoctorAddress $address, - int $at, - int $subtotal, - array $policy, - array &$sources, - ): array { - $outcome = $this->pricingPolicies->evaluate( - $address->tenantEntityType(), - $address->tenantEntityId(), - [ - 'item_count' => count($items), - 'subtotal_rials' => $subtotal, - 'patient_tags' => $policy['patient_tags'] ?? [], - 'visit_count' => $policy['visit_count'] ?? 0, - ], - $address, - $service, - $at, - ); - - if ($outcome->appliedPolicies === []) { - return $policy; - } - - $sources['applied_policies'] = $outcome->appliedPolicies; - - $policy['discount_percent'] = (float) ($policy['discount_percent'] ?? 0) - + (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0); - - $policy['discount_rials'] = (int) ($policy['discount_rials'] ?? 0) - + (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0); - - $policy['discount_label'] ??= $outcome->appliedPolicies[0]['name']; - - return $policy; - } /** * @param array $sources diff --git a/src/Pricing/ValueObject/PriceQuote.php b/src/Pricing/ValueObject/PriceQuote.php index 0eb6ea58..be950faf 100644 --- a/src/Pricing/ValueObject/PriceQuote.php +++ b/src/Pricing/ValueObject/PriceQuote.php @@ -22,12 +22,6 @@ final readonly class PriceQuote public int $depositRials, public array $discounts = [], public array $sources = [], - /** - * پکیج در پیش‌نمایش **مصرف نمی‌شود** — فقط اعلام می‌شود. مصرف واقعی هنگام - * ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار می‌گرفت. - */ - public bool $packageWillBeConsumed = false, - public ?string $packageUuid = null, ) {} public function breakdown(): array @@ -46,8 +40,6 @@ final readonly class PriceQuote 'tax_rials' => $this->taxRials, 'final_rials' => $this->finalRials, 'deposit_rials' => $this->depositRials, - 'package_will_be_consumed' => $this->packageWillBeConsumed, - 'package_uuid' => $this->packageUuid, 'breakdown' => $this->breakdown(), ]; } diff --git a/src/Schedule.php b/src/Schedule.php index ca52e170..e2dab6eb 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -4,9 +4,7 @@ namespace App; use App\Appointment\Message\ExpireAppointmentsMessage; use App\Blog\Message\PublishScheduledBlogsMessage; -use App\Shared\Event\Message\PublishDomainEventsMessage; use App\Shared\Logging\Message\PruneLogsMessage; -use App\Waitlist\Message\ExpireWaitlistMessage; use Symfony\Component\Scheduler\Attribute\AsSchedule; use Symfony\Component\Scheduler\RecurringMessage; use Symfony\Component\Scheduler\Schedule as SymfonySchedule; @@ -34,16 +32,6 @@ class Schedule implements ScheduleProviderInterface ) ->add( RecurringMessage::every('1 minute', new PublishScheduledBlogsMessage()) - ) - // صندوق خروجی رویدادها. `stateful` بالا یعنی تیکِ ازدست‌رفته بعد از ری‌استارت - // جبران می‌شود، و چون خودِ publisher از جدول می‌خواند، یک اجرا کافی است تا - // هرچه در فاصله جمع شده برود. - ->add( - RecurringMessage::every('1 minute', new PublishDomainEventsMessage()) - ) - // انتظارهای مرده — پاکسازیِ نمایش، نه اصلاح رفتار (تطبیق از قبل ردشان می‌کرد). - ->add( - RecurringMessage::every('1 day', new ExpireWaitlistMessage()) ); } } diff --git a/src/Shared/Command/BookingEngineSeeder.php b/src/Shared/Command/BookingEngineSeeder.php index b7001c31..455160ce 100644 --- a/src/Shared/Command/BookingEngineSeeder.php +++ b/src/Shared/Command/BookingEngineSeeder.php @@ -10,25 +10,14 @@ use App\Appointment\Plan\Entity\SegmentRequirement; use App\Appointment\Plan\Entity\SegmentTemplate; use App\Appointment\Plan\Service\AppointmentPlanBuilder; use App\Auth\Entity\User; -use App\Cancellation\Entity\CancellationPolicy; -use App\Cancellation\Entity\NoShowRecord; use App\ClinicService\Entity\CatalogCategory; use App\ClinicService\Entity\ItemGroup; use App\ClinicService\Entity\ItemGroupMember; use App\ClinicService\Entity\ServiceBranchOverride; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceItemRelation; -use App\Course\Entity\CourseProtocol; -use App\Course\Entity\CourseProtocolStep; -use App\Course\Entity\TreatmentCourse; use App\Doctor\Entity\DoctorAddress; -use App\Package\Entity\Package; -use App\Package\Entity\PackageService; -use App\Package\Entity\PatientPackage; -use App\Package\Entity\SessionCreditLedger; -use App\Package\Service\CreditLedgerService; use App\Patient\Entity\PatientRecord; -use App\Policy\Entity\Policy; use App\Pricing\Entity\PriceList; use App\Pricing\Entity\PriceListItem; use App\Pricing\Service\PriceSnapshotService; @@ -40,7 +29,6 @@ use App\Resource\Entity\ResourcePoolMember; use App\Resource\Entity\ResourceSkill; use App\Resource\Entity\ResourceType; use App\Resource\Entity\Skill; -use App\Waitlist\Entity\WaitlistEntry; use Doctrine\Persistence\ManagerRegistry; /** @@ -64,7 +52,6 @@ final class BookingEngineSeeder private readonly HoldService $holds, private readonly BookingService $booking, private readonly PriceSnapshotService $snapshots, - private readonly CreditLedgerService $credits, ) {} private function em(): \Doctrine\ORM\EntityManagerInterface @@ -101,10 +88,7 @@ final class BookingEngineSeeder $counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType); $counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address); $counts['pricing'] = $this->priceList($entityType, $entityId, $services); - $counts['policies'] = $this->policies($entityType, $entityId, $services); $counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId); - $counts['packages'] = $this->packagesAndCourses($entityType, $entityId, $services, $patients); - $counts['aftercare'] = $this->cancellationAndWaitlist($entityType, $entityId, $services, $patients, $doctor); return $counts; } @@ -258,50 +242,6 @@ final class BookingEngineSeeder // ── تسک ۰۹: سیاست‌ها، یکی از هر دسته ──────────────────────────────────── - private function policies(string $entityType, int $entityId, array $services): int - { - $service = $services[0]; - - $rows = [ - [Policy::CATEGORY_SELECTION, 'حداکثر سه ناحیه در یک نوبت', - [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]], - [['type' => 'forbid']]], - - [Policy::CATEGORY_ELIGIBILITY, 'زیر ۱۸ سال بدون رضایت والدین ممنوع', - [['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]], - [['type' => 'require_flag', 'value' => 'parental_consent']]], - - [Policy::CATEGORY_RESOURCE, 'لیزر بدنِ کامل اپراتور ارشد می‌خواهد', - [['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]], - [['type' => 'require_resource', 'value' => 'laser']]], - - [Policy::CATEGORY_TIMING, 'سبد بزرگ ۱۵ دقیقه وقت بیشتر می‌گیرد', - [['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 3]], - [['type' => 'add_duration_minutes', 'value' => 15]]], - - [Policy::CATEGORY_SPACING, 'حداقل ۲۸ روز فاصله بین دو جلسهٔ لیزر', - [['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]], - [['type' => 'min_days_between', 'value' => 28]]], - - [Policy::CATEGORY_PRICING, 'تخفیف ۱۰٪ برای سبد بالای ۲۰ میلیون ریال', - [['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 20_000_000]], - [['type' => 'discount_percent', 'value' => 10]]], - ]; - - foreach ($rows as $i => [$category, $name, $conditions, $effects]) { - $policy = new Policy($entityType, $entityId, $category, $name); - $policy->setCondition(['match' => 'all', 'conditions' => $conditions]) - ->setEffects($effects) - ->setPriority(10 * ($i + 1)) - ->setActive(true); - $this->em()->persist($policy); - } - - $this->em()->flush(); - - return count($rows); - } - // ── تسک ۰۶ و ۰۷: رزرو واقعی روی تقویم منابع ───────────────────────────── /** @@ -374,130 +314,6 @@ final class BookingEngineSeeder // ── تسک ۱۱ و ۱۲: پکیج، دفتر اعتبار، دورهٔ درمان ───────────────────────── - private function packagesAndCourses(string $entityType, int $entityId, array $services, array $patients): int - { - $service = $services[0]; - - $package = new Package($entityType, $entityId, 'پکیج ۶ جلسه لیزر', 6); - $package->setPriceRials(60_000_000)->setValidityDays(365)->setActive(true); - $this->em()->persist($package); - $this->em()->persist(new PackageService($package, $service)); - $this->em()->flush(); - - $records = $this->em()->getRepository(PatientRecord::class) - ->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 3); - - $made = 0; - foreach ($records as $i => $record) { - if ($i >= 2) { - break; - } - - $patientPackage = new PatientPackage($package, $record); - $this->em()->persist($patientPackage); - $this->em()->flush(); - - // یک جلسه مصرف‌شده تا دفتر خالی نباشد؛ دفتر append-only است، پس مصرف هم - // یک ردیف است نه کم‌کردن یک عدد. - $this->credits->record($patientPackage, SessionCreditLedger::KIND_CONSUME, -1, null, $service, 'مصرف جلسهٔ اول'); - $made++; - } - - // دورهٔ درمان: پروتکل با پارامتر هر جلسه (انرژی لیزر بالا می‌رود) و یک دورهٔ فعال. - $protocol = new CourseProtocol($service, 6, 21, 28, 45); - $this->em()->persist($protocol); - foreach (range(1, 6) as $n) { - $this->em()->persist(new CourseProtocolStep($protocol, $n, ['energy' => 10 + $n * 2, 'spot_size' => 18])); - } - $this->em()->flush(); - - if ($records !== []) { - $course = new TreatmentCourse($records[0], $protocol); - $this->em()->persist($course); - - // جلسه‌های دوره از روی گام‌های پروتکل ساخته می‌شوند. بدون این ردیف‌ها دوره - // «۶ جلسه‌ای» است ولی هیچ جلسه‌ای ندارد، و پیشنهاد جلسهٔ بعد می‌گوید همه‌چیز - // برنامه‌ریزی شده — یعنی دقیقاً برعکس واقعیت. - foreach ($protocol->getSteps() as $step) { - $this->em()->persist(new \App\Course\Entity\CourseSession( - $course, - $step->getSessionNumber(), - $step->getParams(), - )); - } - - $this->em()->flush(); - $made++; - } - - return $made; - } - // ── تسک ۱۳: لغو، عدم حضور، لیست انتظار ────────────────────────────────── - private function cancellationAndWaitlist(string $entityType, int $entityId, array $services, array $patients, \App\Doctor\Entity\Doctor $doctor): int - { - // سیاست عمومیِ محیط + یک سیاست سخت‌گیرانه‌تر روی سرویس گران — تا انتخابِ - // «اختصاصی‌ترین سیاست» چیزی برای انتخاب داشته باشد. - $general = new CancellationPolicy($entityType, $entityId); - $general->setFreeWindowHours(24)->setPenalty(CancellationPolicy::MODE_PERCENT, 30) - ->setDepositRefundable(true)->setCreditRefundable(true)->setNoShowThreshold(3)->setActive(true); - $this->em()->persist($general); - - $strict = new CancellationPolicy($entityType, $entityId, $services[0]); - $strict->setFreeWindowHours(48)->setPenalty(CancellationPolicy::MODE_PERCENT, 50) - ->setDepositRefundable(false)->setCreditRefundable(false)->setNoShowThreshold(2)->setActive(true); - $this->em()->persist($strict); - $this->em()->flush(); - - $records = $this->em()->getRepository(PatientRecord::class) - ->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 4); - - // عدم حضورِ ثبت‌شده روی نوبت‌هایی که وضعیتشان no_show است — بدون این ردیف، - // شمارندهٔ عدم حضور بیمار همیشه صفر می‌ماند و آستانهٔ سیاست هرگز فعال نمی‌شود. - $noShows = $this->em()->getRepository(Appointment::class) - ->findBy(['doctor' => $doctor, 'status' => Appointment::STATUS_NO_SHOW], null, 2); - - foreach ($noShows as $appointment) { - $record = $this->recordFor($records, $appointment->getUser()); - if ($record !== null) { - $this->em()->persist(new NoShowRecord($record, $appointment)); - } - } - - $made = 2 + count($noShows); - - // لیست انتظار: دو نفر منتظرِ بازهٔ هفتهٔ آینده، یکی با ترجیح صبح. - foreach (array_slice($records, 0, 2) as $i => $record) { - $entry = new WaitlistEntry( - $record, - $services[min($i, count($services) - 1)], - strtotime('+2 days 00:00'), - strtotime('+9 days 00:00'), - $this->em()->getRepository(\App\Doctor\Entity\DoctorAddress::class)->findOneBy([])?->getId(), - ); - $entry->setPriority(10 - $i); - if ($i === 0) { - $entry->setPreferredDayParts(['morning']); - } - $this->em()->persist($entry); - $made++; - } - - $this->em()->flush(); - - return $made; - } - - /** @param PatientRecord[] $records */ - private function recordFor(array $records, User $user): ?PatientRecord - { - foreach ($records as $record) { - if ($record->getUser()->getId() === $user->getId()) { - return $record; - } - } - - return $records[0] ?? null; - } } diff --git a/src/Shared/Command/SeedScenariosCommand.php b/src/Shared/Command/SeedScenariosCommand.php index ef7f1d8d..98d63a00 100644 --- a/src/Shared/Command/SeedScenariosCommand.php +++ b/src/Shared/Command/SeedScenariosCommand.php @@ -176,7 +176,7 @@ class SeedScenariosCommand extends Command foreach ($this->engineCounts as $scenario => $counts) { $rows[] = array_merge(['سناریو ' . $scenario], array_values($counts)); } - $io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخش‌ها', 'قیمت', 'سیاست', 'رزرو واقعی', 'پکیج/دوره', 'لغو/انتظار'], $rows); + $io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخش‌ها', 'قیمت', 'رزرو واقعی'], $rows); return Command::SUCCESS; } diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index d3531e15..86347ba4 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -110,7 +110,6 @@ final class GlobalTables \App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class, \App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class, - \App\Policy\Entity\PolicyVersionLog::class => \App\Policy\Entity\Policy::class, \App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class, \App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class, @@ -118,11 +117,7 @@ final class GlobalTables \App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class, - // سرویس‌های یک پکیج جزئی از تعریف همان پکیج‌اند، نه دادهٔ مستقل. - \App\Package\Entity\PackageService::class => \App\Package\Entity\Package::class, - // پارامترهای هر جلسه جزئی از تعریف همان پروتکل‌اند. - \App\Course\Entity\CourseProtocolStep::class => \App\Course\Entity\CourseProtocol::class, \App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class, \App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class, diff --git a/tests/Appointment/HoldAndBookTest.php b/tests/Appointment/HoldAndBookTest.php index 5bdea9f9..28d86c98 100644 --- a/tests/Appointment/HoldAndBookTest.php +++ b/tests/Appointment/HoldAndBookTest.php @@ -510,12 +510,5 @@ class HoldAndBookTest extends ApiTestCase self::assertSame(200, $this->responseCode(), json_encode($moved, JSON_UNESCAPED_UNICODE)); self::assertSame($newStart, $moved['data']['starts_at']); - - $event = $this->em->getRepository(\App\Shared\Event\Entity\DomainEventLog::class) - ->findOneBy(['name' => \App\Shared\Event\DomainEvents::APPOINTMENT_RESCHEDULED], ['id' => 'DESC']); - - self::assertNotNull($event); - self::assertSame($start, $event->getPayload()['previous_start']); - self::assertSame($newStart, $event->getPayload()['new_start']); } } diff --git a/tests/Appointment/ResourceBlockTest.php b/tests/Appointment/ResourceBlockTest.php index 790bc482..e5fab02a 100644 --- a/tests/Appointment/ResourceBlockTest.php +++ b/tests/Appointment/ResourceBlockTest.php @@ -8,8 +8,6 @@ use App\Clinic\Entity\Clinic; use App\Doctor\Entity\DoctorAddress; use App\Resource\Entity\ClinicResource; use App\Resource\Entity\ResourceType; -use App\Shared\Event\DomainEvents; -use App\Shared\Event\Entity\DomainEventLog; use App\Tests\ApiTestCase; use Doctrine\ORM\EntityManagerInterface; @@ -164,39 +162,4 @@ class ResourceBlockTest extends ApiTestCase self::assertSame(404, $this->responseCode()); } - /** - * ظرفیتی که برمی‌گردد باید همان‌قدر شنیده شود که ظرفیتی که می‌رود: مصرف‌کننده‌ای که - * فقط مسدودسازی را بشنود، منبع را برای همیشه اشغال می‌بیند. - */ - public function testBlockingAndReleasingEachRecordADomainEvent(): void - { - [$user, $resource] = $this->clinicWithResource(); - - $start = time() + 86400; - - $created = $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [ - 'starts_at' => $start, - 'ends_at' => $start + 3600, - ]); - - self::assertSame(201, $this->responseCode()); - - $blocked = $this->latestEvent(DomainEvents::RESOURCE_BLOCKED); - self::assertNotNull($blocked); - self::assertSame($resource->getUuid(), $blocked->getPayload()['resource_uuid']); - self::assertSame($start, $blocked->getPayload()['starts_at']); - - $this->authJson('DELETE', "/api/v1/resource-block/{$created['data']['uuid']}", $user); - self::assertSame(200, $this->responseCode()); - - $released = $this->latestEvent(DomainEvents::RESOURCE_RELEASED); - self::assertNotNull($released); - self::assertSame($created['data']['uuid'], $released->getPayload()['block_uuid']); - } - - private function latestEvent(string $name): ?DomainEventLog - { - return $this->em->getRepository(DomainEventLog::class) - ->findOneBy(['name' => $name], ['id' => 'DESC']); - } }