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 |
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
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; }
}
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;
}
}
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"; // правило округления
}
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";
}
}
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; }
}
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);
}
}
Правило 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;
}
}
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");
}
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}");
}
}
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();
}
[
{ "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" }
]
[
{ "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" }
]
[
{ "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’ы можно не фиксировать руками — код их сгенерирует; значения приведены для ориентира.
null/string.Empty при вводе.