Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Modules/Contracts/Sales/CustomerWithOverdueOrdersData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Contracts.Sales;

/// <summary>
/// Customer information with overdue order statistics
/// </summary>
public sealed class CustomerWithOverdueOrdersData
{
/// <summary>
/// Customer display name (CompanyName if available, otherwise FirstName + LastName)
/// </summary>
public required string CustomerName { get; init; }

/// <summary>
/// Total count of overdue orders for this customer
/// </summary>
public int OverdueOrdersCount { get; init; }

/// <summary>
/// Due date of the oldest overdue order
/// </summary>
public DateTime OldestDueDate { get; init; }
}
10 changes: 10 additions & 0 deletions Modules/Contracts/Sales/ICustomerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,14 @@ public interface ICustomerService
CustomerData[] GetCustomersWithOrdersStartingWith(string prefix);

CustomerData[] GetCustomersWithOrdersContaining(string fragment);

/// <summary>
/// Retrieves customers that have at least one overdue order.
/// An order is overdue when DueDate &lt; Today and Status is not Shipped or Cancelled.
/// </summary>
/// <returns>
/// Array of customers with overdue order information, ordered by oldest overdue date ascending.
/// Returns empty array if no customers with overdue orders exist.
/// </returns>
CustomerWithOverdueOrdersData[] GetCustomersWithOverdueOrders();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using AppBoot.DependencyInjection;
using Contracts.ConsoleUi;
using Contracts.Sales;

namespace Sales.ConsoleCommands;

[Service(typeof(IConsoleCommand))]
internal sealed class CustomersWithOverdueOrdersConsoleCommand(IConsole console, ICustomerService customerService) : IConsoleCommand
{
public string MenuLabel => "Show customers with overdue orders";

public void Execute()
{
console.WriteLine("Retrieving customers with overdue orders...");

var customers = customerService.GetCustomersWithOverdueOrders();

if (customers.Length == 0)
{
console.WriteLine("No customers with overdue orders found.");
return;
}

console.WriteLine($"Found {customers.Length} customer(s) with overdue orders:\n");

foreach (var customer in customers)
{
console.WriteEntity(customer);
}
}
}
212 changes: 212 additions & 0 deletions Modules/Sales/Sales.Services.UnitTests/CustomerServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
using Contracts.Sales;
using Sales.DataModel.SalesLT;
using Sales.DataModel.Values;
using Sales.Services.UnitTests.Fakes;

namespace Sales.Services.UnitTests;

