OOP Course
Сьогодні

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.

Що реалізувати:

  1. interface IPayable у новій теці ClinicApp/Interfaces/ (простір імен ClinicApp.Interfaces).
  2. Appointment реалізує IPayable:
    • приватне поле для ознаки оплати;
    • вартість запису — 10 грн за хвилину тривалості;
    • оплатити можна лише не скасований запис.
  3. У 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

Підказки

  1. Файл і простір імен. Інтерфейси кладуть в окрему теку: ClinicApp/Interfaces/IPayable.cs, простір імен ClinicApp.Interfaces (як у Лабі 05: простір імен збігається з текою). Оголошення — ключове слово interface, ім'я з великої I.
  2. Що писати всередині інтерфейсу. Лише сигнатури: без public (він і так є), без тіл { … }, без полів. Властивість оголошується з get; — контракт вимагає лише можливості читати значення; чи буде в класі сеттер, вирішує клас.
  3. Реалізація в класі. Після імені класу — двокрапка й ім'я інтерфейсу. Кожен член контракту треба реалізувати; обов'язково public. Не забудьте using ClinicApp.Interfaces; у файлі класу.
  4. Експерименти з компілятором (по одному, потім поверніть як було):
    • прибрати реалізацію одного члена → помилка CS0535;
    • прибрати public біля реалізації → CS0737 («не може реалізувати член інтерфейсу, бо не public»);
    • написати new IPayable() → CS0144 (інтерфейс не інстанціюється);
    • додати в інтерфейс поле → CS0525;
    • прибрати using → CS0246 (тип не знайдено).
  5. Гроші — decimal, а не double. double зберігає числа наближено (двійкові дроби), тому в грошових розрахунках накопичується похибка; decimal — десятковий тип для сум. Літерал decimal пишеться із суфіксом m (10m). Множити int на decimal можна без явного приведення — результат буде decimal.
  6. IsPaid — властивість, реалізацій кілька. Найпростіше: приватне поле _isPaid і властивість, що його повертає (або автовластивість з private set). Контракт вимагає лише get; змінити значення зовні можна єдиним способом — MarkPaid() (це та сама інкапсуляція з Лаби 05).
  7. MarkPaid() для скасованого запису. Метод void не може повідомити про відмову, тож тут — тиха відмова: нічого не змінюється. Той, хто викликає, має перевірити стан заздалегідь (у Задачі 3 це робить BillingManager). Подумайте, чому тут не кинуто виняток.
  8. Скасованість на цьому етапі. Окремої властивості IsCancelled ще немає (вона з'явиться в Задачі 2), тож перевіряйте стан запису прямо: скасований — це Status зі значенням Cancelled.
  9. GetAll() — копія, а не сам внутрішній масив. Створіть новий масив довжиною Count і скопіюйте в нього елементи циклом. Віддати внутрішній масив _appointments не можна: зовнішній код міг би змінити його, оминувши правила менеджера (інкапсуляція); до того ж у ньому є порожні «хвости» після _count.
  10. Перевірка тимчасовим кодом. Створіть запис, виведіть вартість, оплатіть, виведіть 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. Для коду, що працює через кожен із них, той самий об'єкт має «своє обличчя». А менеджеру, який хоче скасувати кілька об'єктів одним викликом, не важливо, що це за об'єкти, — потрібен лише контракт «можна скасувати».

Що реалізувати:

  1. interface ICancellable у ClinicApp/Interfaces/.
  2. Appointment : IPayable, ICancellable:
    • властивість, що показує, чи запис скасовано;
    • властивість із причиною скасування (порожній рядок, якщо не скасовано);
    • метод Cancel(...) вже існує — його лишаєте як є, він і стане реалізацією контракту.
  3. Невеликий рефакторинг: MarkPaid() із Задачі 1 тепер може спиратись на нову властивість замість прямої перевірки Status.
  4. Споживач контракту. У 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 — скасувати можна лише запланований; решта не рахуються

Підказки

  1. Кілька інтерфейсів — через кому: class X : IA, IB. Порядок не важливий. Усе, що казали про реалізацію в Задачі 1 (public, using), стосується кожного інтерфейсу окремо.
  2. Cancel вже є. Компілятор зіставляє члени класу з членами інтерфейсу за іменем і сигнатурою, тож існуючий публічний метод bool Cancel(string reason = "") сам стає реалізацією. Нічого дописувати не потрібно — лише перевірте, що він public.
  3. Значення за замовчуванням у параметрі підставляється за типом змінної. Якщо оголосити в інтерфейсі Cancel(string reason) без = "", то виклик c.Cancel() через змінну інтерфейсу не збереться (CS7036), хоча a.Cancel() через клас працює. Тому значення за замовчуванням записують і в інтерфейсі, і в класі — однаково.
  4. IsCancelled і CancellationReason — обчислювані властивості (стрілка =>, як ExpiresAt у Лабі 06). Окремого поля «скасовано» не заводьте: це дублювало б Status і могло б розійтись із ним. Для нескасованого запису поверніть порожній рядок, а не null.
  5. Один об'єкт — кілька «облич». Змінна IPayable бачить лише оплату, змінна ICancellable — лише скасування; сам об'єкт — той самий. Оператор is дозволяє з'ясувати під час виконання, чи підписав об'єкт додатковий контракт.
  6. CancelAll — код, що залежить від контракту. Метод проходить масив циклом і для кожного елемента викликає Cancel(reason); лічильник збільшується лише тоді, коли Cancel повернув true (виконаний чи вже скасований запис не скасовується — і не зараховується). У самому методі не повинно бути жодного слова Appointment. Він static, бо не використовує стан менеджера — лише свої параметри; у AppointmentManager лежить тому, що стосується скасування записів.
  7. Масив записів як ICancellable[]. Результат GetByPatient (тип Appointment[]) можна передати в параметр ICancellable[] без приведення — масиви посилальних типів «коваріантні». Але: запис у такий масив об'єкта іншого типу (який теж реалізує інтерфейс, та не є Appointment) закінчиться винятком ArrayTypeMismatchException уже під час виконання — це історична особливість масивів.
  8. Перевірка тимчасовим кодом. Створіть три записи одного пацієнта, один скасуйте, інший позначте виконаним. CancelAll має повернути 1, причина у вже скасованого не змінюється. Порожній масив дає 0.
  9. Рефакторинг MarkPaid(). Перепишіть умову з Задачі 1 через IsCancelled. Поведінка не змінюється — зміниться лише читабельність. Це і є «безпечний» рефакторинг: тимчасові тести з початку задачі мають давати ті самі результати.
  10. Питання для роздумів (код не змінюйте). Cancel дозволяє скасувати запис, який уже оплачено — гроші «зависають». Як би це виглядало в реальній системі? Де б ви додали перевірку або повернення коштів?

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
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.

Що реалізувати:

  1. interface ISchedulable у ClinicApp/Interfaces/.
  2. Doctor : ISchedulable:
    • CanSchedule(DateTime at) — чи потрапляє година at у розклад лікаря;
    • GetAvailableSlots(DateTime date, int slotCount) — цілогодинні слоти на вказаний день, починаючи з початку розкладу; не більше slotCount і не більше, ніж годин у розкладі; slotCount ≤ 0 — ArgumentOutOfRangeException через ClinicValidator.ValidatePositive.
  3. 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; без неї на українській локалі буде кома.)

