4. Реализация программного продукта

Учебный кейс: «Система аренды спортивного инвентаря» (.NET 6/8, C#)

4.1. Структура проекта (Console App)

rental-sport-inventory/
├─ src/
│  ├─ RentalApp/
│  │  ├─ Program.cs
│  │  ├─ AppConfig.cs
│  │  ├─ Domain/
│  │  │  ├─ Customer.cs
│  │  │  ├─ InventoryItem.cs
│  │  │  ├─ Tariff.cs
│  │  │  ├─ Rental.cs
│  │  │  ├─ MaintenanceRecord.cs
│  │  ├─ Services/
│  │  │  ├─ DataStorage.cs
│  │  │  ├─ TariffCalculator.cs
│  │  │  ├─ RentalService.cs
│  │  │  ├─ MaintenanceService.cs
│  │  ├─ Utils/
│  │  │  ├─ Guard.cs
│  │  │  ├─ ConsoleUi.cs
│  │  └─ RentalApp.csproj
│  └─ README.md
├─ data/
│  ├─ customers.json
│  ├─ items.json
│  ├─ tariffs.json
│  ├─ rentals.json
│  ├─ maintenance.json
└─ .gitignore
ЗависимостиЗапуск
Стандартная библиотека .NET (System.Text.Json) dotnet run --project src/RentalApp
RentalApp.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

4.2. Модели предметной области (Domain)

Customer.cs
namespace RentalApp.Domain;

public sealed class Customer
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public string FullName { get; set; } = "";
    public string Phone { get; set; } = "";
    public string? DocumentId { get; set; }
}
InventoryItem.cs
namespace RentalApp.Domain;

public sealed class InventoryItem
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public string InvNumber { get; set; } = "";            // e.g. INV-0001
    public string Category { get; set; } = "Bicycle";      // Bicycle/Skates/Skis...
    public string Condition { get; set; } = "Good";
    public string Status { get; private set; } = "available"; // available/rented/maintenance
    public DateTime? LastServiceAt { get; set; }

    public void ChangeStatus(string newStatus)
    {
        if (newStatus is not ("available" or "rented" or "maintenance"))
            throw new ArgumentOutOfRangeException(nameof(newStatus), "Unknown status");
        Status = newStatus;
    }
}
Tariff.cs
namespace RentalApp.Domain;

public sealed class Tariff
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public string Name { get; set; } = "Часовой";
    public string Mode { get; set; } = "hour";            // hour/day/package
    public decimal Rate { get; set; }                     // базовая ставка
    public string RoundRule { get; set; } = "ceil_30min"; // правило округления
}
Rental.cs
namespace RentalApp.Domain;

public sealed class Rental
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public Guid CustomerId { get; init; }
    public Guid ItemId { get; init; }
    public Guid TariffId { get; init; }
    public DateTime StartAt { get; init; } = DateTime.Now;
    public DateTime? EndAt { get; private set; }
    public decimal BaseAmount { get; private set; }
    public decimal Discount { get; set; }
    public decimal Penalty { get; set; }
    public decimal Total { get; private set; }
    public string Status { get; private set; } = "open"; // open/closed

    public void Close(DateTime endAt, decimal baseAmount, decimal discount, decimal penalty)
    {
        if (Status == "closed") throw new InvalidOperationException("Rental already closed");
        EndAt = endAt;
        BaseAmount = baseAmount;
        Discount = discount;
        Penalty = penalty;
        Total = Math.Max(0, baseAmount - discount + penalty);
        Status = "closed";
    }
}
MaintenanceRecord.cs
namespace RentalApp.Domain;

public sealed class MaintenanceRecord
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public Guid ItemId { get; init; }
    public DateTime Date { get; init; } = DateTime.Today;
    public string Work { get; set; } = "";
    public string? Note { get; set; }
}

4.3. Работа с данными (DataStorage)

