Dev #59
@@ -13,4 +13,5 @@ public interface IStoreRepository
|
||||
void Update(Models.Store entity);
|
||||
void Remove(Models.Store entity);
|
||||
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
|
||||
Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/analytics")]
|
||||
public async Task<IActionResult> GetAnalytics(Guid id, [FromQuery] int months = 12, [FromQuery] int overdueDays = 30)
|
||||
{
|
||||
var result = await service.GetAnalyticsAsync(id, months, overdueDays);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -59,3 +59,40 @@ public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
||||
public record PatchStoreStatusRequest(bool IsActive);
|
||||
|
||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||
|
||||
public record StoreAnalyticsSummary(
|
||||
decimal TotalSales,
|
||||
decimal TotalCollected,
|
||||
decimal Outstanding,
|
||||
decimal OverdueAmount,
|
||||
int OverdueInvoiceCount,
|
||||
int InvoiceCount,
|
||||
decimal AverageOrderValue,
|
||||
DateTime? LastSaleDate,
|
||||
decimal CreditLimit,
|
||||
decimal CreditUtilizationPercent);
|
||||
|
||||
public record StoreMonthlySales(string Month, decimal Invoiced, decimal Collected, decimal Outstanding);
|
||||
|
||||
public record StoreTopProduct(Guid ItemId, string Name, int Quantity, decimal Revenue, DateTime LastSoldAt);
|
||||
|
||||
public record StoreAgeingBucket(string Label, decimal Amount, int InvoiceCount);
|
||||
|
||||
public record StoreAnalyticsInvoice(
|
||||
Guid InvoiceId,
|
||||
DateTime InvoiceDate,
|
||||
DateTime DueDate,
|
||||
decimal TotalAmount,
|
||||
decimal PaidAmount,
|
||||
decimal Balance,
|
||||
string PaymentStatus,
|
||||
int DaysOverdue);
|
||||
|
||||
public record StoreAnalyticsResponse(
|
||||
Guid StoreId,
|
||||
int OverdueDays,
|
||||
StoreAnalyticsSummary Summary,
|
||||
List<StoreMonthlySales> MonthlySales,
|
||||
List<StoreTopProduct> TopProducts,
|
||||
List<StoreAgeingBucket> Ageing,
|
||||
List<StoreAnalyticsInvoice> Invoices);
|
||||
|
||||
@@ -51,4 +51,12 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
|
||||
await context.ShopPOs
|
||||
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId) =>
|
||||
await context.Invoices
|
||||
.AsNoTracking()
|
||||
.Include(i => i.Payments)
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items).ThenInclude(x => x.Item)
|
||||
.Where(i => i.IsActive && i.Status != InvoiceStatus.Draft && i.ShopPO.FK_StoreId == storeId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -253,4 +253,92 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
orders.Sum(o => o.TotalAmount),
|
||||
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
|
||||
}
|
||||
|
||||
public async Task<StoreAnalyticsResponse?> GetAnalyticsAsync(Guid id, int months, int overdueDays)
|
||||
{
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
|
||||
months = Math.Clamp(months, 1, 36);
|
||||
overdueDays = Math.Clamp(overdueDays, 0, 365);
|
||||
var now = DateTime.Now;
|
||||
var from = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1));
|
||||
|
||||
var invoices = (await repo.GetInvoicesForAnalyticsAsync(id))
|
||||
.Select(i =>
|
||||
{
|
||||
var date = i.FinalizedAt ?? i.CreatedAt;
|
||||
var paid = i.Payments.Where(p => p.IsActive).Sum(p => p.Amount);
|
||||
var balance = Math.Max(0, i.TotalAmount - paid);
|
||||
var due = date.AddDays(overdueDays);
|
||||
var daysOverdue = balance > 0 && now > due ? (int)(now - due).TotalDays : 0;
|
||||
return (Invoice: i, Date: date, Due: due, Paid: paid, Balance: balance, DaysOverdue: daysOverdue);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var inRange = invoices.Where(x => x.Date >= from).ToList();
|
||||
var totalSales = inRange.Sum(x => x.Invoice.TotalAmount);
|
||||
var overdue = invoices.Where(x => x.DaysOverdue > 0).ToList();
|
||||
|
||||
var summary = new StoreAnalyticsSummary(
|
||||
totalSales,
|
||||
inRange.Sum(x => x.Paid),
|
||||
invoices.Sum(x => x.Balance),
|
||||
overdue.Sum(x => x.Balance),
|
||||
overdue.Count,
|
||||
inRange.Count,
|
||||
inRange.Count == 0 ? 0 : Math.Round(totalSales / inRange.Count, 2),
|
||||
invoices.Count == 0 ? null : invoices.Max(x => x.Date),
|
||||
store.CreditLimit,
|
||||
store.CreditLimit > 0 ? Math.Round(store.OutstandingBalance / store.CreditLimit * 100, 1) : 0);
|
||||
|
||||
var monthly = Enumerable.Range(0, months)
|
||||
.Select(n => from.AddMonths(n))
|
||||
.Select(m => new StoreMonthlySales(
|
||||
m.ToString("yyyy-MM"),
|
||||
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Invoice.TotalAmount),
|
||||
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Paid),
|
||||
invoices
|
||||
.Where(x => x.Date < m.AddMonths(1))
|
||||
.Sum(x => Math.Max(0, x.Invoice.TotalAmount - x.Invoice.Payments.Where(p => p.IsActive && p.CollectedAt < m.AddMonths(1)).Sum(p => p.Amount)))))
|
||||
.ToList();
|
||||
|
||||
var topProducts = inRange
|
||||
.SelectMany(x => x.Invoice.ShopPO.Items.Select(it => (it, x.Date)))
|
||||
.GroupBy(t => t.it.FK_ItemId)
|
||||
.Select(g => new StoreTopProduct(
|
||||
g.Key,
|
||||
g.First().it.Item?.Name ?? "Unknown",
|
||||
g.Sum(t => t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity),
|
||||
g.Sum(t => (t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity) * t.it.UnitPrice),
|
||||
g.Max(t => t.Date)))
|
||||
.OrderByDescending(p => p.Revenue)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
var unpaid = invoices.Where(x => x.Balance > 0).ToList();
|
||||
StoreAgeingBucket Bucket(string label, Func<int, bool> match)
|
||||
{
|
||||
var items = unpaid.Where(x => match(x.DaysOverdue)).ToList();
|
||||
return new StoreAgeingBucket(label, items.Sum(x => x.Balance), items.Count);
|
||||
}
|
||||
var ageing = new List<StoreAgeingBucket>
|
||||
{
|
||||
Bucket("Current", d => d <= 0),
|
||||
Bucket("1-30", d => d is >= 1 and <= 30),
|
||||
Bucket("31-60", d => d is >= 31 and <= 60),
|
||||
Bucket("61-90", d => d is >= 61 and <= 90),
|
||||
Bucket("90+", d => d > 90)
|
||||
};
|
||||
|
||||
var list = invoices
|
||||
.OrderByDescending(x => x.Date)
|
||||
.Select(x => new StoreAnalyticsInvoice(
|
||||
x.Invoice.Id, x.Date, x.Due, x.Invoice.TotalAmount, x.Paid, x.Balance,
|
||||
x.Invoice.PaymentStatus.ToString(), x.DaysOverdue))
|
||||
.ToList();
|
||||
|
||||
return new StoreAnalyticsResponse(id, overdueDays, summary, monthly, topProducts, ageing, list);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user