Підказки

  1. ISchedulable і Doctor — той самий порядок дій, що в Задачі 1: файл в Interfaces/, оголошення, : ISchedulable після імені класу, публічні реалізації, using. Споживач цього контракту — пункт меню «Вільні години лікаря» в Задачі 4.
  2. CanSchedule. У Doctor вже є метод перевірки години (CanAcceptAt(int hour)), а WorkSchedule має метод Contains(int hour). Не пишіть третю копію правила: CanSchedule бере годину з at (DateTime.Hour) і питає розклад. Подумайте, чи лишати обидва методи, чи один викликати з іншого — два імені для одного правила це дублювання.
  3. GetAvailableSlots. Кількість слотів — менше з двох чисел: slotCount і кількість годин у розкладі (HoursPerDay). Кожен слот — початок цілої години, починаючи з Start. Дату візьміть без часу доби (DateTime.Date) й додавайте години методом AddHours. Спершу перевірте slotCount валідатором — інакше від'ємне значення призведе до OverflowException під час створення масиву.
  4. Про слово «вільні». Метод повертає години розкладу, а не реально вільні: лікар нічого не знає про свої записи — їх знає AppointmentManager. Тому слоти, які вже зайняті, метод не виключає. (Врахувати їх — вправа на потім; подумайте, хто мав би це робити.)
  5. readonly-поле. BillingManager зберігає AppointmentManager із конструктора у приватному readonly-полі: воно присвоюється лише в конструкторі й далі не змінюється.
  6. Де менеджер знає про Appointment, а де ні — це головна ідея задачі. Збираючи записи, BillingManager неминуче має справу з Appointment (їх віддає AppointmentManager). Але рахуючи борг, він знає лише IPayable: GetAllUnpaid() і GetUnpaidByPatient() повертають IPayable[], а GetTotalDebt() та GetPatientDebt() викликають їх і користуються тільки методом GetCost() — жодного is Appointment. Завтра ще один клас стане платним — методи-розрахунки не зміняться.
  7. Фільтр — знайомий двопрохідний алгоритм. Перший прохід рахує записи, що не оплачені й не скасовані; потім створюється масив типу IPayable[] потрібного розміру; другий прохід заповнює його. Записати Appointment в елемент масиву IPayable[] можна без приведення. Джерело даних у GetAllUnpaid і GetUnpaidByPatient різне (GetAll() і GetByPatient(id)), а фільтр однаковий — виділіть його в один приватний метод, що приймає масив записів і повертає IPayable[].
  8. Суми — decimal. Накопичувач створюйте як decimal з нулем 0m; змішувати з double не можна (компілятор не дозволить без явного приведення — і правильно).
  9. PayAppointment. Перегляньте GetAll() і знайдіть запис за Id. Спершу з'ясуйте, чи його можна оплатити (не оплачений, не скасований), і лише тоді викликайте MarkPaid(). Результат bool — «чи було щось оплачено».
  10. DisplayUnpaid(IPayable[]). Параметр — інтерфейс: метод не знає, що саме в масиві. Для сум цього досить (GetCost()), а щоб показати деталі запису (пацієнт, лікар, дата), можна перевірити is Appointment a і вивести a; для будь-чого іншого — лише порядковий номер і суму. Формат суми — ToString("F2") (два знаки після коми; докладніше — документація про рядки числового формату).
  11. Експеримент. Спробуйте змінити тип змінної в тимчасовому коді на Appointment[] u = clinic.Billing.GetAllUnpaid(); — компілятор заперечить (CS0266): менеджер віддає контракт, а не конкретний клас. Саме так і задумано.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
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). Оскільки розділів у програмі вже шість, головному меню потрібні короткі описи пунктів.