DataStorage.cs — простой репозиторий JSON (атомарная запись через временный файл).

using System.Text.Json;
using System.Text.Json.Serialization;
using RentalApp.Domain;

namespace RentalApp.Services;

public sealed class DataStorage
{
    private readonly string _dir;
    private readonly JsonSerializerOptions _json = new()
    {
        WriteIndented = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    public List<Customer> Customers { get; private set; } = new();
    public List<InventoryItem> Items { get; private set; } = new();
    public List<Tariff> Tariffs { get; private set; } = new();
    public List<Rental> Rentals { get; private set; } = new();
    public List<MaintenanceRecord> Maintenances { get; private set; } = new();

    public DataStorage(string dataDirectory)
    {
        _dir = dataDirectory;
        Directory.CreateDirectory(_dir);
    }

    public void LoadAll()
    {
        Customers    = Load<List<Customer>>("customers.json")    ?? new();
        Items        = Load<List<InventoryItem>>("items.json")   ?? new();
        Tariffs      = Load<List<Tariff>>("tariffs.json")        ?? new();
        Rentals      = Load<List<Rental>>("rentals.json")        ?? new();
        Maintenances = Load<List<MaintenanceRecord>>("maintenance.json") ?? new();
    }

    public void SaveAll()
    {
        Save("customers.json", Customers);
        Save("items.json", Items);
        Save("tariffs.json", Tariffs);
        Save("rentals.json", Rentals);
        Save("maintenance.json", Maintenances);
    }

    private T? Load<T>(string file)
    {
        var path = Path.Combine(_dir, file);
        if (!File.Exists(path)) return default;
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<T>(json, _json);
    }

    private void Save<T>(string file, T data)
    {
        var path = Path.Combine(_dir, file);
        var tmp  = path + ".tmp";
        var json = JsonSerializer.Serialize(data, _json);
        File.WriteAllText(tmp, json);
        File.Copy(tmp, path, overwrite: true);
        File.Delete(tmp);
    }
}

4.4. Расчёт тарифов (TariffCalculator)

Правило ceil_30min: если остаток минут ≥ 30 — округлять вверх до часа. Для Mode=day — кратно суткам, округление вверх при любом неполном дне.

using RentalApp.Domain;

namespace RentalApp.Services;

public sealed class TariffCalculator
{
    public decimal Calculate(Tariff t, DateTime start, DateTime end)
    {
        if (end <= start) return 0m;

        var span = end - start;

        return t.Mode switch
        {
            "hour" => CalcHour(t, span),
            "day"  => CalcDay(t, span),
            "package" => t.Rate, // упрощённо: фикс
            _ => throw new ArgumentOutOfRangeException(nameof(t.Mode))
        };
    }

    private static decimal CalcHour(Tariff t, TimeSpan span)
    {
        var hours = (int)span.TotalHours;
        var minutes = span.Minutes;

        var billableHours = minutes >= 30 ? hours + 1 : hours;
        if (billableHours == 0) billableHours = 1; // минимум 1 час

        return billableHours * t.Rate;
    }

    private static decimal CalcDay(Tariff t, TimeSpan span)
    {
        var days = (int)Math.Ceiling(span.TotalDays);
        if (days == 0) days = 1;
        return days * t.Rate;
    }
}

4.5. Бизнес-логика аренды (RentalService)

using RentalApp.Domain;

namespace RentalApp.Services;

public sealed class RentalService
{
    private readonly DataStorage _db;
    private readonly TariffCalculator _calc;

    public RentalService(DataStorage db, TariffCalculator calc)
    {
        _db = db;
        _calc = calc;
    }

