feat: close the last four domain events, and the panel paths they describe
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:
- AppointmentCompleted fires from both status-change routes, after the row is
saved. A rejected transition or a version conflict leaves no event; otherwise
the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
confirm plus a cancel, and a consumer that only hears the cancel messages a
patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
as audible as capacity going away, or the resource reads as permanently taken.
Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.
Panel work that these paths made reachable:
- Cancelling from the appointment page now goes through the policy-aware
endpoint and shows the penalty preview before the confirm, so the operator
does not discover the patient's penalty after the fact. The cancellation
service writes the timeline entry itself and accepts a reason, which that
path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
hold steps are identical and only the final step differs. The doctor picker
is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
list is not an error, it means the appointment is slot-based, and that is
exactly what gates the resource-mode reschedule button.
AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,8 @@ export default function AppointmentInvoiceCard({ appointmentUuid }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{invoice.breakdown.discounts.map((line, index) => (
|
{/* فاکتور قدیمی ممکن است ریز تخفیف نداشته باشد؛ نبودنش نباید کل صفحه را ببندد. */}
|
||||||
|
{(invoice.breakdown?.discounts ?? []).map((line, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-3)' }}
|
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-3)' }}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import AppointmentSegmentsCard from './AppointmentSegmentsCard';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const segment = (over: Record<string, unknown>) => ({
|
||||||
|
sequence: 1,
|
||||||
|
name: 'ویزیت',
|
||||||
|
starts_at: 1_800_000_000,
|
||||||
|
ends_at: 1_800_001_200,
|
||||||
|
duration_minutes: 20,
|
||||||
|
patient_present: true,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AppointmentSegmentsCard', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('sums the recorded segments rather than the service duration', async () => {
|
||||||
|
get.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
segment({ sequence: 1, duration_minutes: 20 }),
|
||||||
|
segment({ sequence: 2, name: 'انتظار', duration_minutes: 40, patient_present: false }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('60 دقیقه')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ⭐ مدتی که بیمار روی صندلی نیست باید دیده شود، وگرنه «۹۰ دقیقه» گمراهکننده است. */
|
||||||
|
it('marks the segments the patient is not present for', async () => {
|
||||||
|
get.mockResolvedValue({
|
||||||
|
data: [segment({ sequence: 1, name: 'انتظار', patient_present: false })],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('بدون حضور بیمار')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** نوبت اسلاتی بخشی ندارد؛ کارتِ خالی یعنی «چیزی خراب است». */
|
||||||
|
it('renders nothing for an appointment with no segments', async () => {
|
||||||
|
get.mockResolvedValue({ data: [] });
|
||||||
|
|
||||||
|
const { container } = renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
expect(container.textContent).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAppointmentSegments } from '../hooks/useResourceBooking';
|
||||||
|
|
||||||
|
const timeOf = (ts: number) =>
|
||||||
|
new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بخشهای ثبتشدهٔ نوبت — همان چیزی که لحظهٔ رزرو تثبیت شد، نه الگوی امروزِ خدمت.
|
||||||
|
*
|
||||||
|
* بخشی که بیمار در آن حاضر نیست کمرنگ میآید: اپراتوری که «۹۰ دقیقه» را روی نوبت
|
||||||
|
* میبیند باید بفهمد بیمار همهٔ آن مدت روی صندلی نیست.
|
||||||
|
*
|
||||||
|
* نوبت اسلاتی بخشی ندارد و کارت اصلاً رندر نمیشود — جدول خالی یعنی چیزی خراب است.
|
||||||
|
*/
|
||||||
|
export default function AppointmentSegmentsCard({ appointmentUuid }: { appointmentUuid: string }) {
|
||||||
|
const { segments, loading } = useAppointmentSegments(appointmentUuid);
|
||||||
|
|
||||||
|
if (loading || segments.length === 0) return null;
|
||||||
|
|
||||||
|
const total = segments.reduce((sum, s) => sum + s.duration_minutes, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-[var(--text)]">بخشهای نوبت</h3>
|
||||||
|
<span className="text-sm text-[var(--text-2)]">{total} دقیقه</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 2, marginBottom: 14 }}>
|
||||||
|
{segments.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.sequence}
|
||||||
|
title={`${s.name} — ${s.duration_minutes} دقیقه`}
|
||||||
|
style={{
|
||||||
|
flex: s.duration_minutes,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 'var(--r-pill)',
|
||||||
|
background: 'var(--primary)',
|
||||||
|
opacity: s.patient_present ? 1 : 0.35,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
|
||||||
|
{segments.map((s) => (
|
||||||
|
<li key={s.sequence} style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<span style={{ color: 'var(--text-3)', minWidth: 18 }}>{s.sequence}</span>
|
||||||
|
<span style={{ flex: 1 }}>{s.name}</span>
|
||||||
|
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{timeOf(s.starts_at)} – {timeOf(s.ends_at)}
|
||||||
|
</span>
|
||||||
|
{!s.patient_present && (
|
||||||
|
<span className="badge" style={{ fontSize: 11 }}>
|
||||||
|
بدون حضور بیمار
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import CancelAppointmentDialog from './CancelAppointmentDialog';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const preview = (over: Record<string, unknown> = {}) => ({
|
||||||
|
data: {
|
||||||
|
penalty_rials: 250_000,
|
||||||
|
deposit_refundable: true,
|
||||||
|
credit_refundable: true,
|
||||||
|
within_free_window: false,
|
||||||
|
notes: [],
|
||||||
|
paid_rials: 1_000_000,
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CancelAppointmentDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
post.mockResolvedValue({ data: { waitlist_notified: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ⭐ عددی که اپراتور میبیند باید همان باشد که کسر میشود. */
|
||||||
|
it('shows the penalty from the server before anything is cancelled', async () => {
|
||||||
|
get.mockResolvedValue(preview());
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// مبلغها به تومان نمایش داده میشوند: ۲۵۰٬۰۰۰ ریال ⇒ ۲۵٬۰۰۰ تومان.
|
||||||
|
await waitFor(() => expect(screen.getByText(/۲۵٬۰۰۰ تومان/)).toBeInTheDocument());
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says "بدون جریمه" inside the free window instead of showing a zero', async () => {
|
||||||
|
get.mockResolvedValue(preview({ penalty_rials: 0, within_free_window: true }));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('بدون جریمه')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText('در بازهٔ لغو رایگان است.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the typed reason along with the cancellation', async () => {
|
||||||
|
get.mockResolvedValue(preview());
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/مبلغ پرداختشده/)).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/دلیل لغو/), 'بیمار تماس گرفت');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'لغو نوبت' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(post).toHaveBeenCalledWith('/api/v1/appointment/a-1/cancel', {
|
||||||
|
by: 'doctor',
|
||||||
|
reason: 'بیمار تماس گرفت',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** پیشنمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید که نیامده. */
|
||||||
|
it('still allows cancelling when the preview fails, and says so', async () => {
|
||||||
|
get.mockRejectedValue(new Error('down'));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import ConfirmDialog from './ui/ConfirmDialog';
|
||||||
|
import { useCancellationPreview, useCancelAppointment } from '../hooks/useCancellation';
|
||||||
|
import { formatRial } from '../lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
appointmentUuid: string;
|
||||||
|
/** چه کسی لغو میکند — جریمه برای لغو کلینیک همیشه صفر است. */
|
||||||
|
by?: 'user' | 'doctor';
|
||||||
|
onClose: () => void;
|
||||||
|
onCancelled?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لغو نوبت با نمایش پیامد مالی **قبل از** تأیید.
|
||||||
|
*
|
||||||
|
* پیشنمایش از همان محاسبهای میآید که خودِ لغو انجام میدهد، پس عددی که اپراتور
|
||||||
|
* میبیند همان است که کسر میشود. دکمهٔ لغوِ بیپیشنمایش یعنی اپراتور جریمهٔ بیمار را
|
||||||
|
* بعد از وقوعش کشف میکند.
|
||||||
|
*/
|
||||||
|
export default function CancelAppointmentDialog({
|
||||||
|
open,
|
||||||
|
appointmentUuid,
|
||||||
|
by = 'doctor',
|
||||||
|
onClose,
|
||||||
|
onCancelled,
|
||||||
|
}: Props) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const { preview, loading } = useCancellationPreview(open ? appointmentUuid : undefined, by);
|
||||||
|
const cancel = useCancelAppointment();
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setReason('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={open}
|
||||||
|
title="لغو نوبت"
|
||||||
|
message="آیا از لغو این نوبت اطمینان دارید؟"
|
||||||
|
confirmLabel="لغو نوبت"
|
||||||
|
danger
|
||||||
|
loading={cancel.isPending}
|
||||||
|
onConfirm={() =>
|
||||||
|
cancel.mutate(
|
||||||
|
{ uuid: appointmentUuid, by, reason },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
close();
|
||||||
|
onCancelled?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCancel={close}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 14,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>در حال محاسبهٔ پیامد لغو…</span>
|
||||||
|
) : !preview ? (
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>
|
||||||
|
پیامد مالی لغو در دسترس نیست؛ لغو انجام میشود ولی مبلغ را دستی بررسی کنید.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>جریمهٔ لغو</span>
|
||||||
|
<strong style={{ color: preview.penalty_rials > 0 ? 'var(--danger)' : 'var(--success)' }}>
|
||||||
|
{preview.penalty_rials > 0 ? formatRial(preview.penalty_rials) : 'بدون جریمه'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>مبلغ پرداختشده</span>
|
||||||
|
<span>{formatRial(preview.paid_rials)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>بازگشت اعتبار پکیج</span>
|
||||||
|
<span>{preview.credit_refundable ? 'بله' : 'خیر'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview.within_free_window && (
|
||||||
|
<div style={{ color: 'var(--success)' }}>در بازهٔ لغو رایگان است.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview.notes.map((note, i) => (
|
||||||
|
<div key={i} style={{ color: 'var(--text-3)', fontSize: 12 }}>
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<label className="cp-label mb-2" htmlFor="cancel-reason">
|
||||||
|
دلیل لغو (اختیاری)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="cancel-reason"
|
||||||
|
className="cp-input"
|
||||||
|
rows={2}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="دلیل لغو نوبت را وارد کنید..."
|
||||||
|
style={{ width: '100%', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,8 +64,11 @@ export function useCancelAppointment() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ uuid, by }: { uuid: string; by: 'user' | 'doctor' }) =>
|
mutationFn: ({ uuid, by, reason }: { uuid: string; by: 'user' | 'doctor'; reason?: string }) =>
|
||||||
api.post<ApiResponse<CancellationResult>>(`/api/v1/appointment/${uuid}/cancel`, { by }),
|
api.post<ApiResponse<CancellationResult>>(`/api/v1/appointment/${uuid}/cancel`, {
|
||||||
|
by,
|
||||||
|
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
||||||
|
}),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const notified = res.data.waitlist_notified;
|
const notified = res.data.waitlist_notified;
|
||||||
toast.success(
|
toast.success(
|
||||||
|
|||||||
@@ -109,5 +109,44 @@ export function useHold() {
|
|||||||
onError: (e) => fail(e, 'ثبت نهایی ناموفق بود'),
|
onError: (e) => fail(e, 'ثبت نهایی ناموفق بود'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { create, release, confirm };
|
/**
|
||||||
|
* جابهجایی: رزرو موقتِ زمان تازه از قبل گرفته شده و اینجا فقط تأیید میشود.
|
||||||
|
*
|
||||||
|
* عمداً از `confirm` جداست — سرور در همین یک درخواست زمان قدیم را هم آزاد میکند، پس
|
||||||
|
* لغو دستی پیش از رزرو یعنی پنجرهای که در آن بیمار اصلاً نوبت ندارد.
|
||||||
|
*/
|
||||||
|
const rebook = useMutation({
|
||||||
|
mutationFn: ({ appointmentUuid, holdUuid }: { appointmentUuid: string; holdUuid: string }) =>
|
||||||
|
api.post<ApiResponse<{ appointment_uuid: string; starts_at: number }>>(
|
||||||
|
`/api/v1/appointment/${appointmentUuid}/rebook`,
|
||||||
|
{ hold_uuid: holdUuid },
|
||||||
|
),
|
||||||
|
onSuccess: () => toast.success('نوبت جابهجا شد'),
|
||||||
|
onError: (e) => fail(e, 'جابهجایی نوبت ناموفق بود'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { create, release, confirm, rebook };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentSegmentRow {
|
||||||
|
sequence: number;
|
||||||
|
name: string;
|
||||||
|
starts_at: number;
|
||||||
|
ends_at: number;
|
||||||
|
duration_minutes: number;
|
||||||
|
patient_present: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فهرست خالی یعنی نوبت اسلاتی است، نه اینکه چیزی خراب باشد. */
|
||||||
|
export function useAppointmentSegments(uuid: string | undefined) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['appointment-segments', uuid],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<ApiResponse<AppointmentSegmentRow[]>>(`/api/v1/appointment/${uuid}/segments`),
|
||||||
|
enabled: !!uuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = query.data?.data;
|
||||||
|
|
||||||
|
return { segments: Array.isArray(rows) ? rows : [], loading: query.isLoading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { formatDate, formatDateTime, toDate } from '../lib/utils';
|
|||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import AppointmentInvoiceCard from '../components/AppointmentInvoiceCard';
|
import AppointmentInvoiceCard from '../components/AppointmentInvoiceCard';
|
||||||
import StatusBadge from '../components/ui/StatusBadge';
|
import StatusBadge from '../components/ui/StatusBadge';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import CancelAppointmentDialog from '../components/CancelAppointmentDialog';
|
||||||
|
import AppointmentSegmentsCard from '../components/AppointmentSegmentsCard';
|
||||||
|
import { useAppointmentSegments } from '../hooks/useResourceBooking';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import ConfirmAppointmentModal from '../components/appointments/ConfirmAppointmentModal';
|
import ConfirmAppointmentModal from '../components/appointments/ConfirmAppointmentModal';
|
||||||
|
|
||||||
@@ -53,7 +55,6 @@ export default function AppointmentDetailPage() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [cancelOpen, setCancelOpen] = useState(false);
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState('');
|
|
||||||
const [newStatus, setNewStatus] = useState('');
|
const [newStatus, setNewStatus] = useState('');
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -69,6 +70,9 @@ export default function AppointmentDetailPage() {
|
|||||||
});
|
});
|
||||||
const events: AppointmentEvent[] = (eventsQuery.data?.data as any) ?? [];
|
const events: AppointmentEvent[] = (eventsQuery.data?.data as any) ?? [];
|
||||||
|
|
||||||
|
// خالی بودن یعنی نوبت اسلاتی است؛ همین تفاوت تعیین میکند جابهجایی منبعمحور دیده شود یا نه.
|
||||||
|
const { segments } = useAppointmentSegments(uuid);
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: (status: string) =>
|
mutationFn: (status: string) =>
|
||||||
api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, { status, version: appt?.version }),
|
api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, { status, version: appt?.version }),
|
||||||
@@ -80,22 +84,6 @@ export default function AppointmentDetailPage() {
|
|||||||
onError: (err: Error) => toast.error(err.message),
|
onError: (err: Error) => toast.error(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
|
||||||
mutationFn: () => api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, {
|
|
||||||
status: 'cancelled_by_doctor',
|
|
||||||
version: appt?.version,
|
|
||||||
...(cancelReason.trim() ? { cancel_reason: cancelReason.trim() } : {}),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('نوبت لغو شد');
|
|
||||||
setCancelOpen(false);
|
|
||||||
setCancelReason('');
|
|
||||||
qc.invalidateQueries({ queryKey: ['appointment', uuid] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['appointment-events', uuid] });
|
|
||||||
},
|
|
||||||
onError: (err: Error) => toast.error(err.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
// پاسخ single تودرتو است: { data: { data: {...} } }
|
// پاسخ single تودرتو است: { data: { data: {...} } }
|
||||||
const appt: any = (data?.data as any)?.data ?? data?.data;
|
const appt: any = (data?.data as any)?.data ?? data?.data;
|
||||||
|
|
||||||
@@ -148,6 +136,8 @@ export default function AppointmentDetailPage() {
|
|||||||
|
|
||||||
<AppointmentInvoiceCard appointmentUuid={appt.uuid} />
|
<AppointmentInvoiceCard appointmentUuid={appt.uuid} />
|
||||||
|
|
||||||
|
<AppointmentSegmentsCard appointmentUuid={appt.uuid} />
|
||||||
|
|
||||||
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
||||||
<h3 className="font-semibold text-[var(--text)] mb-4">وضعیت و اقدامات</h3>
|
<h3 className="font-semibold text-[var(--text)] mb-4">وضعیت و اقدامات</h3>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -186,6 +176,17 @@ export default function AppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* جابهجایی منبعمحور فقط برای نوبتی معنا دارد که بخش ثبتشده دارد؛
|
||||||
|
نوبت اسلاتی از مسیر ویرایشِ خودش جابهجا میشود. */}
|
||||||
|
{segments.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/resource-booking?rebook=${uuid}`)}
|
||||||
|
className="btn secondary w-full mt-3"
|
||||||
|
>
|
||||||
|
جابهجایی نوبت
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-[var(--border)]">
|
<div className="mt-4 pt-4 border-t border-[var(--border)]">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCancelOpen(true)}
|
onClick={() => setCancelOpen(true)}
|
||||||
@@ -231,28 +232,15 @@ export default function AppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<CancelAppointmentDialog
|
||||||
open={cancelOpen}
|
open={cancelOpen}
|
||||||
title="لغو نوبت"
|
appointmentUuid={uuid!}
|
||||||
message="آیا از لغو این نوبت اطمینان دارید؟"
|
onClose={() => setCancelOpen(false)}
|
||||||
confirmLabel="لغو نوبت"
|
onCancelled={() => {
|
||||||
danger
|
qc.invalidateQueries({ queryKey: ['appointment', uuid] });
|
||||||
loading={cancelMutation.isPending}
|
qc.invalidateQueries({ queryKey: ['appointment-events', uuid] });
|
||||||
onConfirm={() => cancelMutation.mutate()}
|
}}
|
||||||
onCancel={() => { setCancelOpen(false); setCancelReason(''); }}
|
/>
|
||||||
>
|
|
||||||
<div style={{ marginTop: 14 }}>
|
|
||||||
<label className="cp-label mb-2">دلیل لغو (اختیاری)</label>
|
|
||||||
<textarea
|
|
||||||
className="cp-input"
|
|
||||||
rows={2}
|
|
||||||
value={cancelReason}
|
|
||||||
onChange={(e) => setCancelReason(e.target.value)}
|
|
||||||
placeholder="دلیل لغو نوبت را وارد کنید..."
|
|
||||||
style={{ width: '100%', resize: 'vertical' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmAppointmentModal
|
<ConfirmAppointmentModal
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import HoldCountdown from '../components/HoldCountdown';
|
import HoldCountdown from '../components/HoldCountdown';
|
||||||
@@ -31,9 +31,19 @@ function timeOf(ts: number): string {
|
|||||||
*/
|
*/
|
||||||
export default function ResourceBookingPage() {
|
export default function ResourceBookingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
const { branches } = useBranches();
|
const { branches } = useBranches();
|
||||||
const { items: services } = useAllServiceItems();
|
const { items: services } = useAllServiceItems();
|
||||||
const { create, release, confirm } = useHold();
|
const { create, release, confirm, rebook } = useHold();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حالت جابهجایی — همان سه مرحله، با یک تفاوت در گام آخر.
|
||||||
|
*
|
||||||
|
* صفحهٔ جدا نساختیم چون جستجو و رزرو موقت دقیقاً هماناند؛ چیزی که فرق میکند فقط
|
||||||
|
* این است که در پایان بهجای «ثبت نوبت تازه»، نوبت موجود جابهجا میشود و زمان قدیم
|
||||||
|
* در همان درخواست آزاد میشود.
|
||||||
|
*/
|
||||||
|
const rebookUuid = params.get('rebook');
|
||||||
|
|
||||||
const [serviceUuid, setServiceUuid] = useState('');
|
const [serviceUuid, setServiceUuid] = useState('');
|
||||||
const [branchUuid, setBranchUuid] = useState('');
|
const [branchUuid, setBranchUuid] = useState('');
|
||||||
@@ -121,9 +131,13 @@ export default function ResourceBookingPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="رزرو نوبت منبعمحور"
|
title={rebookUuid ? 'جابهجایی نوبت' : 'رزرو نوبت منبعمحور'}
|
||||||
description="وقت آزاد از تقاطع تقویم منابع میآید؛ هر وقت با منابع پیشنهادی خودش نمایش داده میشود."
|
description={
|
||||||
backTo="/admin/appointments"
|
rebookUuid
|
||||||
|
? 'زمان تازه را انتخاب و نگه دارید؛ زمان قبلی در همان لحظهٔ جابهجایی آزاد میشود.'
|
||||||
|
: 'وقت آزاد از تقاطع تقویم منابع میآید؛ هر وقت با منابع پیشنهادی خودش نمایش داده میشود.'
|
||||||
|
}
|
||||||
|
backTo={rebookUuid ? `/admin/appointments/${rebookUuid}` : '/admin/appointments'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
@@ -285,15 +299,18 @@ export default function ResourceBookingPage() {
|
|||||||
نمایش داده نمیشود.
|
نمایش داده نمیشود.
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="field" style={{ maxWidth: 280, margin: 0 }}>
|
{/* در جابهجایی پزشک عوض نمیشود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */}
|
||||||
<label>پزشک نوبت</label>
|
{!rebookUuid && (
|
||||||
<SearchableSelect
|
<div className="field" style={{ maxWidth: 280, margin: 0 }}>
|
||||||
value={doctorUuid}
|
<label>پزشک نوبت</label>
|
||||||
onChange={(v) => setDoctorUuid(String(v ?? ''))}
|
<SearchableSelect
|
||||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
value={doctorUuid}
|
||||||
placeholder="انتخاب پزشک"
|
onChange={(v) => setDoctorUuid(String(v ?? ''))}
|
||||||
/>
|
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
||||||
</div>
|
placeholder="انتخاب پزشک"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{hold === null ? (
|
{hold === null ? (
|
||||||
@@ -307,17 +324,34 @@ export default function ResourceBookingPage() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
{rebookUuid ? (
|
||||||
type="button"
|
<button
|
||||||
className="btn primary"
|
type="button"
|
||||||
disabled={expired || doctorUuid === '' || confirm.isPending}
|
className="btn primary"
|
||||||
onClick={async () => {
|
disabled={expired || rebook.isPending}
|
||||||
await confirm.mutateAsync({ hold_uuid: hold.hold_uuid, doctor_uuid: doctorUuid });
|
onClick={async () => {
|
||||||
navigate('/admin/appointments');
|
await rebook.mutateAsync({
|
||||||
}}
|
appointmentUuid: rebookUuid,
|
||||||
>
|
holdUuid: hold.hold_uuid,
|
||||||
ثبت نهایی نوبت
|
});
|
||||||
</button>
|
navigate(`/admin/appointments/${rebookUuid}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
جابهجایی به این زمان
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={expired || doctorUuid === '' || confirm.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
await confirm.mutateAsync({ hold_uuid: hold.hold_uuid, doctor_uuid: doctorUuid });
|
||||||
|
navigate('/admin/appointments');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ثبت نهایی نوبت
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ framework:
|
|||||||
'App\Sms\Message\SendSmsMessage': async
|
'App\Sms\Message\SendSmsMessage': async
|
||||||
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
|
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
|
||||||
'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default
|
'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default
|
||||||
|
'App\Shared\Event\Message\PublishDomainEventsMessage': scheduler_default
|
||||||
|
# مصرفکنندهٔ رویداد async است، وگرنه یک consumer کند خودِ تخلیهٔ صندوق را
|
||||||
|
# کند میکند و شکستش ردیفی را «ناموفق» علامت میزند که در واقع تحویل شده بود.
|
||||||
|
'App\Shared\Event\Message\DomainEventMessage': async
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -109,6 +109,42 @@ UNIQUE (resource_id, bucket_at, seat)
|
|||||||
جابهجایی: **اول** رزرو جدید، بعد آزادسازی قدیم. ترتیب عمدی است — اگر رزرو جدید شکست
|
جابهجایی: **اول** رزرو جدید، بعد آزادسازی قدیم. ترتیب عمدی است — اگر رزرو جدید شکست
|
||||||
بخورد، نوبت قدیمی دستنخورده میماند و بیمار بینوبت نمیشود.
|
بخورد، نوبت قدیمی دستنخورده میماند و بیمار بینوبت نمیشود.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "hold_uuid": "5e0e…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
پاسخ: `appointment_uuid` · `released_intervals` (تعداد اشغال آزادشدهٔ زمان قبلی) ·
|
||||||
|
`starts_at` (زمان تازه).
|
||||||
|
|
||||||
|
سه رویداد دامنه ثبت میشود، نه یکی: `AppointmentBooked`، `AppointmentCancelled` و
|
||||||
|
`AppointmentRescheduled`. سومی همان چیزی است که دو تای اول را به هم وصل میکند؛ بدون آن،
|
||||||
|
مصرفکنندهای که فقط لغو را میشنود برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
|
||||||
|
|
||||||
|
در پنل، همین مسیر با `/admin/resource-booking?rebook={uuid}` باز میشود: همان جستجو و
|
||||||
|
رزرو موقت، فقط گام آخرش جابهجایی است.
|
||||||
|
|
||||||
|
## `GET /api/v1/appointment/{uuid}/segments`
|
||||||
|
|
||||||
|
بخشهای **ثبتشدهٔ** نوبت — عکسِ لحظهٔ رزرو، نه الگوی امروزِ خدمت. تغییر بعدیِ الگو این
|
||||||
|
فهرست را عوض نمیکند.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "sequence": 1, "name": "بیحسی", "starts_at": 1811232000, "ends_at": 1811232300,
|
||||||
|
"duration_minutes": 5, "patient_present": true },
|
||||||
|
{ "sequence": 2, "name": "انتظار", "starts_at": 1811232300, "ends_at": 1811234100,
|
||||||
|
"duration_minutes": 30, "patient_present": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**فهرست خالی خطا نیست** و یعنی نوبت اسلاتی است. پنل با همین تفاوت تصمیم میگیرد دکمهٔ
|
||||||
|
جابهجایی منبعمحور را نشان بدهد یا نه.
|
||||||
|
|
||||||
|
**۴۰۴** روی نوبت محیط دیگر.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## اشغال: یک ردیف per (بخش × منبع)
|
## اشغال: یک ردیف per (بخش × منبع)
|
||||||
|
|||||||
@@ -908,6 +908,8 @@ New optional fields on `Appointment` (all backward-compatible): `service_section
|
|||||||
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
|
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
|
||||||
`pending → confirmed|following_up|cancelled_*|expired` · `confirmed → completed|following_up|salon|cancelled_*|no_show` · `following_up → confirmed|salon|completed|cancelled_*|no_show` · `salon → completed|following_up|cancelled_*|no_show`
|
`pending → confirmed|following_up|cancelled_*|expired` · `confirmed → completed|following_up|salon|cancelled_*|no_show` · `following_up → confirmed|salon|completed|cancelled_*|no_show` · `salon → completed|following_up|cancelled_*|no_show`
|
||||||
|
|
||||||
|
**Domain event on completion.** Reaching `completed` — through either `PATCH /appointment/{uuid}/status` or the general `PATCH /appointment/{uuid}` — records an `AppointmentCompleted` domain event in the outbox (see [domain-events.md](../architecture/domain-events.md)). It is recorded **after** the row is saved, so a rejected transition or a version conflict leaves no event; otherwise the completed count would run ahead of the appointments themselves.
|
||||||
|
|
||||||
### PATCH `/api/v1/appointment/{uuid}`
|
### PATCH `/api/v1/appointment/{uuid}`
|
||||||
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model) — the appointment's owning doctor, admin, the clinic owner / member doctor / assigned secretary of `appointment.clinic`. The patient is **not** allowed here (view + cancel only).
|
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model) — the appointment's owning doctor, admin, the clinic owner / member doctor / assigned secretary of `appointment.clinic`. The patient is **not** allowed here (view + cancel only).
|
||||||
|
|
||||||
|
|||||||
@@ -98,10 +98,15 @@
|
|||||||
## POST `/api/v1/appointment/{uuid}/cancel`
|
## POST `/api/v1/appointment/{uuid}/cancel`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "by": "doctor" }
|
{ "by": "doctor", "reason": "بیمار تماس گرفت" }
|
||||||
```
|
```
|
||||||
|
|
||||||
`by` اختیاری است؛ نبودنش یعنی لغو از سمت بیمار.
|
`by` اختیاری است؛ نبودنش یعنی لغو از سمت بیمار. `reason` هم اختیاری است و روی تایملاین
|
||||||
|
نوبت (`AppointmentEvent`) مینشیند — همان چیزی که اپراتور در صفحهٔ نوبت میبیند. بدون آن،
|
||||||
|
لغو از مسیر سیاست هیچ ردی در تاریخچهٔ نوبت نمیگذاشت.
|
||||||
|
|
||||||
|
پنل این اندپوینت را از دکمهٔ «لغو نوبت» صدا میزند و **پیش از تأیید** نتیجهٔ
|
||||||
|
`cancellation-preview` را نشان میدهد؛ عددی که اپراتور میبیند همان است که کسر میشود.
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -407,3 +407,7 @@ npx vitest run assets/admin/pages/ResourcesPage.test.tsx
|
|||||||
|
|
||||||
اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف **نمیشود** (`422`) — وگرنه
|
اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف **نمیشود** (`422`) — وگرنه
|
||||||
نوبت بیمار بیصدا منبعش را از دست میداد.
|
نوبت بیمار بیصدا منبعش را از دست میداد.
|
||||||
|
|
||||||
|
هر دو عمل رویداد دامنه ثبت میکنند: `ResourceBlocked` و `ResourceReleased`. ظرفیتی که
|
||||||
|
برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود؛ مصرفکنندهای که فقط اولی را
|
||||||
|
بشنود، منبع را برای همیشه اشغال میبیند.
|
||||||
|
|||||||
@@ -28,13 +28,30 @@
|
|||||||
| انتشار پیش از commit، بعد rollback | پیامک رفته، نوبتی وجود ندارد |
|
| انتشار پیش از commit، بعد rollback | پیامک رفته، نوبتی وجود ندارد |
|
||||||
| commit موفق، انتشار شکست خورد (Redis down) | نوبت هست، هیچکس مطلع نشد |
|
| commit موفق، انتشار شکست خورد (Redis down) | نوبت هست، هیچکس مطلع نشد |
|
||||||
|
|
||||||
با outbox ردیف رویداد **در همان تراکنش** ثبت میشود و یک worker بعداً منتشرش میکند:
|
با outbox ردیف رویداد **در همان تراکنش** ثبت میشود و بعداً منتشر میشود. حداکثر **تأخیر**
|
||||||
|
داریم، هرگز گمشدن.
|
||||||
|
|
||||||
```bash
|
### چه چیزی آن را در تولید اجرا میکند
|
||||||
ddev exec php bin/console app:events:publish --limit=100
|
|
||||||
```
|
|
||||||
|
|
||||||
حداکثر **تأخیر** داریم، هرگز گمشدن.
|
انتشار روی زمانبند نشسته است، نه cron جدا: `PublishDomainEventsMessage` هر دقیقه در
|
||||||
|
`src/Schedule.php` صادر میشود و `worker-scheduler` همان کانتینر همیشگی مصرفش میکند.
|
||||||
|
سرویس تازهای لازم نیست.
|
||||||
|
|
||||||
|
| لایه | چه میکند |
|
||||||
|
|---|---|
|
||||||
|
| `OutboxPublisher::publish()` | منطق واقعی؛ ردیفهای منتشرنشده را از جدول میخواند |
|
||||||
|
| `PublishDomainEventsHandler` | هر تیک زمانبند صدایش میزند |
|
||||||
|
| `app:events:publish --limit=100` | اجرای دستی، برای وقتی که صف عقب افتاده |
|
||||||
|
|
||||||
|
پیامِ زمانبند عمداً **بیداده** است: «چه چیزی منتشر شود» از جدول خوانده میشود نه از پیام،
|
||||||
|
وگرنه رویدادی که بین دو تیک ثبت شده جا میماند. و چون `Schedule` روی `stateful` است، تیکِ
|
||||||
|
ازدسترفته بعد از ریاستارت جبران میشود — یک اجرا کافی است تا همهٔ عقبماندگی برود.
|
||||||
|
|
||||||
|
`DomainEventMessage` به `async` میرود، نه `sync`: یک مصرفکنندهٔ کند وگرنه خودِ تخلیهٔ صندوق
|
||||||
|
را کند میکرد، و شکستش ردیفی را «ناموفق» علامت میزد که در واقع تحویل شده بود.
|
||||||
|
|
||||||
|
پاکسازی (`app:events:prune`) روی زمانبند **نیست** و باید cron جدا باشد؛ حذف داده تصمیمی است
|
||||||
|
که باید صریح و با پنجرهٔ نگهداریِ انتخابشده اجرا شود، نه اثر جانبیِ یک worker همیشهروشن.
|
||||||
|
|
||||||
`DomainEventPublisher::record()` عمداً flush نمیکند — همان چیزی که تضمین میکند رویداد
|
`DomainEventPublisher::record()` عمداً flush نمیکند — همان چیزی که تضمین میکند رویداد
|
||||||
با تراکنشِ برگشته از بین برود. جایی که فراخوان تراکنش باز ندارد، `recordAndFlush()` هست.
|
با تراکنشِ برگشته از بین برود. جایی که فراخوان تراکنش باز ندارد، `recordAndFlush()` هست.
|
||||||
@@ -90,10 +107,21 @@ CreditConsumed CreditRefunded
|
|||||||
| `CourseSessionCompleted` · `CourseCompleted` | `CourseSessionLinker::complete()` |
|
| `CourseSessionCompleted` · `CourseCompleted` | `CourseSessionLinker::complete()` |
|
||||||
| `PackagePurchased` | `PackageSalesService::sell()` |
|
| `PackagePurchased` | `PackageSalesService::sell()` |
|
||||||
| `CreditConsumed` · `CreditRefunded` | `CreditLedgerService` |
|
| `CreditConsumed` · `CreditRefunded` | `CreditLedgerService` |
|
||||||
|
| `AppointmentRescheduled` | `BookingController::rebook()` |
|
||||||
|
| `AppointmentCompleted` | `AppointmentController` — هر دو مسیر تغییر وضعیت |
|
||||||
|
| `ResourceBlocked` · `ResourceReleased` | `ResourceBlockController` |
|
||||||
|
|
||||||
`AppointmentRescheduled`، `AppointmentCompleted`، `ResourceBlocked` و `ResourceReleased`
|
هر چهارده رویداد نقطهٔ ثبت دارند.
|
||||||
هنوز نقطهٔ ثبت ندارند: مسیرهایشان (جابهجایی نوبت، تکمیل دستی، بلوک منبع) از تسکهای
|
|
||||||
قبلیاند و دستزدن به آنها بیرون از دامنهٔ این تسک بود.
|
دو نکته که موقع خواندن این جدول بهدرد میخورند:
|
||||||
|
|
||||||
|
- **`AppointmentRescheduled` سومین رویداد است، نه جایگزین.** جابهجایی از درون یک `confirm`
|
||||||
|
و یک `cancel` است و هر کدام رویداد خودشان را میگذارند. مصرفکنندهای که فقط
|
||||||
|
`AppointmentCancelled` را بشنود، برای بیماری که هنوز نوبت دارد پیام لغو میفرستد؛ این
|
||||||
|
رویداد همان چیزی است که آن دو را به هم وصل میکند.
|
||||||
|
- **`AppointmentCompleted` بعد از ذخیرهٔ موفق ثبت میشود، نه هنگام درخواست.** انتقالی که
|
||||||
|
`canTransitionTo` رد میکند یا `saveWithLock` روی تداخل نسخه میشکند، هیچ رویدادی
|
||||||
|
نمیگذارد — وگرنه شمارِ «انجامشده» از خودِ نوبتها جلو میزند.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
| سرویس | نقش | نکته |
|
| سرویس | نقش | نکته |
|
||||||
|-------|-----|------|
|
|-------|-----|------|
|
||||||
| `app` | وب (PHP-FPM + Nginx) | دامنه به این سرویس اختصاص مییابد (پورت ۸۰). `RUN_INIT=1` → migration و تولید کلید JWT |
|
| `app` | وب (PHP-FPM + Nginx) | دامنه به این سرویس اختصاص مییابد (پورت ۸۰). `RUN_INIT=1` → migration و تولید کلید JWT |
|
||||||
| `worker-async` | مصرف صف `async` (ارسال SMS) | `RUN_INIT=0` |
|
| `worker-async` | مصرف صف `async` (ارسال SMS، مصرفکنندهٔ رویدادهای دامنه) | `RUN_INIT=0` |
|
||||||
| `worker-scheduler` | مصرف `scheduler_default` (انقضای نوبتهای پرداختنشده، هر دقیقه) | `RUN_INIT=0` |
|
| `worker-scheduler` | مصرف `scheduler_default` (انقضای نوبتها، انتشار صندوق خروجی رویدادها، هر دقیقه) | `RUN_INIT=0` |
|
||||||
| `mariadb` | دیتابیس MariaDB 11.8 | healthcheck دارد؛ سرویسهای اپ منتظر سالمشدن آن میمانند |
|
| `mariadb` | دیتابیس MariaDB 11.8 | healthcheck دارد؛ سرویسهای اپ منتظر سالمشدن آن میمانند |
|
||||||
| `redis` | Messenger transport + کش/OTP | با appendonly persist میشود |
|
| `redis` | Messenger transport + کش/OTP | با appendonly persist میشود |
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use App\Resource\Entity\ClinicResource;
|
|||||||
use App\Resource\Repository\ClinicResourceRepository;
|
use App\Resource\Repository\ClinicResourceRepository;
|
||||||
use App\Shared\Constant\ErrorCodes;
|
use App\Shared\Constant\ErrorCodes;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
|
use App\Shared\Event\DomainEventPublisher;
|
||||||
|
use App\Shared\Event\DomainEvents;
|
||||||
use App\Shared\Exception\AppException;
|
use App\Shared\Exception\AppException;
|
||||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -43,6 +45,7 @@ class ResourceBlockController extends BaseController
|
|||||||
private readonly ResourceOccupancyRepository $occupancy,
|
private readonly ResourceOccupancyRepository $occupancy,
|
||||||
private readonly BranchResolver $branches,
|
private readonly BranchResolver $branches,
|
||||||
private readonly TenantOwnershipChecker $ownership,
|
private readonly TenantOwnershipChecker $ownership,
|
||||||
|
private readonly DomainEventPublisher $domainEvents,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -95,6 +98,19 @@ class ResourceBlockController extends BaseController
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->em->persist($block);
|
$this->em->persist($block);
|
||||||
|
|
||||||
|
$this->domainEvents->record(
|
||||||
|
$resource->getEntityType(),
|
||||||
|
$resource->getEntityId(),
|
||||||
|
DomainEvents::RESOURCE_BLOCKED,
|
||||||
|
[
|
||||||
|
'resource_uuid' => $resource->getUuid(),
|
||||||
|
'block_uuid' => $block->getUuid(),
|
||||||
|
'starts_at' => $startsAt,
|
||||||
|
'ends_at' => $endsAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $this->success($block->toArray(), 201);
|
return $this->success($block->toArray(), 201);
|
||||||
@@ -120,6 +136,19 @@ class ResourceBlockController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// پیش از `remove` ثبت میشود چون بعد از آن، uuid و بازه فقط در حافظهاند و
|
||||||
|
// خواندنشان از یک entity حذفشده به رفتار Doctrine وابسته میماند.
|
||||||
|
$this->domainEvents->record(
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
DomainEvents::RESOURCE_RELEASED,
|
||||||
|
[
|
||||||
|
'block_uuid' => $block->getUuid(),
|
||||||
|
'starts_at' => $block->getStartsAt(),
|
||||||
|
'ends_at' => $block->getEndsAt(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$this->em->remove($block);
|
$this->em->remove($block);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Appointment\Booking\Controller;
|
namespace App\Appointment\Booking\Controller;
|
||||||
|
|
||||||
use App\Appointment\Booking\Entity\AppointmentHold;
|
use App\Appointment\Booking\Entity\AppointmentHold;
|
||||||
|
use App\Appointment\Booking\Entity\AppointmentSegment;
|
||||||
use App\Appointment\Booking\Repository\AppointmentHoldRepository;
|
use App\Appointment\Booking\Repository\AppointmentHoldRepository;
|
||||||
|
use App\Appointment\Booking\Repository\AppointmentSegmentRepository;
|
||||||
use App\Appointment\Booking\Service\BookingService;
|
use App\Appointment\Booking\Service\BookingService;
|
||||||
use App\Appointment\Booking\Service\HoldService;
|
use App\Appointment\Booking\Service\HoldService;
|
||||||
use App\Appointment\Entity\Appointment;
|
use App\Appointment\Entity\Appointment;
|
||||||
@@ -23,6 +25,8 @@ use App\Resource\Entity\ClinicResource;
|
|||||||
use App\Resource\Repository\ClinicResourceRepository;
|
use App\Resource\Repository\ClinicResourceRepository;
|
||||||
use App\Shared\Constant\ErrorCodes;
|
use App\Shared\Constant\ErrorCodes;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
|
use App\Shared\Event\DomainEventPublisher;
|
||||||
|
use App\Shared\Event\DomainEvents;
|
||||||
use App\Shared\Exception\AppException;
|
use App\Shared\Exception\AppException;
|
||||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -55,6 +59,8 @@ class BookingController extends BaseController
|
|||||||
private readonly BookingPolicyGuard $guard,
|
private readonly BookingPolicyGuard $guard,
|
||||||
private readonly PackageConsumptionService $packages,
|
private readonly PackageConsumptionService $packages,
|
||||||
private readonly TenantOwnershipChecker $ownership,
|
private readonly TenantOwnershipChecker $ownership,
|
||||||
|
private readonly AppointmentSegmentRepository $segments,
|
||||||
|
private readonly DomainEventPublisher $domainEvents,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -200,6 +206,29 @@ class BookingController extends BaseController
|
|||||||
* ترتیب عمدی است — اگر رزرو جدید شکست بخورد، نوبت قدیمی دستنخورده میماند و
|
* ترتیب عمدی است — اگر رزرو جدید شکست بخورد، نوبت قدیمی دستنخورده میماند و
|
||||||
* بیمار بینوبت نمیشود. ترتیب برعکس، در بدترین حالت هر دو را از دست میداد.
|
* بیمار بینوبت نمیشود. ترتیب برعکس، در بدترین حالت هر دو را از دست میداد.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* بخشهای ثبتشدهٔ یک نوبت — عکسِ لحظهٔ رزرو، نه الگوی امروزِ خدمت.
|
||||||
|
*
|
||||||
|
* فهرست خالی یعنی نوبت اسلاتی است؛ خطا نیست. پنل با همین تفاوت میفهمد کدام نوبت
|
||||||
|
* را میشود منبعمحور جابهجا کرد.
|
||||||
|
*/
|
||||||
|
#[Route('/api/v1/appointment/{uuid}/segments', name: 'appointment_segments', methods: ['GET'])]
|
||||||
|
public function segments(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
|
||||||
|
|
||||||
|
[$entityType, $entityId] = $this->branches->pair($user);
|
||||||
|
|
||||||
|
if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) {
|
||||||
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success(array_map(
|
||||||
|
static fn (AppointmentSegment $s): array => $s->toArray(),
|
||||||
|
$this->segments->findForAppointment($appointment),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/v1/appointment/{uuid}/rebook', name: 'appointment_rebook', methods: ['POST'])]
|
#[Route('/api/v1/appointment/{uuid}/rebook', name: 'appointment_rebook', methods: ['POST'])]
|
||||||
public function rebook(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
public function rebook(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -220,10 +249,26 @@ class BookingController extends BaseController
|
|||||||
|
|
||||||
$hold = $this->requireHold($user, $data['hold_uuid']);
|
$hold = $this->requireHold($user, $data['hold_uuid']);
|
||||||
|
|
||||||
|
$previousStart = $appointment->getSlotStart();
|
||||||
|
|
||||||
// رزرو جدید از قبل گرفته شده؛ اینجا فقط تأیید و سپس آزادسازی قدیم.
|
// رزرو جدید از قبل گرفته شده؛ اینجا فقط تأیید و سپس آزادسازی قدیم.
|
||||||
$this->booking->confirm($hold, $appointment);
|
$this->booking->confirm($hold, $appointment);
|
||||||
$released = $this->booking->cancel($appointment);
|
$released = $this->booking->cancel($appointment);
|
||||||
|
|
||||||
|
// `confirm` و `cancel` هرکدام رویداد خودشان را ثبت کردهاند؛ این سومی میگوید آن دو
|
||||||
|
// یک جابهجایی بودهاند نه یک لغو و یک رزروِ بیربط. مصرفکنندهای که فقط
|
||||||
|
// `AppointmentCancelled` را بشنود، برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
|
||||||
|
$this->domainEvents->recordAndFlush(
|
||||||
|
$appointment->getEntityType(),
|
||||||
|
$appointment->getEntityId(),
|
||||||
|
DomainEvents::APPOINTMENT_RESCHEDULED,
|
||||||
|
[
|
||||||
|
'appointment_uuid' => $appointment->getUuid(),
|
||||||
|
'previous_start' => $previousStart,
|
||||||
|
'new_start' => $hold->getStartsAt(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'appointment_uuid' => $appointment->getUuid(),
|
'appointment_uuid' => $appointment->getUuid(),
|
||||||
'released_intervals' => $released,
|
'released_intervals' => $released,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class AppointmentController extends BaseController
|
|||||||
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
|
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
|
||||||
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
|
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
|
||||||
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
|
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
|
||||||
|
private readonly \App\Shared\Event\DomainEventPublisher $domainEvents,
|
||||||
private readonly \Psr\Log\LoggerInterface $logger,
|
private readonly \Psr\Log\LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -72,6 +73,27 @@ class AppointmentController extends BaseController
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ثبت رویداد دامنهٔ «نوبت انجام شد».
|
||||||
|
*
|
||||||
|
* جدا از `AppointmentEvent` است و جایگزینش نمیشود: آن، تایملاینِ خواندهشده توسط
|
||||||
|
* اپراتور است و این، صندوق خروجی برای مصرفکنندههای بیرونی. هر دو مسیرِ تغییر
|
||||||
|
* وضعیت (اندپوینت اختصاصی و `PATCH`) بعد از ذخیرهٔ موفق به اینجا میرسند، چون
|
||||||
|
* رویدادِ کاری که هنوز ذخیره نشده، دروغ است.
|
||||||
|
*/
|
||||||
|
private function recordCompletion(Appointment $appointment): void
|
||||||
|
{
|
||||||
|
$this->domainEvents->recordAndFlush(
|
||||||
|
$appointment->getEntityType(),
|
||||||
|
$appointment->getEntityId(),
|
||||||
|
\App\Shared\Event\DomainEvents::APPOINTMENT_COMPLETED,
|
||||||
|
[
|
||||||
|
'appointment_uuid' => $appointment->getUuid(),
|
||||||
|
'slot_start' => $appointment->getSlotStart(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Public: available slots ───────────────────────────────────────────────
|
// ── Public: available slots ───────────────────────────────────────────────
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
@@ -970,6 +992,10 @@ class AppointmentController extends BaseController
|
|||||||
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
|
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($newStatus === Appointment::STATUS_COMPLETED) {
|
||||||
|
$this->recordCompletion($appointment);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success(['data' => $appointment->toArray()]);
|
return $this->success(['data' => $appointment->toArray()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,6 +1237,7 @@ class AppointmentController extends BaseController
|
|||||||
// Optional status transition, same rules as the dedicated endpoint.
|
// Optional status transition, same rules as the dedicated endpoint.
|
||||||
$newStatus = trim((string) ($data['status'] ?? ''));
|
$newStatus = trim((string) ($data['status'] ?? ''));
|
||||||
$cancelledTo = null;
|
$cancelledTo = null;
|
||||||
|
$completed = false;
|
||||||
if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) {
|
if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) {
|
||||||
if (!$appointment->canTransitionTo($newStatus)) {
|
if (!$appointment->canTransitionTo($newStatus)) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
||||||
@@ -1224,6 +1251,7 @@ class AppointmentController extends BaseController
|
|||||||
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
|
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
|
||||||
$cancelledTo = $newStatus;
|
$cancelledTo = $newStatus;
|
||||||
}
|
}
|
||||||
|
$completed = $newStatus === Appointment::STATUS_COMPLETED;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1240,6 +1268,10 @@ class AppointmentController extends BaseController
|
|||||||
$this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user);
|
$this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($completed) {
|
||||||
|
$this->recordCompletion($appointment);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success(['data' => $appointment->toArray()]);
|
return $this->success(['data' => $appointment->toArray()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,11 @@ class CancellationController extends BaseController
|
|||||||
? Appointment::STATUS_CANCELLED_BY_DOCTOR
|
? Appointment::STATUS_CANCELLED_BY_DOCTOR
|
||||||
: Appointment::STATUS_CANCELLED_BY_USER;
|
: Appointment::STATUS_CANCELLED_BY_USER;
|
||||||
|
|
||||||
return $this->success($this->cancellation->cancel($appointment, $by, $user));
|
$reason = is_array($data) && is_string($data['reason'] ?? null) && trim($data['reason']) !== ''
|
||||||
|
? trim($data['reason'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->success($this->cancellation->cancel($appointment, $by, $user, null, $reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ثبت عدم حضور — برچسب پرریسک اگر آستانه رد شود. */
|
/** ثبت عدم حضور — برچسب پرریسک اگر آستانه رد شود. */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Cancellation\Service;
|
|||||||
|
|
||||||
use App\Appointment\Booking\Service\BookingService;
|
use App\Appointment\Booking\Service\BookingService;
|
||||||
use App\Appointment\Entity\Appointment;
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Appointment\Entity\AppointmentEvent;
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
use App\Cancellation\ValueObject\PenaltyResult;
|
use App\Cancellation\ValueObject\PenaltyResult;
|
||||||
use App\Package\Service\CreditLedgerService;
|
use App\Package\Service\CreditLedgerService;
|
||||||
@@ -35,7 +36,7 @@ final class CancellationService
|
|||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
* @throws AppException ۴۲۲ روی نوبت گذشته، ۴۰۹ روی نوبتِ از قبل لغوشده
|
* @throws AppException ۴۲۲ روی نوبت گذشته، ۴۰۹ روی نوبتِ از قبل لغوشده
|
||||||
*/
|
*/
|
||||||
public function cancel(Appointment $appointment, string $status, ?User $actor = null, ?int $now = null): array
|
public function cancel(Appointment $appointment, string $status, ?User $actor = null, ?int $now = null, ?string $reason = null): array
|
||||||
{
|
{
|
||||||
$now = $now ?? time();
|
$now = $now ?? time();
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ final class CancellationService
|
|||||||
|
|
||||||
$notified = $this->waitlist->notifyForFreedSlot($appointment);
|
$notified = $this->waitlist->notifyForFreedSlot($appointment);
|
||||||
|
|
||||||
|
$this->recordTimelineEntry($appointment, $actor, $reason);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'appointment_uuid' => $appointment->getUuid(),
|
'appointment_uuid' => $appointment->getUuid(),
|
||||||
'status' => $appointment->getStatus(),
|
'status' => $appointment->getStatus(),
|
||||||
@@ -77,6 +80,26 @@ final class CancellationService
|
|||||||
] + $penalty->toArray();
|
] + $penalty->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ردیف تایملاین — همان چیزی که اپراتور در صفحهٔ نوبت میبیند.
|
||||||
|
*
|
||||||
|
* جدا از رویداد دامنه است و جایگزینش نمیشود: آن برای مصرفکنندهٔ بیرونی است و این
|
||||||
|
* برای آدمی که میخواهد بداند چه کسی و چرا لغو کرد. بدون این، لغو از مسیر سیاست
|
||||||
|
* هیچ ردی در تاریخچهٔ نوبت نمیگذاشت.
|
||||||
|
*/
|
||||||
|
private function recordTimelineEntry(Appointment $appointment, ?User $actor, ?string $reason): void
|
||||||
|
{
|
||||||
|
$event = new AppointmentEvent($appointment, AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد');
|
||||||
|
$event->setReason($reason);
|
||||||
|
|
||||||
|
if ($actor !== null) {
|
||||||
|
$event->setActor($actor->getId(), $actor->getRealName() ?: $actor->getMobileNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($event);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* جریمه از کیف پول کسر میشود، و اگر موجودی نبود **کسر نمیشود**.
|
* جریمه از کیف پول کسر میشود، و اگر موجودی نبود **کسر نمیشود**.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App;
|
|||||||
|
|
||||||
use App\Appointment\Message\ExpireAppointmentsMessage;
|
use App\Appointment\Message\ExpireAppointmentsMessage;
|
||||||
use App\Blog\Message\PublishScheduledBlogsMessage;
|
use App\Blog\Message\PublishScheduledBlogsMessage;
|
||||||
|
use App\Shared\Event\Message\PublishDomainEventsMessage;
|
||||||
use App\Shared\Logging\Message\PruneLogsMessage;
|
use App\Shared\Logging\Message\PruneLogsMessage;
|
||||||
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||||
use Symfony\Component\Scheduler\RecurringMessage;
|
use Symfony\Component\Scheduler\RecurringMessage;
|
||||||
@@ -32,6 +33,12 @@ class Schedule implements ScheduleProviderInterface
|
|||||||
)
|
)
|
||||||
->add(
|
->add(
|
||||||
RecurringMessage::every('1 minute', new PublishScheduledBlogsMessage())
|
RecurringMessage::every('1 minute', new PublishScheduledBlogsMessage())
|
||||||
|
)
|
||||||
|
// صندوق خروجی رویدادها. `stateful` بالا یعنی تیکِ ازدسترفته بعد از ریاستارت
|
||||||
|
// جبران میشود، و چون خودِ publisher از جدول میخواند، یک اجرا کافی است تا
|
||||||
|
// هرچه در فاصله جمع شده برود.
|
||||||
|
->add(
|
||||||
|
RecurringMessage::every('1 minute', new PublishDomainEventsMessage())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,26 @@ namespace App\Shared\Event\Command;
|
|||||||
|
|
||||||
use App\Shared\Event\Entity\DomainEventLog;
|
use App\Shared\Event\Entity\DomainEventLog;
|
||||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use App\Shared\Event\Service\OutboxPublisher;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* انتشار صندوق خروجی: ردیفهای `published_at IS NULL` به messenger میروند.
|
* اجرای دستیِ انتشار صندوق خروجی.
|
||||||
*
|
*
|
||||||
* شکست انتشار ردیف را نمیکشد؛ `attempts` بالا میرود و خطا ثبت میشود. بعد از سقف
|
* منطقش در `OutboxPublisher` است چون زمانبند هم همان را هر دقیقه صدا میزند؛ این دستور
|
||||||
* تلاش، ردیف با خطایش باقی میماند تا ادمین ببیند — حذف خاموش یعنی رویداد گمشدهٔ بیرد.
|
* برای وقتی میماند که صف عقب افتاده و باید همین حالا تخلیه شود.
|
||||||
*/
|
*/
|
||||||
#[AsCommand(name: 'app:events:publish', description: 'Publish pending domain events from the outbox.')]
|
#[AsCommand(name: 'app:events:publish', description: 'Publish pending domain events from the outbox.')]
|
||||||
class PublishDomainEventsCommand extends Command
|
class PublishDomainEventsCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly OutboxPublisher $publisher,
|
||||||
private readonly DomainEventLogRepository $events,
|
private readonly DomainEventLogRepository $events,
|
||||||
private readonly MessageBusInterface $bus,
|
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -37,36 +35,10 @@ class PublishDomainEventsCommand extends Command
|
|||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$pending = $this->events->findPending(max(1, (int) $input->getOption('limit')));
|
$result = $this->publisher->publish((int) $input->getOption('limit'));
|
||||||
|
|
||||||
$published = 0;
|
$io->success(sprintf('%d رویداد منتشر شد، %d ناموفق.', $result['published'], $result['failed']));
|
||||||
$failed = 0;
|
|
||||||
|
|
||||||
foreach ($pending as $event) {
|
|
||||||
try {
|
|
||||||
$this->bus->dispatch(new \App\Shared\Event\Message\DomainEventMessage(
|
|
||||||
$event->getUuid(),
|
|
||||||
$event->getName(),
|
|
||||||
$event->getEntityType(),
|
|
||||||
$event->getEntityId(),
|
|
||||||
$event->getPayload(),
|
|
||||||
$event->getOccurredAt(),
|
|
||||||
));
|
|
||||||
|
|
||||||
$event->markPublished();
|
|
||||||
$published++;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$event->markFailed($e->getMessage());
|
|
||||||
$failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pending !== []) {
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->success(sprintf('%d رویداد منتشر شد، %d ناموفق.', $published, $failed));
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Shared\Event\Message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پیام نشانهای که زمانبند هر دقیقه میفرستد تا صندوق خروجی تخلیه شود.
|
||||||
|
*
|
||||||
|
* خودش داده ندارد: «چه چیزی منتشر شود» را `OutboxPublisher` از جدول میخواند، نه از
|
||||||
|
* پیام — وگرنه رویدادی که بین دو تیکِ زمانبند ثبت شده جا میماند.
|
||||||
|
*/
|
||||||
|
final class PublishDomainEventsMessage
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Shared\Event\MessageHandler;
|
||||||
|
|
||||||
|
use App\Shared\Event\Message\PublishDomainEventsMessage;
|
||||||
|
use App\Shared\Event\Service\OutboxPublisher;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
final class PublishDomainEventsHandler
|
||||||
|
{
|
||||||
|
public function __construct(private readonly OutboxPublisher $publisher) {}
|
||||||
|
|
||||||
|
public function __invoke(PublishDomainEventsMessage $message): void
|
||||||
|
{
|
||||||
|
$this->publisher->publish();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Shared\Event\Service;
|
||||||
|
|
||||||
|
use App\Shared\Event\Message\DomainEventMessage;
|
||||||
|
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تخلیهٔ صندوق خروجی — ردیفهای `published_at IS NULL` به messenger میروند.
|
||||||
|
*
|
||||||
|
* منطق اینجاست نه در Command، چون دو فراخوان دارد: دستور دستی برای وقتی که صف عقب
|
||||||
|
* افتاده، و زمانبند برای اجرای همیشگی. اگر در Command میماند، زمانبند مجبور بود
|
||||||
|
* پروسهٔ کنسول اجرا کند و خطاهایش را از exit code حدس بزند.
|
||||||
|
*/
|
||||||
|
final class OutboxPublisher
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DomainEventLogRepository $events,
|
||||||
|
private readonly MessageBusInterface $bus,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شکستِ یک ردیف بقیه را متوقف نمیکند؛ `attempts` بالا میرود و خطا روی خودِ ردیف
|
||||||
|
* مینشیند تا بعد از سقف تلاش، با دلیلش قابل دیدن بماند.
|
||||||
|
*
|
||||||
|
* @return array{published: int, failed: int}
|
||||||
|
*/
|
||||||
|
public function publish(int $limit = 100): array
|
||||||
|
{
|
||||||
|
$pending = $this->events->findPending(max(1, $limit));
|
||||||
|
$published = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($pending as $event) {
|
||||||
|
try {
|
||||||
|
$this->bus->dispatch(new DomainEventMessage(
|
||||||
|
$event->getUuid(),
|
||||||
|
$event->getName(),
|
||||||
|
$event->getEntityType(),
|
||||||
|
$event->getEntityId(),
|
||||||
|
$event->getPayload(),
|
||||||
|
$event->getOccurredAt(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$event->markPublished();
|
||||||
|
$published++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$event->markFailed($e->getMessage());
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pending !== []) {
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['published' => $published, 'failed' => $failed];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -478,4 +478,44 @@ class HoldAndBookTest extends ApiTestCase
|
|||||||
|
|
||||||
self::assertSame([], $roomRows, 'اتاق نباید از رزروِ شکستخورده قفل بماند');
|
self::assertSame([], $roomRows, 'اتاق نباید از رزروِ شکستخورده قفل بماند');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جابهجایی از بیرون شبیه «لغو + رزرو» است، ولی برای بیمار یک اتفاق است.
|
||||||
|
*
|
||||||
|
* رویداد `AppointmentRescheduled` همین را میگوید؛ بدون آن، مصرفکنندهای که فقط
|
||||||
|
* `AppointmentCancelled` را میشنود برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
|
||||||
|
*/
|
||||||
|
public function testRebookingMovesTheAppointmentAndRecordsTheEvent(): void
|
||||||
|
{
|
||||||
|
$s = $this->simpleSetup();
|
||||||
|
$start = $this->nextSaturdayAt(14);
|
||||||
|
|
||||||
|
$first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
|
||||||
|
$book = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
|
||||||
|
'hold_uuid' => $first['data']['hold_uuid'],
|
||||||
|
'doctor_uuid' => $s['doctor']->getUuid(),
|
||||||
|
]);
|
||||||
|
self::assertSame(200, $this->responseCode(), json_encode($book, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$newStart = $start + 2 * 3600;
|
||||||
|
$second = $this->hold($s['user'], $s['service'], $s['address'], $newStart, ['room' => [$s['room']['uuid']]]);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
|
||||||
|
$moved = $this->authJson(
|
||||||
|
'POST',
|
||||||
|
"/api/v1/appointment/{$book['data']['appointment_uuid']}/rebook",
|
||||||
|
$s['user'],
|
||||||
|
['hold_uuid' => $second['data']['hold_uuid']],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $this->responseCode(), json_encode($moved, JSON_UNESCAPED_UNICODE));
|
||||||
|
self::assertSame($newStart, $moved['data']['starts_at']);
|
||||||
|
|
||||||
|
$event = $this->em->getRepository(\App\Shared\Event\Entity\DomainEventLog::class)
|
||||||
|
->findOneBy(['name' => \App\Shared\Event\DomainEvents::APPOINTMENT_RESCHEDULED], ['id' => 'DESC']);
|
||||||
|
|
||||||
|
self::assertNotNull($event);
|
||||||
|
self::assertSame($start, $event->getPayload()['previous_start']);
|
||||||
|
self::assertSame($newStart, $event->getPayload()['new_start']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\Clinic\Entity\Clinic;
|
|||||||
use App\Doctor\Entity\DoctorAddress;
|
use App\Doctor\Entity\DoctorAddress;
|
||||||
use App\Resource\Entity\ClinicResource;
|
use App\Resource\Entity\ClinicResource;
|
||||||
use App\Resource\Entity\ResourceType;
|
use App\Resource\Entity\ResourceType;
|
||||||
|
use App\Shared\Event\DomainEvents;
|
||||||
|
use App\Shared\Event\Entity\DomainEventLog;
|
||||||
use App\Tests\ApiTestCase;
|
use App\Tests\ApiTestCase;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
@@ -161,4 +163,40 @@ class ResourceBlockTest extends ApiTestCase
|
|||||||
|
|
||||||
self::assertSame(404, $this->responseCode());
|
self::assertSame(404, $this->responseCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ظرفیتی که برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود: مصرفکنندهای که
|
||||||
|
* فقط مسدودسازی را بشنود، منبع را برای همیشه اشغال میبیند.
|
||||||
|
*/
|
||||||
|
public function testBlockingAndReleasingEachRecordADomainEvent(): void
|
||||||
|
{
|
||||||
|
[$user, $resource] = $this->clinicWithResource();
|
||||||
|
|
||||||
|
$start = time() + 86400;
|
||||||
|
|
||||||
|
$created = $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [
|
||||||
|
'starts_at' => $start,
|
||||||
|
'ends_at' => $start + 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
|
||||||
|
$blocked = $this->latestEvent(DomainEvents::RESOURCE_BLOCKED);
|
||||||
|
self::assertNotNull($blocked);
|
||||||
|
self::assertSame($resource->getUuid(), $blocked->getPayload()['resource_uuid']);
|
||||||
|
self::assertSame($start, $blocked->getPayload()['starts_at']);
|
||||||
|
|
||||||
|
$this->authJson('DELETE', "/api/v1/resource-block/{$created['data']['uuid']}", $user);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$released = $this->latestEvent(DomainEvents::RESOURCE_RELEASED);
|
||||||
|
self::assertNotNull($released);
|
||||||
|
self::assertSame($created['data']['uuid'], $released->getPayload()['block_uuid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestEvent(string $name): ?DomainEventLog
|
||||||
|
{
|
||||||
|
return $this->em->getRepository(DomainEventLog::class)
|
||||||
|
->findOneBy(['name' => $name], ['id' => 'DESC']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Report;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Shared\Event\DomainEvents;
|
||||||
|
use App\Shared\Event\Entity\DomainEventLog;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دو رویدادی که مسیرشان از تسکهای قدیمیتر میآید: «نوبت انجام شد» و «نوبت جابهجا شد».
|
||||||
|
*
|
||||||
|
* هر دو از مسیرِ وضعیتِ موجود عبور میکنند، پس چیزی که این تستها نگه میدارند این است
|
||||||
|
* که رویداد **بعد از ذخیرهٔ موفق** ثبت شود — نه هنگام درخواستِ تغییر وضعیت.
|
||||||
|
*/
|
||||||
|
class AppointmentLifecycleEventTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
private function makeDoctor(): Doctor
|
||||||
|
{
|
||||||
|
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر رویداد');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $doctor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latest(string $name): ?DomainEventLog
|
||||||
|
{
|
||||||
|
return $this->em->getRepository(DomainEventLog::class)
|
||||||
|
->findOneBy(['name' => $name], ['id' => 'DESC']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompletingAnAppointmentRecordsTheEvent(): void
|
||||||
|
{
|
||||||
|
$doctor = $this->makeDoctor();
|
||||||
|
$start = time() + 86_400;
|
||||||
|
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
|
||||||
|
$this->em->persist($appointment);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
|
||||||
|
'status' => Appointment::STATUS_CONFIRMED,
|
||||||
|
'version' => $appointment->getVersion(),
|
||||||
|
]);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
$reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $appointment->getUuid()]);
|
||||||
|
|
||||||
|
$this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}/status", $doctor->getUser(), [
|
||||||
|
'status' => Appointment::STATUS_COMPLETED,
|
||||||
|
'version' => $reloaded->getVersion(),
|
||||||
|
]);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$event = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
|
||||||
|
self::assertNotNull($event);
|
||||||
|
self::assertSame($appointment->getUuid(), $event->getPayload()['appointment_uuid']);
|
||||||
|
self::assertSame($start, $event->getPayload()['slot_start']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتقال ردشده نباید رویداد بگذارد؛ وگرنه گزارش «انجامشده»ها از خودِ نوبتها جلو میزند.
|
||||||
|
*/
|
||||||
|
public function testARejectedTransitionRecordsNothing(): void
|
||||||
|
{
|
||||||
|
$doctor = $this->makeDoctor();
|
||||||
|
$start = time() + 86_400;
|
||||||
|
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
|
||||||
|
$this->em->persist($appointment);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$before = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
|
||||||
|
|
||||||
|
// `pending → completed` در جدول انتقالها نیست.
|
||||||
|
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
|
||||||
|
'status' => Appointment::STATUS_COMPLETED,
|
||||||
|
'version' => $appointment->getVersion(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
|
||||||
|
$after = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
|
||||||
|
self::assertSame($before?->getId(), $after?->getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user