Що реалізувати:

  1. У Clinic.cs — властивість BillingManager Billing.
  2. У Program.cs прибрати тимчасовий код перевірок і додати підменю «Рахунки».
  3. Оновити головне меню: описи через —, новий пункт 5 — «Рахунки», «Звіт» переїжджає на 6.
  4. У підменю «Записи» — новий пункт 8: «Скасувати всі записи пацієнта» (через AppointmentManager.CancelAll).
  5. У підменю «Лікарі» — новий пункт 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

Підказки

  1. Clinic. Нова властивість — за зразком інших менеджерів: лише get, значення створюється в конструкторі. Порядок присвоєнь має значення: BillingManager отримує Appointments — тож створюйте його після того, як Appointments уже створено, інакше передасте null. Знадобиться using ClinicApp.Managers; (там він уже є).
  2. using ClinicApp.Interfaces; у Program.cs. Потрібен, щоб оголосити змінну типу IPayable[].
  3. Тип змінної — інтерфейс. Результат GetUnpaidByPatient зберігайте в IPayable[], а не в Appointment[] (це і є мета лаби: меню теж не знає, що саме лежить у масиві).
  4. Підменю — окремий метод (наприклад, BillingMenu, що приймає Clinic) за зразком інших меню, а не блок усередині головного циклу. Пункт головного меню 5 викликає його, а switch для «Звіту» тепер має 6.
  5. Дві дії для одного пацієнта. Борг пацієнта і список його неоплачених записів — два окремі виклики: GetPatientDebt та DisplayUnpaid.
  6. Не «падайте» на вводі. int.TryParse повертає bool — перевіряйте його, як у Лабі 06. Неіснуючий пацієнт не потребує окремої перевірки: масив просто порожній, а борг дорівнює нулю.
  7. Рамка меню. Кожен рядок має ту саму довжину — 48 символів разом з рамкою: рахуйте пробіли, доки права межа ║ не стане рівною. Шрифт консолі має бути моноширинним.
  8. Скасовані записи. Скасуйте запис у підменю «Записи» й перегляньте «Всі неоплачені» — скасований запис не має там бути й не входить у загальний борг.
  9. CancelAll у меню (Записи → 8). Візьміть записи пацієнта (GetByPatient) і передайте масив у AppointmentManager.CancelAll — приведення до ICancellable[] не потрібне. Метод статичний, тож викликається через ім'я класу, а не через clinic.Appointments (спроба через екземпляр дасть помилку CS0176). Виведіть кількість скасованих; причина може бути порожньою.
  10. ISchedulable у меню (Лікарі → 5). Знайдіть лікаря за ID (результат Doctor? — перевірте на null), присвойте його змінній типу ISchedulable і далі працюйте лише з нею. Так меню залежить від контракту, а не від класу Doctor.
  11. Дата й слоти. Дату розбирайте так само, як у меню запису на прийом (формат dd.MM.yyyy, DateTime.TryParseExact). Виклик GetAvailableSlots для кількості ≤ 0 кидає ArgumentOutOfRangeException — огорніть його в try/catch (порядок блоків, як у Лабі 05: спершу конкретніший тип) і покажіть Помилка: …. Для решти методів BillingManager виняток не потрібен — обгортати їх «про всяк випадок» не варто.
  12. Ключовий момент для самоперевірки: у 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 (приховування)