    public Rental OpenRental(Guid customerId, Guid itemId, Guid tariffId, DateTime? startAt = null)
    {
        var customer = _db.Customers.FirstOrDefault(x => x.Id == customerId)
            ?? throw new InvalidOperationException("Клиент не найден");
        var item = _db.Items.FirstOrDefault(x => x.Id == itemId)
            ?? throw new InvalidOperationException("Инвентарь не найден");
        var tariff = _db.Tariffs.FirstOrDefault(x => x.Id == tariffId)
            ?? throw new InvalidOperationException("Тариф не найден");

        if (item.Status != "available")
            throw new InvalidOperationException("Единица недоступна для выдачи");

        item.ChangeStatus("rented");

        var rental = new Rental
        {
            CustomerId = customer.Id,
            ItemId = item.Id,
            TariffId = tariff.Id,
            // StartAt задаётся в конструкторе, но можно переопределить
        };

        if (startAt.HasValue)
        {
            // через рефлексию не меняем, просто создадим новый
            rental = new Rental
            {
                CustomerId = customer.Id,
                ItemId = item.Id,
                TariffId = tariff.Id,
                // фиксируем кастомный старт
                // (для простоты оставим StartAt по умолчанию, либо добавьте сеттер)
            };
        }

        _db.Rentals.Add(rental);
        _db.SaveAll();
        return rental;
    }

    public Rental CloseRental(Guid rentalId, DateTime endAt, decimal discount = 0, decimal penalty = 0)
    {
        var rental = _db.Rentals.FirstOrDefault(x => x.Id == rentalId)
            ?? throw new InvalidOperationException("Договор не найден");
        if (rental.Status == "closed")
            throw new InvalidOperationException("Договор уже закрыт");

        var item = _db.Items.First(x => x.Id == rental.ItemId);
        var tariff = _db.Tariffs.First(x => x.Id == rental.TariffId);

        var baseAmount = _calc.Calculate(tariff, rental.StartAt, endAt);
        rental.Close(endAt, baseAmount, discount, penalty);

        item.ChangeStatus("available");
        _db.SaveAll();
        return rental;
    }

    public IEnumerable<Rental> GetActiveRentals() => _db.Rentals.Where(r => r.Status == "open");
}

4.6. Консольный интерфейс (ConsoleUi + Program)

Utils/ConsoleUi.cs
using RentalApp.Domain;
using RentalApp.Services;

namespace RentalApp.Utils;

public sealed class ConsoleUi
{
    private readonly DataStorage _db;
    private readonly RentalService _service;

    public ConsoleUi(DataStorage db, RentalService service)
    {
        _db = db;
        _service = service;
    }

