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