481 lines
20 KiB
C#
481 lines
20 KiB
C#
using Indotalent.Infrastructure.Database;
|
|
using Indotalent.Data.Entities;
|
|
using Indotalent.Data.Enums;
|
|
using MediatR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Indotalent.Features.Root.Home.Cqrs;
|
|
|
|
// Chart data
|
|
public class ChartDataPoint
|
|
{
|
|
public string? Label { get; set; }
|
|
public double SalesAmount { get; set; }
|
|
public double PurchaseAmount { get; set; }
|
|
}
|
|
|
|
// WMS-specific DTOs
|
|
public class WarehouseKpiDto
|
|
{
|
|
public string? WarehouseName { get; set; }
|
|
public double StockQty { get; set; }
|
|
public int SkuCount { get; set; }
|
|
public decimal InventoryValue { get; set; }
|
|
public string? Color { get; set; }
|
|
}
|
|
|
|
public class WmsModuleBreakdownDto
|
|
{
|
|
public string? Label { get; set; }
|
|
public int Count { get; set; }
|
|
public double Quantity { get; set; }
|
|
public string? Color { get; set; }
|
|
}
|
|
|
|
public class WmsPipelineSummaryDto
|
|
{
|
|
public string? Label { get; set; }
|
|
public string? Value { get; set; }
|
|
public string? IconColorClass { get; set; }
|
|
}
|
|
|
|
public class WmsConversionMetricDto
|
|
{
|
|
public string? Label { get; set; }
|
|
public double Current { get; set; }
|
|
public double Target { get; set; }
|
|
public double PctValue { get; set; }
|
|
public string? BarColor { get; set; }
|
|
}
|
|
|
|
public class WmsRecentTransactionDto
|
|
{
|
|
public string? Number { get; set; }
|
|
public string? Description { get; set; }
|
|
public string? Module { get; set; }
|
|
public double Quantity { get; set; }
|
|
public string? Type { get; set; }
|
|
public string? Status { get; set; }
|
|
public DateTimeOffset? Date { get; set; }
|
|
}
|
|
|
|
public class WmsActivityFeedDto
|
|
{
|
|
public string? Title { get; set; }
|
|
public string? Detail { get; set; }
|
|
public string? TimeAgo { get; set; }
|
|
public string? ChipLabel { get; set; }
|
|
public string? ChipColor { get; set; }
|
|
public string? AvatarBg { get; set; }
|
|
public string? AvatarText { get; set; }
|
|
}
|
|
|
|
public class WmsStockMovementPointDto
|
|
{
|
|
public string? Label { get; set; }
|
|
public double InQty { get; set; }
|
|
public double OutQty { get; set; }
|
|
}
|
|
|
|
public class WmsYearlyMovementDto
|
|
{
|
|
public string? Category { get; set; }
|
|
public double Qty { get; set; }
|
|
public string? Color { get; set; }
|
|
}
|
|
|
|
public class WmsTopProductDto
|
|
{
|
|
public string? ProductName { get; set; }
|
|
public double TotalMovement { get; set; }
|
|
public string? Color { get; set; }
|
|
}
|
|
|
|
public class DashboardWmsResponse
|
|
{
|
|
// Core KPIs
|
|
public double TotalStockQty { get; set; }
|
|
public decimal InventoryValue { get; set; }
|
|
public int TotalSku { get; set; }
|
|
public int WarehouseCount { get; set; }
|
|
public double StockAccuracy { get; set; }
|
|
public double OrderFulfillmentRate { get; set; }
|
|
|
|
// Monthly movement
|
|
public double MonthlyInboundQty { get; set; }
|
|
public double MonthlyOutboundQty { get; set; }
|
|
public int MonthlyTransactionCount { get; set; }
|
|
|
|
// Yearly
|
|
public double YearlyInboundQty { get; set; }
|
|
public double YearlyOutboundQty { get; set; }
|
|
|
|
// Counts
|
|
public int PendingInbound { get; set; }
|
|
public int PendingOutbound { get; set; }
|
|
public int ScrapCount { get; set; }
|
|
public int AdjustmentCount { get; set; }
|
|
public int GrCount { get; set; }
|
|
public int DoCount { get; set; }
|
|
public int TransferCount { get; set; }
|
|
public int StockCountCount { get; set; }
|
|
|
|
// Collections
|
|
public List<WarehouseKpiDto> WarehouseKpis { get; set; } = new();
|
|
public List<WmsModuleBreakdownDto> ModuleBreakdown { get; set; } = new();
|
|
public List<WmsPipelineSummaryDto> PipelineSummary { get; set; } = new();
|
|
public List<WmsConversionMetricDto> ConversionMetrics { get; set; } = new();
|
|
public List<WmsRecentTransactionDto> RecentTransactions { get; set; } = new();
|
|
public List<WmsActivityFeedDto> ActivityFeeds { get; set; } = new();
|
|
public List<ChartDataPoint> RevenueTrend { get; set; } = new();
|
|
public List<WmsStockMovementPointDto> StockMovementTrend { get; set; } = new();
|
|
public List<WmsYearlyMovementDto> YearlyMovement { get; set; } = new();
|
|
public List<WmsTopProductDto> TopProducts { get; set; } = new();
|
|
|
|
// Formatted
|
|
public string FormattedStock => FormatNumber((decimal)TotalStockQty);
|
|
public string FormattedInvValue => FormatNumber(InventoryValue);
|
|
public string FormattedMonthlyIn => FormatNumber((decimal)MonthlyInboundQty);
|
|
public string FormattedMonthlyOut => FormatNumber((decimal)MonthlyOutboundQty);
|
|
|
|
private static string FormatNumber(decimal v)
|
|
{
|
|
if (v >= 1_000_000) return (v / 1_000_000m).ToString("F2") + "M";
|
|
if (v >= 1_000) return (v / 1_000m).ToString("F1") + "K";
|
|
return v.ToString("N0");
|
|
}
|
|
}
|
|
|
|
public record GetWmsDashboardQuery() : IRequest<DashboardWmsResponse>;
|
|
|
|
public class DashboardHandler : IRequestHandler<GetWmsDashboardQuery, DashboardWmsResponse>
|
|
{
|
|
private readonly AppDbContext _context;
|
|
|
|
public DashboardHandler(AppDbContext context) => _context = context;
|
|
|
|
public async Task<DashboardWmsResponse> Handle(GetWmsDashboardQuery request, CancellationToken ct)
|
|
{
|
|
var today = DateTime.Today;
|
|
var monthStart = new DateTime(today.Year, today.Month, 1);
|
|
var yearStart = new DateTime(today.Year, 1, 1);
|
|
|
|
// 1. Top 1000 recent confirmed inventory transactions (with product & warehouse)
|
|
var topTxs = await _context.InventoryTransaction
|
|
.AsNoTracking()
|
|
.Where(x => x.ProductId != null && x.WarehouseId != null && x.Status == InventoryTransactionStatus.Confirmed)
|
|
.OrderByDescending(x => x.CreatedAt)
|
|
.Take(1000)
|
|
.Select(x => new
|
|
{
|
|
x.ModuleCode,
|
|
x.ModuleNumber,
|
|
x.ModuleName,
|
|
x.MovementDate,
|
|
x.Movement,
|
|
x.TransType,
|
|
x.Stock,
|
|
x.CreatedAt,
|
|
ProductId = x.ProductId!,
|
|
ProductName = x.Product!.Name,
|
|
ProductNumber = x.Product!.AutoNumber,
|
|
UnitPrice = x.Product!.UnitPrice ?? 0,
|
|
WarehouseId = x.WarehouseId!,
|
|
WarehouseName = x.Warehouse!.Name
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
// Fallback: if table is empty, use count-based metrics from entity tables
|
|
var grCount = await _context.GoodsReceive.AsNoTracking().CountAsync(ct);
|
|
var doCount = await _context.DeliveryOrder.AsNoTracking().CountAsync(ct);
|
|
var scrapCount = await _context.Scrapping.AsNoTracking().CountAsync(ct);
|
|
var adjCount = await _context.PositiveAdjustment.AsNoTracking().CountAsync(ct)
|
|
+ await _context.NegativeAdjustment.AsNoTracking().CountAsync(ct);
|
|
var transferCount = await _context.TransferIn.AsNoTracking().CountAsync(ct);
|
|
var stockCountCount = await _context.StockCount.AsNoTracking().CountAsync(ct);
|
|
var pendingIn = await _context.GoodsReceive.AsNoTracking().CountAsync(x => x.Status == GoodsReceiveStatus.Draft, ct);
|
|
var pendingOut = await _context.DeliveryOrder.AsNoTracking().CountAsync(x => x.Status == DeliveryOrderStatus.Draft, ct);
|
|
|
|
// 2. Aggregate from top transactions
|
|
var totalStockQty = topTxs.Sum(x => x.Stock ?? 0);
|
|
var totalSku = topTxs.Select(x => x.ProductId).Distinct().Count();
|
|
var warehouseCount = topTxs.Select(x => x.WarehouseId).Distinct().Count();
|
|
var inventoryValue = topTxs.Sum(x => (decimal)(x.Stock ?? 0) * x.UnitPrice);
|
|
|
|
// ModuleCode uses SHORT codes: GR, DO, ADJ+, ADJ-, COUNT, SCRP, TO-IN, TO-OUT, SRN, PRN
|
|
var inboundModules = new HashSet<string> { "GR", "ADJ+", "TO-IN", "PRN" };
|
|
var outboundModules = new HashSet<string> { "DO", "ADJ-", "TO-OUT", "SCRP", "SRN" };
|
|
|
|
bool IsInbound(string? mc) => mc != null && inboundModules.Contains(mc);
|
|
bool IsOutbound(string? mc) => mc != null && outboundModules.Contains(mc);
|
|
|
|
var monthlyInbound = topTxs
|
|
.Where(x => x.MovementDate >= monthStart && IsInbound(x.ModuleCode))
|
|
.Sum(x => Math.Abs(x.Movement ?? 0));
|
|
var monthlyOutbound = topTxs
|
|
.Where(x => x.MovementDate >= monthStart && IsOutbound(x.ModuleCode))
|
|
.Sum(x => Math.Abs(x.Movement ?? 0));
|
|
var monthlyTxCount = topTxs.Count(x => x.MovementDate >= monthStart);
|
|
|
|
var yearlyInbound = topTxs
|
|
.Where(x => x.MovementDate >= yearStart && IsInbound(x.ModuleCode))
|
|
.Sum(x => Math.Abs(x.Movement ?? 0));
|
|
var yearlyOutbound = topTxs
|
|
.Where(x => x.MovementDate >= yearStart && IsOutbound(x.ModuleCode))
|
|
.Sum(x => Math.Abs(x.Movement ?? 0));
|
|
|
|
// 3. Stock Accuracy from StockCount transactions (ModuleCode = "COUNT")
|
|
var countTxs = topTxs.Where(x => x.ModuleCode == "COUNT").ToList();
|
|
var stockAccuracy = 100.0;
|
|
if (countTxs.Any())
|
|
{
|
|
var totalSys = countTxs.Sum(x => x.Stock ?? 0);
|
|
var totalDelta = countTxs.Sum(x => Math.Abs((x.Movement ?? 0) - (x.Stock ?? 0)));
|
|
stockAccuracy = totalSys > 0 ? Math.Max(0, Math.Round(100 - (totalDelta / totalSys * 100), 1)) : 100;
|
|
}
|
|
|
|
// 4. Order Fulfillment
|
|
var totalDo = await _context.DeliveryOrder.AsNoTracking().CountAsync(x => x.Status != DeliveryOrderStatus.Draft, ct);
|
|
var doConfirmed = await _context.DeliveryOrder.AsNoTracking().CountAsync(x => x.Status == DeliveryOrderStatus.Confirmed, ct);
|
|
var fulfPct = totalDo > 0 ? Math.Round((double)doConfirmed / totalDo * 100, 1) : 0;
|
|
|
|
// 5. Warehouse KPIs (group by warehouse from top txs)
|
|
var whColors = new[] { "#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4" };
|
|
var warehouseKpis = topTxs
|
|
.GroupBy(x => new { x.WarehouseId, x.WarehouseName })
|
|
.Select((g, i) => new WarehouseKpiDto
|
|
{
|
|
WarehouseName = g.Key.WarehouseName ?? "Unknown",
|
|
StockQty = g.Sum(x => x.Stock ?? 0),
|
|
SkuCount = g.Select(x => x.ProductId).Distinct().Count(),
|
|
InventoryValue = g.Sum(x => (decimal)(x.Stock ?? 0) * x.UnitPrice),
|
|
Color = whColors[i % whColors.Length]
|
|
})
|
|
.OrderByDescending(x => x.StockQty)
|
|
.ToList();
|
|
|
|
// 6. Module breakdown (by count) — ModuleCode uses SHORT codes
|
|
var moduleColors = new Dictionary<string, string>
|
|
{
|
|
["GR"] = "#06b6d4", ["DO"] = "#10b981",
|
|
["ADJ+"] = "#6366f1", ["ADJ-"] = "#ef4444",
|
|
["COUNT"] = "#f59e0b", ["SCRP"] = "#dc2626",
|
|
["TO-IN"] = "#8b5cf6", ["TO-OUT"] = "#f43f5e",
|
|
["PRN"] = "#0891b2", ["SRN"] = "#ea580c"
|
|
};
|
|
|
|
var moduleBreakdown = topTxs
|
|
.GroupBy(x => x.ModuleCode ?? "Other")
|
|
.Select(g => new WmsModuleBreakdownDto
|
|
{
|
|
Label = g.Key,
|
|
Count = g.Count(),
|
|
Quantity = g.Sum(x => Math.Abs(x.Movement ?? 0)),
|
|
Color = moduleColors.TryGetValue(g.Key, out var c) ? c : "#94a3b8"
|
|
})
|
|
.OrderByDescending(x => x.Count)
|
|
.ToList();
|
|
|
|
if (!moduleBreakdown.Any())
|
|
{
|
|
moduleBreakdown = new List<WmsModuleBreakdownDto>
|
|
{
|
|
new() { Label = "GR", Count = grCount, Color = "#06b6d4" },
|
|
new() { Label = "DO", Count = doCount, Color = "#10b981" },
|
|
new() { Label = "ADJ", Count = adjCount, Color = "#6366f1" },
|
|
new() { Label = "SCRP", Count = scrapCount, Color = "#dc2626" },
|
|
new() { Label = "TO", Count = transferCount, Color = "#8b5cf6" }
|
|
};
|
|
}
|
|
|
|
// 7. Pipeline Summary
|
|
var pipelineSummary = new List<WmsPipelineSummaryDto>
|
|
{
|
|
new() { Label = "Total Stock Qty", Value = FormattedSingle((decimal)totalStockQty), IconColorClass = "gc1" },
|
|
new() { Label = "Inventory Value", Value = FormattedSingle(inventoryValue), IconColorClass = "gc2" },
|
|
new() { Label = "Active SKUs", Value = totalSku.ToString("N0"), IconColorClass = "gc3" },
|
|
new() { Label = "Warehouses", Value = warehouseCount.ToString(), IconColorClass = "gc4" }
|
|
};
|
|
|
|
// 8. Conversion Metrics
|
|
var conversionMetrics = new List<WmsConversionMetricDto>
|
|
{
|
|
new() { Label = "GR→Stock", Current = grCount, Target = Math.Max(grCount, 1), PctValue = grCount > 0 ? 100 : 0, BarColor = "#06b6d4" },
|
|
new() { Label = "DO Fulfilled", Current = doConfirmed, Target = Math.Max(totalDo, 1), PctValue = fulfPct, BarColor = "#10b981" },
|
|
new() { Label = "Stock Accuracy", Current = stockAccuracy, Target = 100, PctValue = stockAccuracy, BarColor = "#6366f1" },
|
|
new() { Label = "Pending Clear", Current = totalDo - pendingOut, Target = Math.Max(totalDo, 1), PctValue = totalDo > 0 ? (double)(totalDo - pendingOut) / totalDo * 100 : 0, BarColor = "#f59e0b" }
|
|
};
|
|
|
|
// 9. Recent Transactions (top 5 from our top-1000 set)
|
|
var recentTransactions = topTxs
|
|
.OrderByDescending(x => x.CreatedAt)
|
|
.Take(5)
|
|
.Select(x => new WmsRecentTransactionDto
|
|
{
|
|
Number = x.ModuleNumber,
|
|
Description = x.ProductName ?? "?",
|
|
Module = x.ModuleCode,
|
|
Quantity = Math.Abs(x.Movement ?? 0),
|
|
Type = x.TransType?.ToString() ?? "-",
|
|
Status = "Confirmed",
|
|
Date = x.CreatedAt
|
|
}).ToList();
|
|
|
|
// 10. Activity Feed (from top transactions, most recent 4) — filter to key module types
|
|
var activityModules = new[] { "GR", "DO", "SCRP", "TO-IN", "COUNT" };
|
|
var avatarColors = new[] { "#06b6d4", "#10b981", "#dc2626", "#8b5cf6", "#6366f1" };
|
|
var activityFeeds = topTxs
|
|
.Where(x => x.ModuleCode != null && activityModules.Contains(x.ModuleCode))
|
|
.OrderByDescending(x => x.CreatedAt)
|
|
.Take(4)
|
|
.Select((x, i) =>
|
|
{
|
|
var pn = x.ProductName ?? "?";
|
|
var mc = x.ModuleCode ?? "?";
|
|
var timeAgo = x.CreatedAt.HasValue
|
|
? (DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalMinutes < 60
|
|
? $"{(int)(DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalMinutes}m ago"
|
|
: (DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalHours < 24
|
|
? $"{(int)(DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalHours}h ago"
|
|
: $"{(int)(DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalDays}d ago"
|
|
: "";
|
|
return new WmsActivityFeedDto
|
|
{
|
|
Title = $"{mc} — {pn}",
|
|
Detail = $"Qty: {Math.Abs(x.Movement ?? 0):N0} · {x.WarehouseName}",
|
|
TimeAgo = timeAgo,
|
|
ChipLabel = mc,
|
|
ChipColor = i % 2 == 0 ? "chip-cyan" : "chip-amber",
|
|
AvatarBg = avatarColors[i % avatarColors.Length],
|
|
AvatarText = mc
|
|
};
|
|
}).ToList();
|
|
|
|
// 11. Stock Movement Trend (last 6 months from top transactions)
|
|
var monthNames = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
|
var last6Months = Enumerable.Range(0, 6)
|
|
.Select(i => today.AddMonths(-5 + i))
|
|
.Select(d => new { d.Year, d.Month })
|
|
.ToList();
|
|
|
|
var stockMovementTrend = last6Months.Select(m =>
|
|
{
|
|
var inQty = topTxs
|
|
.Where(x => x.MovementDate.HasValue
|
|
&& x.MovementDate.Value.Year == m.Year
|
|
&& x.MovementDate.Value.Month == m.Month
|
|
&& IsInbound(x.ModuleCode))
|
|
.Sum(x => Math.Abs(x.Movement ?? 0));
|
|
var outQty = topTxs
|
|
.Where(x => x.MovementDate.HasValue
|
|
&& x.MovementDate.Value.Year == m.Year
|
|
&& x.MovementDate.Value.Month == m.Month
|
|
&& IsOutbound(x.ModuleCode))
|
|
.Sum(x => Math.Abs(x.Movement ?? 0));
|
|
return new WmsStockMovementPointDto
|
|
{
|
|
Label = monthNames[m.Month - 1],
|
|
InQty = inQty,
|
|
OutQty = outQty
|
|
};
|
|
}).ToList();
|
|
|
|
// 12. SO vs PO revenue trend (from SalesOrder/PurchaseOrder tables, as in CRM)
|
|
var soMonths = await _context.SalesOrder.AsNoTracking()
|
|
.Where(x => x.OrderDate != null)
|
|
.Select(x => x.OrderDate!.Value)
|
|
.ToListAsync(ct);
|
|
var poMonths = await _context.PurchaseOrder.AsNoTracking()
|
|
.Where(x => x.OrderDate != null)
|
|
.Select(x => x.OrderDate!.Value)
|
|
.ToListAsync(ct);
|
|
var allDates = soMonths.Concat(poMonths)
|
|
.Select(d => new { d.Year, d.Month })
|
|
.Distinct()
|
|
.OrderByDescending(x => x.Year).ThenByDescending(x => x.Month)
|
|
.Take(6)
|
|
.Reverse()
|
|
.ToList();
|
|
|
|
var revenueTrend = new List<ChartDataPoint>();
|
|
foreach (var ym in allDates)
|
|
{
|
|
var ms = new DateTime(ym.Year, ym.Month, 1);
|
|
var me = ms.AddMonths(1);
|
|
var sa = await _context.SalesOrder.AsNoTracking()
|
|
.Where(x => x.OrderDate >= ms && x.OrderDate < me)
|
|
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
|
var pa = await _context.PurchaseOrder.AsNoTracking()
|
|
.Where(x => x.OrderDate >= ms && x.OrderDate < me)
|
|
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
|
revenueTrend.Add(new ChartDataPoint
|
|
{
|
|
Label = monthNames[ym.Month - 1],
|
|
SalesAmount = Math.Round(sa / 1000.0, 1),
|
|
PurchaseAmount = Math.Round(pa / 1000.0, 1)
|
|
});
|
|
}
|
|
|
|
// 13. Yearly Movement summary
|
|
var yearlyMovement = new List<WmsYearlyMovementDto>
|
|
{
|
|
new() { Category = "Inbound", Qty = yearlyInbound, Color = "#10b981" },
|
|
new() { Category = "Outbound", Qty = yearlyOutbound, Color = "#e11d48" },
|
|
new() { Category = "Net", Qty = yearlyInbound - yearlyOutbound, Color = "#6366f1" }
|
|
};
|
|
|
|
// 14. Top Products by total movement volume
|
|
var topProducts = topTxs
|
|
.GroupBy(x => x.ProductName ?? "?")
|
|
.Select(g => new { Name = g.Key, Total = g.Sum(x => Math.Abs(x.Movement ?? 0)) })
|
|
.OrderByDescending(x => x.Total)
|
|
.Take(5)
|
|
.Select((x, i) => new WmsTopProductDto
|
|
{
|
|
ProductName = x.Name,
|
|
TotalMovement = x.Total,
|
|
Color = whColors[i % whColors.Length]
|
|
})
|
|
.ToList();
|
|
|
|
return new DashboardWmsResponse
|
|
{
|
|
TotalStockQty = totalStockQty,
|
|
InventoryValue = inventoryValue,
|
|
TotalSku = totalSku,
|
|
WarehouseCount = warehouseCount,
|
|
StockAccuracy = stockAccuracy,
|
|
OrderFulfillmentRate = fulfPct,
|
|
MonthlyInboundQty = monthlyInbound,
|
|
MonthlyOutboundQty = monthlyOutbound,
|
|
MonthlyTransactionCount = monthlyTxCount,
|
|
YearlyInboundQty = yearlyInbound,
|
|
YearlyOutboundQty = yearlyOutbound,
|
|
PendingInbound = pendingIn,
|
|
PendingOutbound = pendingOut,
|
|
ScrapCount = scrapCount,
|
|
AdjustmentCount = adjCount,
|
|
GrCount = grCount,
|
|
DoCount = doCount,
|
|
TransferCount = transferCount,
|
|
StockCountCount = stockCountCount,
|
|
WarehouseKpis = warehouseKpis,
|
|
ModuleBreakdown = moduleBreakdown,
|
|
PipelineSummary = pipelineSummary,
|
|
ConversionMetrics = conversionMetrics,
|
|
RecentTransactions = recentTransactions,
|
|
ActivityFeeds = activityFeeds,
|
|
RevenueTrend = revenueTrend,
|
|
StockMovementTrend = stockMovementTrend,
|
|
YearlyMovement = yearlyMovement,
|
|
TopProducts = topProducts
|
|
};
|
|
}
|
|
|
|
private static string FormattedSingle(decimal v)
|
|
{
|
|
if (v >= 1_000_000) return (v / 1_000_000m).ToString("F2") + "M";
|
|
if (v >= 1_000) return (v / 1_000m).ToString("F1") + "K";
|
|
return v.ToString("N0");
|
|
}
|
|
} |