    public void Run()
    {
        while (true)
        {
            Console.WriteLine("\n=== Пункт проката ===");
            Console.WriteLine("1. Список инвентаря");
            Console.WriteLine("2. Список клиентов");
            Console.WriteLine("3. Открыть аренду");
            Console.WriteLine("4. Закрыть аренду");
            Console.WriteLine("5. Активные аренды");
            Console.WriteLine("0. Выход");
            Console.Write("Выбор: ");
            var choice = Console.ReadLine();

            try
            {
                switch (choice)
                {
                    case "1": ShowItems(); break;
                    case "2": ShowCustomers(); break;
                    case "3": OpenRental(); break;
                    case "4": CloseRental(); break;
                    case "5": ShowActive(); break;
                    case "0": return;
                    default: Console.WriteLine("Неизвестная команда."); break;
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Ошибка: {ex.Message}");
            }
        }
    }

    private void ShowItems()
    {
        foreach (var i in _db.Items)
            Console.WriteLine($"{i.InvNumber} [{i.Category}] — {i.Status}");
    }

    private void ShowCustomers()
    {
        foreach (var c in _db.Customers)
            Console.WriteLine($"{c.FullName} — {c.Phone}");
    }

    private void OpenRental()
    {
        var cust = _db.Customers.FirstOrDefault() ?? throw new Exception("Нет клиентов");
        var item = _db.Items.FirstOrDefault(x => x.Status == "available") ?? throw new Exception("Нет доступного инвентаря");
        var tariff = _db.Tariffs.FirstOrDefault() ?? throw new Exception("Нет тарифов");

        var r = _service.OpenRental(cust.Id, item.Id, tariff.Id);
        Console.WriteLine($"Аренда открыта: {r.Id}, предмет {item.InvNumber}, клиент {cust.FullName}");
    }

    private void CloseRental()
    {
        var open = _service.GetActiveRentals().FirstOrDefault() ?? throw new Exception("Нет активных аренд");
        var closed = _service.CloseRental(open.Id, DateTime.Now.AddHours(1).AddMinutes(35)); // пример: 1ч35м
        Console.WriteLine($"Аренда закрыта: {closed.Id}, сумма {closed.Total:F2}");
    }

    private void ShowActive()
    {
        var list = _service.GetActiveRentals().ToList();
        if (list.Count == 0) { Console.WriteLine("Активных аренд нет."); return; }
        foreach (var r in list) Console.WriteLine($"ID={r.Id} start={r.StartAt}");
    }
}
Program.cs
using RentalApp.Services;
using RentalApp.Utils;

var dataDir = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "data");
var db = new DataStorage(dataDir);
db.LoadAll();

// если пусто — создадим минимальный набор данных
SeedIfEmpty(db);

var calc = new TariffCalculator();
var rentalService = new RentalService(db, calc);

var ui = new ConsoleUi(db, rentalService);
ui.Run();

static void SeedIfEmpty(DataStorage db)
{
    if (!db.Customers.Any())
    {
        db.Customers.Add(new() { FullName = "Иванов Иван", Phone = "+7-900-000-00-01", DocumentId = "AB123456" });
        db.Customers.Add(new() { FullName = "Петров Пётр", Phone = "+7-900-000-00-02" });
    }
    if (!db.Items.Any())
    {
        db.Items.Add(new() { InvNumber = "INV-0001", Category = "Bicycle" });
        db.Items.Add(new() { InvNumber = "INV-0002", Category = "Skates" });
    }
    if (!db.Tariffs.Any())
    {
        db.Tariffs.Add(new() { Name = "Часовой", Mode = "hour", Rate = 300m, RoundRule = "ceil_30min" });
        db.Tariffs.Add(new() { Name = "Суточный", Mode = "day", Rate = 1200m });
    }
    db.SaveAll();
}

4.7. Пример исходных данных (data/*.json)

customers.json (фрагмент)
[
  { "Id": "d2f3a1c3-0000-0000-0000-000000000001", "FullName": "Иванов Иван", "Phone": "+7-900-000-00-01", "DocumentId": "AB123456" },
  { "Id": "d2f3a1c3-0000-0000-0000-000000000002", "FullName": "Петров Пётр", "Phone": "+7-900-000-00-02" }
]
items.json (фрагмент)
[
  { "Id": "e1e1e1e1-0000-0000-0000-000000000001", "InvNumber": "INV-0001", "Category": "Bicycle", "Condition": "Good", "Status": "available" },
  { "Id": "e1e1e1e1-0000-0000-0000-000000000002", "InvNumber": "INV-0002", "Category": "Skates", "Condition": "Good", "Status": "available" }
]
tariffs.json (фрагмент)
[
  { "Id": "f1f1f1f1-0000-0000-0000-000000000001", "Name": "Часовой", "Mode": "hour", "Rate": 300, "RoundRule": "ceil_30min" },
  { "Id": "f1f1f1f1-0000-0000-0000-000000000002", "Name": "Суточный", "Mode": "day", "Rate": 1200 }
]

Примечание: GUID’ы можно не фиксировать руками — код их сгенерирует; значения приведены для ориентира.

4.8. Описание интерфейса пользователя

Консольный вариант (базовый для дисциплины «Основы программирования»)

Windows Forms (вариант для сильных студентов)

4.9. Замечания по качеству кода

4.10. Пример готового приложения

https://github.com/OlgaKraven/VO.PP.PM.04-testProject.git