public class CustomerServiceTests
{
[Fact]
public void GetCustomersWithOverdueOrders_WithNoCustomers_ReturnsEmptyArray()
{
var target = GetTarget(new Customer[0]);

var result = target.GetCustomersWithOverdueOrders();

Assert.Empty(result);
}

[Fact]
public void GetCustomersWithOverdueOrders_WithNoOverdueOrders_ReturnsEmptyArray()
{
var customer = CreateCustomer(1, "Acme", "John", "Doe",
CreateOrder(1, DateTime.Today.AddDays(10), SalesOrderHeaderStatusValues.InProcess));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Empty(result);
}

[Fact]
public void GetCustomersWithOverdueOrders_WithOneCustomerOneOverdueOrder_ReturnsCustomer()
{
var dueDate = DateTime.Today.AddDays(-5);
var customer = CreateCustomer(1, "Acme Corp", "John", "Doe",
CreateOrder(1, dueDate, SalesOrderHeaderStatusValues.InProcess));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Single(result);
Assert.Equal("Acme Corp", result[0].CustomerName);
Assert.Equal(1, result[0].OverdueOrdersCount);
Assert.Equal(dueDate, result[0].OldestDueDate);
}

[Fact]
public void GetCustomersWithOverdueOrders_WithMultipleCustomers_ReturnsSortedByOldestDueDate()
{
var customer1 = CreateCustomer(1, "Beta Inc", "Jane", "Smith",
CreateOrder(1, DateTime.Today.AddDays(-3), SalesOrderHeaderStatusValues.InProcess));
var customer2 = CreateCustomer(2, "Alpha Corp", "Bob", "Jones",
CreateOrder(2, DateTime.Today.AddDays(-10), SalesOrderHeaderStatusValues.Approved));
var customer3 = CreateCustomer(3, "Gamma LLC", "Alice", "Brown",
CreateOrder(3, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.Backordered));
var target = GetTarget(new[] { customer1, customer2, customer3 });

var result = target.GetCustomersWithOverdueOrders();

Assert.Equal(3, result.Length);
Assert.Equal("Alpha Corp", result[0].CustomerName);
Assert.Equal("Gamma LLC", result[1].CustomerName);
Assert.Equal("Beta Inc", result[2].CustomerName);
}

[Fact]
public void GetCustomersWithOverdueOrders_ExcludesShippedOrders_WhenDueDatePassed()
{
var customer = CreateCustomer(1, "Test Co", "John", "Doe",
CreateOrder(1, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.Shipped));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Empty(result);
}

[Fact]
public void GetCustomersWithOverdueOrders_ExcludesCancelledOrders_WhenDueDatePassed()
{
var customer = CreateCustomer(1, "Test Co", "John", "Doe",
CreateOrder(1, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.Cancelled));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Empty(result);
}

[Fact]
public void GetCustomersWithOverdueOrders_IncludesInProcessOrders_WhenDueDatePassed()
{
var customer = CreateCustomer(1, "Test Co", "John", "Doe",
CreateOrder(1, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.InProcess));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Single(result);
}

[Fact]
public void GetCustomersWithOverdueOrders_ExcludesOrdersNotYetDue_EvenIfNotClosed()
{
var customer = CreateCustomer(1, "Test Co", "John", "Doe",
CreateOrder(1, DateTime.Today.AddDays(5), SalesOrderHeaderStatusValues.InProcess));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Empty(result);
}

[Fact]
public void GetCustomersWithOverdueOrders_CalculatesCountCorrectly_ForMultipleOverdueOrders()
{
var oldestDueDate = DateTime.Today.AddDays(-10);
var customer = CreateCustomer(1, "Test Co", "John", "Doe",
CreateOrder(1, oldestDueDate, SalesOrderHeaderStatusValues.InProcess),
CreateOrder(2, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.Approved),
CreateOrder(3, DateTime.Today.AddDays(-2), SalesOrderHeaderStatusValues.Backordered));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Single(result);
Assert.Equal(3, result[0].OverdueOrdersCount);
Assert.Equal(oldestDueDate, result[0].OldestDueDate);
}

[Fact]
public void GetCustomersWithOverdueOrders_UsesCompanyName_WhenAvailable()
{
var customer = CreateCustomer(1, "Acme Corporation", "John", "Doe",
CreateOrder(1, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.InProcess));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Single(result);
Assert.Equal("Acme Corporation", result[0].CustomerName);
}

[Fact]
public void GetCustomersWithOverdueOrders_CombinesFirstAndLastName_WhenCompanyNameNull()
{
var customer = CreateCustomer(1, null, "Jane", "Smith",
CreateOrder(1, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.InProcess));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Single(result);
Assert.Equal("Jane Smith", result[0].CustomerName);
}

[Fact]
public void GetCustomersWithOverdueOrders_MixedOrderStatuses_ReturnsOnlyOverdue()
{
var oldestOverdue = DateTime.Today.AddDays(-8);
var customer = CreateCustomer(1, "Test Co", "John", "Doe",
CreateOrder(1, oldestOverdue, SalesOrderHeaderStatusValues.InProcess),
CreateOrder(2, DateTime.Today.AddDays(-5), SalesOrderHeaderStatusValues.Shipped),
CreateOrder(3, DateTime.Today.AddDays(-3), SalesOrderHeaderStatusValues.Approved),
CreateOrder(4, DateTime.Today.AddDays(5), SalesOrderHeaderStatusValues.InProcess));
var target = GetTarget(new[] { customer });

var result = target.GetCustomersWithOverdueOrders();

Assert.Single(result);
Assert.Equal(2, result[0].OverdueOrdersCount);
Assert.Equal(oldestOverdue, result[0].OldestDueDate);
}

private static CustomerService GetTarget(Customer[] customers)
{
var repositoryStub = new FakeRepository(customers);
return new CustomerService(repositoryStub);
}

private static Customer CreateCustomer(int id, string? companyName, string firstName, string lastName, params SalesOrderHeader[] orders)
{
var customer = new Customer
{
CustomerID = id,
CompanyName = companyName,
FirstName = firstName,
LastName = lastName
};

foreach (var order in orders)
{
customer.SalesOrderHeaders.Add(order);
order.Customer = customer;
order.CustomerID = id;
}

return customer;
}

private static SalesOrderHeader CreateOrder(int id, DateTime dueDate, byte status)
{
return new SalesOrderHeader
{
SalesOrderID = id,
DueDate = dueDate,
Status = status,
OrderDate = DateTime.Today.AddDays(-30)
};
}
}
28 changes: 28 additions & 0 deletions Modules/Sales/Sales.Services.UnitTests/Fakes/FakeRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Sales.DataModel.SalesLT;