Питання для самоперевірки

  1. Чим інтерфейс відрізняється від abstract class? Коли обираєте одне, а коли інше? Наведіть приклад із цієї лаби та з Лаби 06.
  2. Чому GetTotalDebt() працює з IPayable, а не з Appointment? Що зміниться, якщо завтра Prescription теж стане платним?
  3. BillingManager знає про Appointment при зборі записів, але не знає при розрахунку боргу. Де саме проходить ця межа і чому вона проходить саме там?
  4. Що означає «реалізувати кілька інтерфейсів»? Чому C# не дозволяє успадковувати кілька класів, але дозволяє реалізувати кілька інтерфейсів?
  5. IPayable[] items = new Appointment[3] — чому це компілюється? Що станеться при items[0] = new Fee(), де Fee — інший клас, що реалізує IPayable?
  6. Спробуйте написати метод static decimal TotalCost(IPayable[] items). Де він може жити і чому він не залежить від жодного конкретного класу?
  7. Чому значення параметра за замовчуванням (reason = "") записують і в інтерфейсі, і в класі? Що станеться, якщо лише в класі?
  8. Чи справді GetAvailableSlots повертає вільні слоти? Що потрібно, щоб врахувати вже створені записи, і хто про них знає?
  9. Cancel дозволяє скасувати вже оплачений запис. У чому проблема? Куди б ви додали перевірку і що робили б з оплатою?
  10. Чому MarkPaid() для скасованого запису мовчки нічого не робить, а не кидає виняток? Який недолік такого рішення?
  11. Чому CancelAll — static і лежить у AppointmentManager, хоч не використовує його стан? Де ще він міг би жити і що змінилось би?
  12. У пункті «Вільні години» змінна має тип 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.

Розроблено Tomka Yurii · © 2026 ·