Lab 07
Інтерфейси
IPayable, ICancellable, ISchedulable
Лаба 07 — Інтерфейси
Мета
Навчитися оголошувати інтерфейси, реалізовувати їх у класах (зокрема кілька в одному), писати код, що залежить від контракту, а не від конкретного класу, і розрізняти, коли потрібен інтерфейс, а коли — абстрактний клас.
Контекст
Після Лаби 06 система вміє зберігати медичні записи. Але оплата прийомів досі не відстежується: немає поняття «оплачено / не оплачено», немає суми, а «скасовність» запису — просто метод, про який ніхто зовні нічого не знає наперед. Ця лаба додає фінансовий блок: три інтерфейси — IPayable, ICancellable, ISchedulable — та новий розділ меню «Рахунки».
Ключове питання лаби: як написати код, який працює з будь-чим, що можна оплатити, не знаючи, що це — прийом, рецепт чи щось, чого ще не існує? Відповідь — інтерфейс.
Структура проєкту на початку лаби
Це результат Лаби 06 — стан main після її злиття:
oop-course/ ← гілка main (після злиття Лаби 06)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs
├── Clinic.cs
├── Enums/
│ ├── AppointmentStatus.cs
│ ├── BloodType.cs
│ └── Speciality.cs
├── Models/
│ ├── Patient.cs
│ ├── Doctor.cs
│ ├── Appointment.cs
│ ├── WorkSchedule.cs
│ ├── Diagnosis.cs
│ ├── LabResult.cs
│ ├── MedicalRecord.cs
│ └── Prescription.cs
├── Managers/
│ ├── PatientManager.cs
│ ├── DoctorManager.cs
│ ├── AppointmentManager.cs
│ ├── GrowablePatientManager.cs
│ └── MedicalRecordManager.cs
└── Utils/
├── ClinicFormatter.cs
└── ClinicValidator.csСтруктуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.
Що таке інтерфейс
Інтерфейс — це контракт: перелік членів (методів і властивостей) без реалізації. Клас, що підписав контракт, зобов'язаний реалізувати кожен його член. Ім'я інтерфейсу за домовленістю починається з I: IPayable, ICancellable.
Що можна й чого не можна. В інтерфейсі оголошують методи та властивості (лише «сигнатури», тіла немає). Полів і конструкторів в інтерфейсі немає, а створити об'єкт типу інтерфейсу через new неможливо. Усі члени інтерфейсу — public за визначенням, тож і реалізація в класі мусить бути public.
Що це дає. Змінна, параметр чи масив можуть мати тип інтерфейсу. Такий код працює з будь-яким класом, що підписав контракт, — і з тими, які з'являться в майбутньому. Через змінну типу інтерфейсу видно лише члени контракту: решта можливостей об'єкта для цього коду не існує.
Кілька контрактів. Клас може успадковувати лише один клас, але реалізувати скільки завгодно інтерфейсів: class Appointment : IPayable, ICancellable. Обмеження на класи пов'язане зі станом і реалізацією: два батьківські класи могли б мати різні поля й різні версії одного методу — виникла б неоднозначність. Інтерфейс не має стану, тож такої проблеми немає.
Інтерфейс чи абстрактний клас? Порівняйте з Лабою 06:
abstract class |
interface |
|
|---|---|---|
| Поля (стан) | так | ні |
| Реалізація методів | може містити (virtual, звичайні методи) |
лише контракт |
| Скільки можна «підписати» | один клас | кілька інтерфейсів |
| Що виражає | «є різновидом» (Diagnosis є MedicalRecord) |
«вміє / можна» (Appointment можна оплатити) |
Правило вибору. Потрібні спільні стан і код для споріднених класів — абстрактний клас. Потрібна спільна можливість для класів, що між собою не споріднені, — інтерфейс. Appointment і Prescription не родичі, але обидва в принципі можуть бути «оплачуваними».
Про сучасний C#. З версії C# 8 інтерфейси можуть мати методи з тілом («default interface methods»). Це окрема, вужча тема — у цій лабі інтерфейс лишається чистим контрактом, без тіл.
Що нового дозволено (і тільки воно)
- оголошення
interface, реалізаціяclass X : IA, IB; - змінні, параметри й масиви типу інтерфейсу; оператор
isдля перевірки реалізації інтерфейсу (x is ICancellable c); decimalдля грошових сум, літерал із суфіксомm(10m), форматуванняToString("F2");readonly-поле, що присвоюється лише в конструкторі.
Досі заборонено: new-приховування та sealed (Лаба 08), List<T> / Dictionary та інші generic-колекції (Лаба 09), LINQ (Лаба 14). Явну реалізацію інтерфейсу (void IPayable.MarkPaid()) та методи з тілом в інтерфейсах не використовуємо.
Крок 1. Гілка
Робочий процес (повністю — Git Воркшоп): лаба = гілка
Lab-XXвідmain, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття вmain.
Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:
git checkout main
git checkout -b Lab-07Коміт — на кожне завдання (Lab07 TaskNN).
Ваш домен
За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» наприкінці кожного завдання. Структуру рішення зберігайте: щонайменше три інтерфейси; один клас реалізує два інтерфейси; ще один інтерфейс реалізує інший клас; є менеджер, що працює з сутностями лише через інтерфейс (масив IPayable[]). Кожен інтерфейс має хоча б одного споживача, який знає лише контракт.
Як користуватися підказками
Підказки — напрям думки, а не готовий код. «Специфікація» каже, що має вийти; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Приклади коду в цій лабі показують загальну схему на вигаданих класах (IPlayable, Song, Podcast) — перенесення її на IPayable, Appointment, Doctor робите самі. Приклади «Як має працювати» показують лише використання вашого коду й очікуваний вивід.
Задача 1. `IPayable` та оплата записів ⭐
Умова
Клініка хоче відстежувати оплату прийомів: кожен запис має вартість і статус «оплачено». Оформимо це як контракт IPayable — його зможуть підписати різні класи, а не лише Appointment.
Що реалізувати:
interface IPayableу новій теціClinicApp/Interfaces/(простір іменClinicApp.Interfaces).AppointmentреалізуєIPayable:- приватне поле для ознаки оплати;
- вартість запису — 10 грн за хвилину тривалості;
- оплатити можна лише не скасований запис.
- У
AppointmentManager— методGetAll(), що повертає копію всіх записів у новому масиві.
Як перевіряти, поки меню не змінене: Меню «Рахунки» з'явиться лише в Задачі 4. До того класи перевіряйте тимчасовим кодом у Program.cs — між блоком тестових даних і головним циклом меню: створіть об'єкти, виведіть результат у консоль, порівняйте з прикладами. Цей тимчасовий код у коміти Задач 1–3 не потрапляє: git add там перелічує лише файли завдання. Тож git status показуватиме Program.cs як змінений — це нормально. У Задачі 4 ви замінюєте тимчасовий код меню й комітите Program.cs разом з рештою.
Специфікація
IPayable (усі члени — без модифікаторів доступу й без тіл):
| Член | Тип | Опис |
|---|---|---|
GetCost() |
метод, повертає decimal |
Вартість |
IsPaid |
властивість bool, лише get |
Чи оплачено |
MarkPaid() |
метод, void |
Позначити оплаченим |
Реалізація в Appointment:
| Член | Поведінка |
|---|---|
GetCost() |
DurationMinutes × 10 грн: запис на 45 хв коштує 450 |
IsPaid |
false для нового запису; true після MarkPaid() |
MarkPaid() |
Позначає запис оплаченим. Якщо запис скасований — нічого не робить (без винятку) |
AppointmentManager:
| Член | Тип результату | Опис |
|---|---|---|
GetAll() |
Appointment[] |
Новий масив довжиною Count із усіма записами |
Приклад
Схема контракту — на вигаданих класах (у вашому проєкті такі конструкції робите самі):
public interface IPlayable
{
string Title { get; } // властивість: лише get, без тіла
void Play(); // метод: без тіла й без модифікатора доступу
}
public class Song : IPlayable // клас «підписує» контракт
{
public string Title { get; } // реалізація — обов'язково public
public Song(string title) { Title = title; }
public void Play() { Console.WriteLine("Грає: " + Title); }
}Змінна типу інтерфейсу тримає будь-який клас, що його реалізує; видно лише члени контракту:
IPlayable item = new Song("Ой у лузі");
item.Play(); // Грає: Ой у лузі
// item.Length // помилка компіляції: у IPlayable немає такого членаЯк має працювати ваш код:
Appointment a = new Appointment(1, 1, DateTime.Today.AddDays(1).AddHours(10), 45);
Console.WriteLine(a.GetCost()); // 450
Console.WriteLine(a.IsPaid); // False
a.MarkPaid();
Console.WriteLine(a.IsPaid); // True
IPayable p = a; // змінна типу інтерфейсу
Console.WriteLine(p.GetCost()); // 450Підказки
- Файл і простір імен. Інтерфейси кладуть в окрему теку:
ClinicApp/Interfaces/IPayable.cs, простір іменClinicApp.Interfaces(як у Лабі 05: простір імен збігається з текою). Оголошення — ключове словоinterface, ім'я з великоїI. - Що писати всередині інтерфейсу. Лише сигнатури: без
public(він і так є), без тіл{ … }, без полів. Властивість оголошується зget;— контракт вимагає лише можливості читати значення; чи буде в класі сеттер, вирішує клас. - Реалізація в класі. Після імені класу — двокрапка й ім'я інтерфейсу. Кожен член контракту треба реалізувати; обов'язково
public. Не забудьтеusing ClinicApp.Interfaces;у файлі класу. - Експерименти з компілятором (по одному, потім поверніть як було):
- прибрати реалізацію одного члена → помилка
CS0535; - прибрати
publicбіля реалізації →CS0737(«не може реалізувати член інтерфейсу, бо не public»); - написати
new IPayable()→CS0144(інтерфейс не інстанціюється); - додати в інтерфейс поле →
CS0525; - прибрати
using→CS0246(тип не знайдено).
- прибрати реалізацію одного члена → помилка
- Гроші —
decimal, а неdouble.doubleзберігає числа наближено (двійкові дроби), тому в грошових розрахунках накопичується похибка;decimal— десятковий тип для сум. Літералdecimalпишеться із суфіксомm(10m). Множитиintнаdecimalможна без явного приведення — результат будеdecimal. IsPaid— властивість, реалізацій кілька. Найпростіше: приватне поле_isPaidі властивість, що його повертає (або автовластивість зprivate set). Контракт вимагає лишеget; змінити значення зовні можна єдиним способом —MarkPaid()(це та сама інкапсуляція з Лаби 05).MarkPaid()для скасованого запису. Методvoidне може повідомити про відмову, тож тут — тиха відмова: нічого не змінюється. Той, хто викликає, має перевірити стан заздалегідь (у Задачі 3 це робитьBillingManager). Подумайте, чому тут не кинуто виняток.- Скасованість на цьому етапі. Окремої властивості
IsCancelledще немає (вона з'явиться в Задачі 2), тож перевіряйте стан запису прямо: скасований — цеStatusзі значеннямCancelled. GetAll()— копія, а не сам внутрішній масив. Створіть новий масив довжиноюCountі скопіюйте в нього елементи циклом. Віддати внутрішній масив_appointmentsне можна: зовнішній код міг би змінити його, оминувши правила менеджера (інкапсуляція); до того ж у ньому є порожні «хвости» після_count.- Перевірка тимчасовим кодом. Створіть запис, виведіть вартість, оплатіть, виведіть
IsPaid. Потім присвойте запис змінній типуIPayableі спробуйте звернутись доDoctorId— компілятор має заперечити (CS1061): через інтерфейс видно лише контракт.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
Appointment реалізує IPayable |
Booking реалізує IPayable |
TableReservation реалізує IPayable |
Enrollment реалізує IPayable |
Rental реалізує IPayable |
BookLoan реалізує IPayable |
Session реалізує IPayable |
GetCost() = DurationMinutes * 10m |
GetCost() = StayNights * RoomRate |
GetCost() = фіксована ставка |
GetCost() = CourseFee |
GetCost() = RentalDays * DailyRate |
GetFine() = OverdueDays * DailyFine |
GetCost() = DurationMinutes * Rate |
IsPaid / MarkPaid() |
IsPaid / MarkPaid() |
IsPaid / MarkPaid() |
IsPaid / MarkPaid() |
IsPaid / MarkPaid() |
IsPaid / MarkPaid() |
IsPaid / MarkPaid() |
Коміт
git add ClinicApp/Interfaces/IPayable.cs ClinicApp/Models/Appointment.cs ClinicApp/Managers/AppointmentManager.cs
git commit -m "Lab07 Task01"Задача 2. `ICancellable` — один клас, два контракти ⭐⭐
Умова
Appointment уже вміє скасовуватись (метод Cancel з Лаби 03). Оформимо це як контракт ICancellable — «це можна скасувати». Тепер один клас підписує два контракти: IPayable і ICancellable. Для коду, що працює через кожен із них, той самий об'єкт має «своє обличчя». А менеджеру, який хоче скасувати кілька об'єктів одним викликом, не важливо, що це за об'єкти, — потрібен лише контракт «можна скасувати».
Що реалізувати:
interface ICancellableуClinicApp/Interfaces/.Appointment : IPayable, ICancellable:- властивість, що показує, чи запис скасовано;
- властивість із причиною скасування (порожній рядок, якщо не скасовано);
- метод
Cancel(...)вже існує — його лишаєте як є, він і стане реалізацією контракту.
- Невеликий рефакторинг:
MarkPaid()із Задачі 1 тепер може спиратись на нову властивість замість прямої перевіркиStatus. - Споживач контракту. У
AppointmentManager—static-методCancelAll(ICancellable[] items, string reason = ""): скасовує все, що ще можна скасувати, і повертає, скільки об'єктів скасовано. Метод нічого не знає проAppointment— лише проICancellable. У Задачі 4 його підключимо до меню.
Специфікація
ICancellable:
| Член | Тип | Опис |
|---|---|---|
IsCancelled |
властивість bool, лише get |
Чи скасовано |
CancellationReason |
властивість string, лише get |
Причина скасування |
Cancel(string reason = "") |
метод, повертає bool |
Скасувати; true, якщо вдалось |
Реалізація в Appointment:
| Член | Поведінка |
|---|---|
IsCancelled |
true, коли Status дорівнює Cancelled (обчислюється, окремого поля немає) |
CancellationReason |
Причина для скасованого запису (вона зберігається в Notes); для нескасованого — "" |
Cancel(reason) |
Без змін: true лише для запису у стані «Заплановано»; повторне скасування — false |
AppointmentManager (додається до GetAll() із Задачі 1):
| Член | Тип результату | Опис |
|---|---|---|
CancelAll(ICancellable[] items, string reason = "") |
static int |
Викликає Cancel(reason) для кожного елемента; повертає кількість тих, для яких він повернув true. Порожній масив — 0 |
Приклад
Схема «клас із двома контрактами» — на вигаданих класах:
public interface IDownloadable
{
bool Download(); // повертає true, якщо вдалось
}
public class Podcast : IPlayable, IDownloadable // кілька інтерфейсів — через кому
{
public string Title { get; }
public Podcast(string title) { Title = title; }
public void Play() { Console.WriteLine("Грає: " + Title); }
public bool Download() { return true; }
}Один об'єкт, два «обличчя»; is працює й з інтерфейсами:
IPlayable item = new Podcast("Тиха ніч");
if (item is IDownloadable d) // чи підписав об'єкт ще й цей контракт?
Console.WriteLine(d.Download()); // TrueЯк має працювати ваш код:
Appointment a = new Appointment(1, 1, DateTime.Today.AddDays(1).AddHours(10));
ICancellable c = a;
Console.WriteLine(c.IsCancelled); // False
Console.WriteLine(c.CancellationReason); // (порожній рядок)
Console.WriteLine(c.Cancel("Лікар захворів")); // True
Console.WriteLine(c.IsCancelled); // True
Console.WriteLine(c.CancellationReason); // Лікар захворів
Console.WriteLine(c.Cancel("ще раз")); // False — вже скасовано
a.MarkPaid();
Console.WriteLine(a.IsPaid); // False — скасований запис не оплачується
IPayable p = a;
Console.WriteLine(p is ICancellable); // True — той самий об'єкт підписав обидва контрактиГрупове скасування (у пацієнта три записи: запланований, уже скасований і виконаний):
Appointment[] mine = clinic.Appointments.GetByPatient(1);
int n = AppointmentManager.CancelAll(mine, "Лікар захворів"); // масив записів передається як ICancellable[]
Console.WriteLine(n); // 1 — скасувати можна лише запланований; решта не рахуютьсяПідказки
- Кілька інтерфейсів — через кому:
class X : IA, IB. Порядок не важливий. Усе, що казали про реалізацію в Задачі 1 (public,using), стосується кожного інтерфейсу окремо. Cancelвже є. Компілятор зіставляє члени класу з членами інтерфейсу за іменем і сигнатурою, тож існуючий публічний методbool Cancel(string reason = "")сам стає реалізацією. Нічого дописувати не потрібно — лише перевірте, що вінpublic.- Значення за замовчуванням у параметрі підставляється за типом змінної. Якщо оголосити в інтерфейсі
Cancel(string reason)без= "", то викликc.Cancel()через змінну інтерфейсу не збереться (CS7036), хочаa.Cancel()через клас працює. Тому значення за замовчуванням записують і в інтерфейсі, і в класі — однаково. IsCancelledіCancellationReason— обчислювані властивості (стрілка=>, якExpiresAtу Лабі 06). Окремого поля «скасовано» не заводьте: це дублювало бStatusі могло б розійтись із ним. Для нескасованого запису поверніть порожній рядок, а неnull.- Один об'єкт — кілька «облич». Змінна
IPayableбачить лише оплату, зміннаICancellable— лише скасування; сам об'єкт — той самий. Операторisдозволяє з'ясувати під час виконання, чи підписав об'єкт додатковий контракт. CancelAll— код, що залежить від контракту. Метод проходить масив циклом і для кожного елемента викликаєCancel(reason); лічильник збільшується лише тоді, колиCancelповернувtrue(виконаний чи вже скасований запис не скасовується — і не зараховується). У самому методі не повинно бути жодного словаAppointment. Вінstatic, бо не використовує стан менеджера — лише свої параметри; уAppointmentManagerлежить тому, що стосується скасування записів.- Масив записів як
ICancellable[]. РезультатGetByPatient(типAppointment[]) можна передати в параметрICancellable[]без приведення — масиви посилальних типів «коваріантні». Але: запис у такий масив об'єкта іншого типу (який теж реалізує інтерфейс, та не єAppointment) закінчиться виняткомArrayTypeMismatchExceptionуже під час виконання — це історична особливість масивів. - Перевірка тимчасовим кодом. Створіть три записи одного пацієнта, один скасуйте, інший позначте виконаним.
CancelAllмає повернути1, причина у вже скасованого не змінюється. Порожній масив дає0. - Рефакторинг
MarkPaid(). Перепишіть умову з Задачі 1 черезIsCancelled. Поведінка не змінюється — зміниться лише читабельність. Це і є «безпечний» рефакторинг: тимчасові тести з початку задачі мають давати ті самі результати. - Питання для роздумів (код не змінюйте).
Cancelдозволяє скасувати запис, який уже оплачено — гроші «зависають». Як би це виглядало в реальній системі? Де б ви додали перевірку або повернення коштів?
📖 Документація:
- Інтерфейси: реалізація кількох інтерфейсів
- Іменовані й необов'язкові аргументи
- Оператори перевірки типу (
is) - Статичні класи та члени
- Коваріантність і контраваріантність
ArrayTypeMismatchException
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
Appointment реалізує ICancellable |
Booking реалізує ICancellable |
TableReservation реалізує ICancellable |
Enrollment реалізує ICancellable |
Rental реалізує ICancellable |
BookLoan реалізує ICancellable |
Session реалізує ICancellable |
Cancel(reason) |
Cancel(reason) |
Cancel(reason) |
Withdraw(reason) |
Cancel(reason) |
Cancel(reason) |
Cancel(reason) |
CancellationReason |
CancellationReason |
CancellationReason |
WithdrawalReason |
CancellationReason |
CancellationReason |
CancellationReason |
CancelAll(items, reason) |
CancelAll |
CancelAll |
WithdrawAll |
CancelAll |
CancelAll |
CancelAll |
Коміт
git add ClinicApp/Interfaces/ICancellable.cs ClinicApp/Models/Appointment.cs ClinicApp/Managers/AppointmentManager.cs
git commit -m "Lab07 Task02"Задача 3. `ISchedulable` та `BillingManager` ⭐⭐⭐
Умова
Дві окремі потреби:
- (а) перевіряти, чи «розкладова» сутність (лікар) приймає в певний час, через єдиний контракт
ISchedulable— його реалізує інший клас, неAppointment; - (б) зібрати всі неоплачені записи й порахувати борги — так, щоб розрахунок залежав лише від
IPayable, а не відAppointment.
Що реалізувати:
interface ISchedulableуClinicApp/Interfaces/.Doctor : ISchedulable:CanSchedule(DateTime at)— чи потрапляє годинаatу розклад лікаря;GetAvailableSlots(DateTime date, int slotCount)— цілогодинні слоти на вказаний день, починаючи з початку розкладу; не більшеslotCountі не більше, ніж годин у розкладі;slotCount ≤ 0—ArgumentOutOfRangeExceptionчерезClinicValidator.ValidatePositive.
BillingManagerуClinicApp/Managers/; конструктор приймаєAppointmentManager.
Специфікація
ISchedulable:
| Член | Тип | Опис |
|---|---|---|
CanSchedule(DateTime at) |
метод, повертає bool |
Чи приймає в цей час |
GetAvailableSlots(DateTime date, int slotCount) |
метод, повертає DateTime[] |
Початки цілогодинних слотів |
Doctor (розклад — WorkSchedule з Лаби 04: початок включно, кінець виключно):
| Член | Поведінка |
|---|---|
CanSchedule(at) |
true, якщо година at.Hour у межах розкладу: для 08–12 — true о 08:00 та 11:59, false о 12:00 та 07:59 |
GetAvailableSlots(date, n) |
Для розкладу 08–12, n = 3 → 08:00, 09:00, 10:00 цієї дати; n = 100 → 4 слоти (не більше годин розкладу); n = 0 → ArgumentOutOfRangeException |
BillingManager:
| Член | Тип результату | Опис |
|---|---|---|
| конструктор | — | Приймає AppointmentManager, зберігає в readonly-полі |
GetAllUnpaid() |
IPayable[] |
Усі записи, які не оплачені й не скасовані |
GetUnpaidByPatient(int patientId) |
IPayable[] |
Те саме для одного пацієнта |
GetTotalDebt() |
decimal |
Сума GetCost() по всіх неоплачених |
GetPatientDebt(int patientId) |
decimal |
Сума по неоплачених записах пацієнта |
PayAppointment(int appointmentId) |
bool |
Знаходить запис за Id і викликає MarkPaid(); false, якщо запису немає, він уже оплачений або скасований |
DisplayUnpaid(IPayable[] items) |
void |
Виводить кожен елемент із сумою у форматі F2 та «грн»; порожній масив — повідомлення «Немає неоплачених записів.» |
Приклад
Як має працювати ваш код (розклад лікаря — 08:00–12:00):
ISchedulable s = doctor; // змінна типу інтерфейсу
Console.WriteLine(s.CanSchedule(new DateTime(2026, 9, 22, 10, 30, 0))); // True
Console.WriteLine(s.CanSchedule(new DateTime(2026, 9, 22, 12, 0, 0))); // False
DateTime[] slots = s.GetAvailableSlots(new DateTime(2026, 9, 22), 3);
for (int i = 0; i < slots.Length; i++)
Console.WriteLine(slots[i].ToString("HH:mm")); // 08:00 09:00 10:00Для трьох нескасованих неоплачених записів по 30, 45 і 20 хвилин:
IPayable[] unpaid = clinic.Billing.GetAllUnpaid(); // тип — інтерфейс, а не Appointment[]
Console.WriteLine(unpaid.Length); // 3
Console.WriteLine(clinic.Billing.GetTotalDebt()); // 950
Console.WriteLine(clinic.Billing.PayAppointment(1)); // True (запис на 30 хв, 300 грн)
Console.WriteLine(clinic.Billing.PayAppointment(1)); // False — уже оплачено
Console.WriteLine(clinic.Billing.GetTotalDebt()); // 650
clinic.Billing.DisplayUnpaid(clinic.Billing.GetAllUnpaid());
// [2] … | Сума: 450.00 грн
// [3] … | Сума: 200.00 грн(«…» — рядок запису з ToString(). У прикладі десяткова крапка, бо після Лаби 06 у програмі InvariantCulture; без неї на українській локалі буде кома.)
Підказки
ISchedulableіDoctor— той самий порядок дій, що в Задачі 1: файл вInterfaces/, оголошення,: ISchedulableпісля імені класу, публічні реалізації,using. Споживач цього контракту — пункт меню «Вільні години лікаря» в Задачі 4.CanSchedule. УDoctorвже є метод перевірки години (CanAcceptAt(int hour)), аWorkScheduleмає методContains(int hour). Не пишіть третю копію правила:CanScheduleбере годину зat(DateTime.Hour) і питає розклад. Подумайте, чи лишати обидва методи, чи один викликати з іншого — два імені для одного правила це дублювання.GetAvailableSlots. Кількість слотів — менше з двох чисел:slotCountі кількість годин у розкладі (HoursPerDay). Кожен слот — початок цілої години, починаючи зStart. Дату візьміть без часу доби (DateTime.Date) й додавайте години методомAddHours. Спершу перевіртеslotCountвалідатором — інакше від'ємне значення призведе доOverflowExceptionпід час створення масиву.- Про слово «вільні». Метод повертає години розкладу, а не реально вільні: лікар нічого не знає про свої записи — їх знає
AppointmentManager. Тому слоти, які вже зайняті, метод не виключає. (Врахувати їх — вправа на потім; подумайте, хто мав би це робити.) readonly-поле.BillingManagerзберігаєAppointmentManagerіз конструктора у приватномуreadonly-полі: воно присвоюється лише в конструкторі й далі не змінюється.- Де менеджер знає про
Appointment, а де ні — це головна ідея задачі. Збираючи записи,BillingManagerнеминуче має справу зAppointment(їх віддаєAppointmentManager). Але рахуючи борг, він знає лишеIPayable:GetAllUnpaid()іGetUnpaidByPatient()повертаютьIPayable[], аGetTotalDebt()таGetPatientDebt()викликають їх і користуються тільки методомGetCost()— жодногоis Appointment. Завтра ще один клас стане платним — методи-розрахунки не зміняться. - Фільтр — знайомий двопрохідний алгоритм. Перший прохід рахує записи, що не оплачені й не скасовані; потім створюється масив типу
IPayable[]потрібного розміру; другий прохід заповнює його. ЗаписатиAppointmentв елемент масивуIPayable[]можна без приведення. Джерело даних уGetAllUnpaidіGetUnpaidByPatientрізне (GetAll()іGetByPatient(id)), а фільтр однаковий — виділіть його в один приватний метод, що приймає масив записів і повертаєIPayable[]. - Суми —
decimal. Накопичувач створюйте якdecimalз нулем0m; змішувати зdoubleне можна (компілятор не дозволить без явного приведення — і правильно). PayAppointment. ПерегляньтеGetAll()і знайдіть запис заId. Спершу з'ясуйте, чи його можна оплатити (не оплачений, не скасований), і лише тоді викликайтеMarkPaid(). Результатbool— «чи було щось оплачено».DisplayUnpaid(IPayable[]). Параметр — інтерфейс: метод не знає, що саме в масиві. Для сум цього досить (GetCost()), а щоб показати деталі запису (пацієнт, лікар, дата), можна перевіритиis Appointment aі вивестиa; для будь-чого іншого — лише порядковий номер і суму. Формат суми —ToString("F2")(два знаки після коми; докладніше — документація про рядки числового формату).- Експеримент. Спробуйте змінити тип змінної в тимчасовому коді на
Appointment[] u = clinic.Billing.GetAllUnpaid();— компілятор заперечить (CS0266): менеджер віддає контракт, а не конкретний клас. Саме так і задумано.
📖 Документація:
- Інтерфейси як типи параметрів і результатів
- Ключове слово
readonly - Стандартні рядки числового формату (
F2) DateTime.DateтаDateTime.AddHours- Оператори перевірки типу (
is)
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
Doctor реалізує ISchedulable |
Staff реалізує ISchedulable |
Waiter реалізує ISchedulable |
Lecturer реалізує ISchedulable |
Manager реалізує ISchedulable |
Librarian реалізує ISchedulable |
Trainer реалізує ISchedulable |
CanSchedule(DateTime) |
CanCheckIn(DateTime) |
CanServe(DateTime) |
CanTeach(DateTime) |
CanHandle(DateTime) |
CanIssue(DateTime) |
CanTrain(DateTime) |
BillingManager |
BillingManager |
BillingManager |
BillingManager |
BillingManager |
FineManager |
BillingManager |
GetUnpaidByPatient |
GetUnpaidByGuest |
GetUnpaidByCustomer |
GetUnpaidByStudent |
GetUnpaidByClient |
GetUnpaidFinesByReader |
GetUnpaidByMember |
Коміт
git add ClinicApp/Interfaces/ISchedulable.cs ClinicApp/Models/Doctor.cs ClinicApp/Managers/BillingManager.cs
git commit -m "Lab07 Task03"Задача 4. Інтеграція та меню «Рахунки» ⭐⭐⭐
Умова
Підключити BillingManager до системи й показати роботу інтерфейсів через консоль. Крім того, підключаємо споживачів двох інших контрактів: групове скасування записів (ICancellable) і вільні години лікаря (ISchedulable). Оскільки розділів у програмі вже шість, головному меню потрібні короткі описи пунктів.
Що реалізувати:
- У
Clinic.cs— властивістьBillingManager Billing. - У
Program.csприбрати тимчасовий код перевірок і додати підменю «Рахунки». - Оновити головне меню: описи через
—, новий пункт5— «Рахунки», «Звіт» переїжджає на6. - У підменю «Записи» — новий пункт
8: «Скасувати всі записи пацієнта» (черезAppointmentManager.CancelAll). - У підменю «Лікарі» — новий пункт
5: «Вільні години лікаря» (через змінну типуISchedulable).
Формат вводу
Підменю «Рахунки»:
── Рахунки ───────────────────
1. Борги пацієнта
2. Всі неоплачені записи
3. Оплатити запис
4. Загальний борг клініки
0. Назад| Пункт | Що запитує програма | Що виводить |
|---|---|---|
1 |
ID пацієнта (ціле) | Список неоплачених записів пацієнта та його борг |
2 |
— | Усі неоплачені записи |
3 |
ID запису (ціле) | Підтвердження або повідомлення про відмову |
4 |
— | Загальний борг клініки |
| Ситуація | Реакція програми |
|---|---|
| ID — не число | повернення в підменю без падіння |
| Оплатити неіснуючий, уже оплачений або скасований запис | повідомлення «Не вдалося оплатити: запис не знайдено, вже оплачено або скасовано.» |
| У пацієнта немає неоплачених записів | «Немає неоплачених записів.» і борг 0.00 грн |
| Лікаря з таким ID немає (пункт «Вільні години») | повідомлення «Лікаря не знайдено», повернення в підменю |
Дата не у форматі dd.MM.yyyy або кількість слотів — не число |
повідомлення, повернення в підменю |
Кількість слотів 0 або від'ємна |
Помилка: … з тексту винятку; програма не падає |
| У пацієнта немає записів, які можна скасувати | Скасовано записів: 0 |
Нові пункти в наявних підменю:
| Меню | Пункт | Що запитує програма (у порядку) | Що виводить |
|---|---|---|---|
| «Записи» | 8 — Скасувати всі записи пацієнта |
ID пацієнта (ціле) → причина (Enter — без причини) | Скасовано записів: N |
| «Лікарі» | 5 — Вільні години лікаря |
ID лікаря (ціле) → дата dd.MM.yyyy → скільки слотів (ціле) |
Години HH:mm, по одній у рядку |
Головне меню (рамка має бути рівною — усі рядки по 48 символів):
╔══════════════════════════════════════════════╗
║ МЕДИЧНА КЛІНІКА ║
╠══════════════════════════════════════════════╣
║ 1. Пацієнти — реєстрація, пошук ║
║ 2. Лікарі — персонал, розклад ║
║ 3. Записи — прийоми, скасування ║
║ 4. Медична картка — діагнози, рецепти ║
║ 5. Рахунки — оплата, борги ║
║ 6. Звіт — загальна статистика ║
║ 0. Вийти ║
╚══════════════════════════════════════════════╝Приклад
Сесія (три записи по 300, 450 і 200 грн; десяткова крапка — завдяки InvariantCulture з Лаби 06):
Оберіть розділ: 5
── Рахунки ───────────────────
...
Оберіть: 1
ID пацієнта: 1
Неоплачені записи пацієнта #1:
[1] … | Сума: 300.00 грн
Борг: 300.00 грн
Оберіть: 3
ID запису для оплати: 1
Запис [1] оплачено.
Оберіть: 3
ID запису для оплати: 1
Не вдалося оплатити: запис не знайдено, вже оплачено або скасовано.
Оберіть: 4
Загальний борг по клініці: 650.00 грнГрупове скасування (пацієнт має два записи, які ще можна скасувати):
Оберіть розділ: 3
...
Оберіть: 8
ID пацієнта: 1
Причина (Enter — без причини): Лікар захворів
Скасовано записів: 2Вільні години лікаря (розклад 08:00–16:00; слоти враховують лише розклад, а не вже створені записи):
Оберіть розділ: 2
...
Оберіть: 5
ID лікаря: 1
Дата (dd.MM.yyyy): 22.09.2026
Скільки слотів: 3
08:00
09:00
10:00Підказки
Clinic. Нова властивість — за зразком інших менеджерів: лишеget, значення створюється в конструкторі. Порядок присвоєнь має значення:BillingManagerотримуєAppointments— тож створюйте його після того, якAppointmentsуже створено, інакше передастеnull. Знадобитьсяusing ClinicApp.Managers;(там він уже є).using ClinicApp.Interfaces;уProgram.cs. Потрібен, щоб оголосити змінну типуIPayable[].- Тип змінної — інтерфейс. Результат
GetUnpaidByPatientзберігайте вIPayable[], а не вAppointment[](це і є мета лаби: меню теж не знає, що саме лежить у масиві). - Підменю — окремий метод (наприклад,
BillingMenu, що приймаєClinic) за зразком інших меню, а не блок усередині головного циклу. Пункт головного меню5викликає його, аswitchдля «Звіту» тепер має6. - Дві дії для одного пацієнта. Борг пацієнта і список його неоплачених записів — два окремі виклики:
GetPatientDebtтаDisplayUnpaid. - Не «падайте» на вводі.
int.TryParseповертаєbool— перевіряйте його, як у Лабі 06. Неіснуючий пацієнт не потребує окремої перевірки: масив просто порожній, а борг дорівнює нулю. - Рамка меню. Кожен рядок має ту саму довжину — 48 символів разом з рамкою: рахуйте пробіли, доки права межа
║не стане рівною. Шрифт консолі має бути моноширинним. - Скасовані записи. Скасуйте запис у підменю «Записи» й перегляньте «Всі неоплачені» — скасований запис не має там бути й не входить у загальний борг.
CancelAllу меню (Записи →8). Візьміть записи пацієнта (GetByPatient) і передайте масив уAppointmentManager.CancelAll— приведення доICancellable[]не потрібне. Метод статичний, тож викликається через ім'я класу, а не черезclinic.Appointments(спроба через екземпляр дасть помилкуCS0176). Виведіть кількість скасованих; причина може бути порожньою.ISchedulableу меню (Лікарі →5). Знайдіть лікаря за ID (результатDoctor?— перевірте наnull), присвойте його змінній типуISchedulableі далі працюйте лише з нею. Так меню залежить від контракту, а не від класуDoctor.- Дата й слоти. Дату розбирайте так само, як у меню запису на прийом (формат
dd.MM.yyyy,DateTime.TryParseExact). ВикликGetAvailableSlotsдля кількості≤ 0кидаєArgumentOutOfRangeException— огорніть його вtry/catch(порядок блоків, як у Лабі 05: спершу конкретніший тип) і покажітьПомилка: …. Для решти методівBillingManagerвиняток не потрібен — обгортати їх «про всяк випадок» не варто. - Ключовий момент для самоперевірки: у
BillingMenuнемає жодногоAppointment— лишеIPayable; а в пункті «Вільні години» змінна має типISchedulable, а неDoctor. Якщо це не так, ви обійшли контракт.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
Clinic.Billing |
Hotel.Billing |
Restaurant.Billing |
University.Billing |
Fleet.Billing |
Library.Fines |
GymCenter.Billing |
| Меню «Рахунки» | «Рахунки» | «Каса» | «Оплата навчання» | «Розрахунки» | «Штрафи» | «Абонементи й оплата» |
| «Скасувати всі записи пацієнта» | «Скасувати всі бронювання гостя» | «Скасувати всі резервації клієнта» | «Відрахувати зі всіх курсів студента» | «Скасувати всі оренди клієнта» | «Скасувати всі видачі читача» | «Скасувати всі заняття учасника» |
| «Вільні години лікаря» | «Вільні години персоналу» | «Вільні години офіціанта» | «Вільні години викладача» | «Вільні години менеджера» | «Вільні години бібліотекаря» | «Вільні години тренера» |
Коміт
git add ClinicApp/Clinic.cs ClinicApp/Program.cs
git commit -m "Lab07 Task04"Структура проєкту наприкінці лаби
Так має виглядати ClinicApp/, коли всі завдання виконано:
oop-course/ ← гілка Lab-07 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs ✏ Т4
├── Clinic.cs ✏ Т4
├── Enums/
│ ├── AppointmentStatus.cs
│ ├── BloodType.cs
│ └── Speciality.cs
├── Models/
│ ├── Patient.cs
│ ├── Doctor.cs ✏ Т3
│ ├── Appointment.cs ✏ Т1 Т2
│ ├── WorkSchedule.cs
│ ├── Diagnosis.cs
│ ├── LabResult.cs
│ ├── MedicalRecord.cs
│ └── Prescription.cs
├── Managers/
│ ├── PatientManager.cs
│ ├── DoctorManager.cs
│ ├── AppointmentManager.cs ✏ Т1 Т2
│ ├── GrowablePatientManager.cs
│ ├── MedicalRecordManager.cs
│ └── BillingManager.cs 🆕 Т3
├── Utils/
│ ├── ClinicFormatter.cs
│ └── ClinicValidator.cs
└── Interfaces/
├── ICancellable.cs 🆕 Т2
├── IPayable.cs 🆕 Т1
└── ISchedulable.cs 🆕 Т3Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 06. Файл може мати кілька позначок: Appointment.cs змінюється і в Т1 (IPayable), і в Т2 (ICancellable).
Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.
Перевірка перед здачею
dotnet build ClinicApp
dotnet run --project ClinicAppПереконайтесь, що:
- Структура проєкту збігається зі схемою вище
- Проєкт компілюється без помилок і без попереджень
-
new IPayable()не компілюється — інтерфейс не інстанціюється (перевірте й приберіть) -
AppointmentреалізуєIPayableіICancellable;Doctor—ISchedulable -
GetCost()для запису на 45 хв дорівнює450 -
MarkPaid()для скасованого запису не змінюєIsPaid - Через змінну
IPayableнедоступніDoctorId,Cancelтощо — лише члени контракту -
Cancelвдруге на тому самому записі повертаєfalse;CancellationReasonдля нескасованого — порожній рядок -
CanScheduleдля розкладу08–12:trueо 11:59,falseо 12:00 -
GetAvailableSlots(date, 0)кидаєArgumentOutOfRangeException - Головне меню має описи через
—, рамка рівна; «Рахунки» — пункт5, «Звіт» —6 - «Борги пацієнта» виводить список і суму
- «Оплатити запис» повертає підтвердження, після чого запис зникає зі списку неоплачених
- «Оплатити» вже оплачений, скасований чи неіснуючий запис — повідомлення, не крах
- «Загальний борг» рахується правильно (сума по всіх неоплачених нескасованих)
-
AppointmentManager.CancelAll(ICancellable[], reason): для запланованого, уже скасованого й виконаного записів повертає1; у методі немає словаAppointment - «Записи» →
8скасовує записи пацієнта й показує кількість; повторний виклик —Скасовано записів: 0 - «Лікарі» →
5показує години за розкладом; змінна в меню має типISchedulable - «Вільні години» з кількістю
0— повідомлення про помилку, програма не падає -
IPayable[] items = clinic.Billing.GetAllUnpaid()компілюється — тип змінної інтерфейс, не клас -
GetTotalDebt()таGetPatientDebt()не містятьis Appointment— лишеIPayable - У коді немає
List<T>,Dictionary<,>, LINQ,new/sealed(приховування)
Питання для самоперевірки
- Чим інтерфейс відрізняється від
abstract class? Коли обираєте одне, а коли інше? Наведіть приклад із цієї лаби та з Лаби 06. - Чому
GetTotalDebt()працює зIPayable, а не зAppointment? Що зміниться, якщо завтраPrescriptionтеж стане платним? BillingManagerзнає проAppointmentпри зборі записів, але не знає при розрахунку боргу. Де саме проходить ця межа і чому вона проходить саме там?- Що означає «реалізувати кілька інтерфейсів»? Чому C# не дозволяє успадковувати кілька класів, але дозволяє реалізувати кілька інтерфейсів?
IPayable[] items = new Appointment[3]— чому це компілюється? Що станеться приitems[0] = new Fee(), деFee— інший клас, що реалізуєIPayable?- Спробуйте написати метод
static decimal TotalCost(IPayable[] items). Де він може жити і чому він не залежить від жодного конкретного класу? - Чому значення параметра за замовчуванням (
reason = "") записують і в інтерфейсі, і в класі? Що станеться, якщо лише в класі? - Чи справді
GetAvailableSlotsповертає вільні слоти? Що потрібно, щоб врахувати вже створені записи, і хто про них знає? Cancelдозволяє скасувати вже оплачений запис. У чому проблема? Куди б ви додали перевірку і що робили б з оплатою?- Чому
MarkPaid()для скасованого запису мовчки нічого не робить, а не кидає виняток? Який недолік такого рішення? - Чому
CancelAll—staticі лежить уAppointmentManager, хоч не використовує його стан? Де ще він міг би жити і що змінилось би? - У пункті «Вільні години» змінна має тип
ISchedulable, а неDoctor. Що це дає меню? Що знадобилось би, щоб той самий пункт працював ще й для іншого класу?
Статус гілки
Після всіх завдань (кожне — окремий коміт Lab07 TaskNN на гілці Lab-07):
git push -u origin Lab-07
git checkout main
git merge --no-ff Lab-07 -m "Merge Lab-07: Interfaces"
git pushНаступна лаба:
git checkout main→git checkout -b Lab-08.