namespace Sales.Services.UnitTests.Fakes;

internal class FakeRepository : DataAccess.IRepository
{
private readonly List<Customer> _customers = new();

public FakeRepository(Customer[] customers)
{
_customers.AddRange(customers);
}

public IQueryable<TEntity> GetEntities<TEntity>() where TEntity : class
{
if (typeof(TEntity) == typeof(Customer))
{
return (IQueryable<TEntity>)_customers.AsQueryable();
}

throw new NotImplementedException($"Entity type {typeof(TEntity).Name} not supported in FakeRepository");
}

public DataAccess.IUnitOfWork CreateUnitOfWork()
{
throw new NotImplementedException("CreateUnitOfWork not needed for read-only tests");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit.v3" Version="3.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Sales.Services\Sales.Services.csproj" />
<ProjectReference Include="..\Sales.DataModel\Sales.DataModel.csproj" />
<ProjectReference Include="..\..\..\Infra\DataAccess\DataAccess.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
24 changes: 24 additions & 0 deletions Modules/Sales/Sales.Services/CustomerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Contracts.Sales;
using DataAccess;
using Sales.DataModel.SalesLT;
using Sales.DataModel.Values;

namespace Sales.Services;

Expand Down Expand Up @@ -52,4 +53,27 @@ public CustomerData[] GetCustomersWithOrdersContaining(string fragment)

return GetCustomersWithOrdersFilteredBy(filter);
}

public CustomerWithOverdueOrdersData[] GetCustomersWithOverdueOrders()
{
var today = DateTime.Today;
var closedStatuses = new[] { SalesOrderHeaderStatusValues.Shipped, SalesOrderHeaderStatusValues.Cancelled };

return repository.GetEntities<Customer>()
.Where(c => c.SalesOrderHeaders.Any(o =>
o.DueDate < today &&
!closedStatuses.Contains(o.Status)))
.Select(c => new CustomerWithOverdueOrdersData
{
CustomerName = c.CompanyName ?? $"{c.FirstName} {c.LastName}",
OverdueOrdersCount = c.SalesOrderHeaders
.Where(o => o.DueDate < today && !closedStatuses.Contains(o.Status))
.Count(),
OldestDueDate = c.SalesOrderHeaders
.Where(o => o.DueDate < today && !closedStatuses.Contains(o.Status))
.Min(o => o.DueDate)
})
.OrderBy(x => x.OldestDueDate)
.ToArray();
}
}
4 changes: 4 additions & 0 deletions Modules/Sales/Sales.Services/Sales.Services.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Sales.Services.UnitTests" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\Infra\AppBoot\AppBoot.csproj" />
<ProjectReference Include="..\..\..\Infra\DataAccess\DataAccess.csproj" />
Expand Down