Files
ERP_Distribution/Distribution/Features/Store/StoreService.cs
T
Kalana B. Thilakarathna 5707bd4c31
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m30s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m1s
Implement store credit tracking and enforcement
Introduced StoreCreditMovement entity and migration to track store credit usage and settlements. Updated payment flow to enforce credit limits and record credit movements. Modified outstanding balance calculation to use credit movements. Enhanced frontend to display real-time credit info and prevent over-credit payments. Updated API types, seed data, and DI registrations accordingly.
2026-10-20 11:45:11 +05:30

347 lines
14 KiB
C#

using Distribution.Features.Scheme;
using Distribution.Features.Stock;
using Distribution.Features.StoreCredit;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
private async Task<bool> HasGlobalScopeAsync() => await currentUser.GetRoleLevelAsync() >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
//if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
//return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
return true;
}
private static StoreResponse ToResponse(Models.Store s) =>
new(s.Id, s.FK_RouteId, s.OwnerName, s.ContactNumber, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
s.CreditLimit, s.OutstandingBalance, s.Segment, s.IsActive, s.CreatedAt);
public async Task<PagedResponse<StoreResponse>> GetAllAsync(
Guid? routeId, string? search, int page, int pageSize)
{
// Deactivated stores stay visible in the list showing Inactive — deactivate never
// removes the row, only marks it (same rule as Orders, see testbug.md #42).
var total = await repo.GetCountAsync(routeId, search);
var stores = await repo.GetAllAsync(routeId, search, page, pageSize);
return new PagedResponse<StoreResponse>(page, pageSize, total, stores.Select(ToResponse).ToList());
}
public async Task<StoreResponse?> GetByIdAsync(Guid id)
{
var store = await repo.GetByIdAsync(id);
if (store is null) return null;
//if (!await HasStoreScopeAsync(id)) return null;
return ToResponse(store);
}
public async Task<StoreResponse> CreateAsync(CreateStoreRequest request, CancellationToken ct = default)
{
var store = new Models.Store
{
FK_RouteId = request.RouteId,
OwnerName = request.OwnerName,
ContactNumber = request.ContactNumber,
StoreName = request.StoreName,
StoreAddress = request.StoreAddress,
Latitude = request.Latitude,
Longitude = request.Longitude,
OneTimeAmount = request.OneTimeAmount,
CreditAmount = request.CreditAmount,
CreditLimit = request.CreditLimit
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> UpdateAsync(Guid id, UpdateStoreRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.FK_RouteId = request.RouteId;
store.OwnerName = request.OwnerName;
store.ContactNumber = request.ContactNumber;
store.StoreName = request.StoreName;
store.StoreAddress = request.StoreAddress;
store.Latitude = request.Latitude;
store.Longitude = request.Longitude;
store.OneTimeAmount = request.OneTimeAmount;
store.CreditAmount = request.CreditAmount;
if (request.CreditLimit is not null) store.CreditLimit = request.CreditLimit.Value;
store.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return false;
if (!await HasStoreScopeAsync(id)) return false;
await uow.BeginAsync(ct);
try
{
repo.Remove(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
{
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.CreditLimit = request.NewLimit;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
{
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.OutstandingBalance = request.NewBalance;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<OutstandingBalanceResponse?> GetOutstandingBalanceAsync(Guid id)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
var outstanding = await creditRepo.GetOutstandingForStoreAsync(id);
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, outstanding, store.CreditLimit - outstanding);
}
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.Segment = request.Segment;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> SetActiveStatusAsync(Guid id, bool isActive, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.IsActive = isActive;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<List<StoreVisitHistoryItem>?> GetVisitHistoryAsync(Guid id)
{
var store = await repo.GetByIdAsync(id);
if (store is null) return null;
if (!await HasStoreScopeAsync(id)) return null;
var visits = await routeRepo.GetVisitsByStoreIdAsync(id);
return visits
.OrderByDescending(v => v.CheckInAt)
.Select(v => new StoreVisitHistoryItem(v.Id, v.CheckInAt, v.FK_UserId, v.CheckOutAt is null ? "In Progress" : "Completed"))
.ToList();
}
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
var result = await schemeService.CheckStoreScopeEligibilityAsync(id, schemeId);
if (result is null) return null;
return new SchemeEligibilityResponse(result.Value.Eligible, result.Value.Reason);
}
public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
var periodEndExclusive = periodStart.AddMonths(1);
var orders = await repo.GetOrdersForDashboardAsync(id, periodStart, periodEndExclusive);
return new StoreDashboardResponse(
id,
periodStart.ToString("yyyy-MM"),
orders.Count,
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);
}
}