// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
//
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。
// 2.请不要删除和修改SimpleAdmin源码头部的版权声明。
// 3.分发源码时候,请注明软件出处 https://gitee.com/dotnetmoyu/SimpleAdmin
using SimpleAdmin.Application.Entity;
using SimpleAdmin.Application.Services.Statistics;
///
/// 统计分析 Service 实现
///
public class StatisticsService : DbRepository, IStatisticsService
{
// ─────────────────────────────────────────────────────────────
// 公开接口
// ─────────────────────────────────────────────────────────────
///
public async Task GetTotalOverview()
{
// 1) 所有处方(按创建时间升序,FIFO 需要)
var prescriptionInfos = await Context.Queryable()
.LeftJoin((p, pe) => p.PersonId == pe.Id)
.OrderBy((p, pe) => p.CreateTime)
.Select((p, pe) => new
{
p.Id,
p.PersonId,
IsRelative = pe.EmployeeId != null,
Type = pe.Type
})
.ToListAsync();
decimal staffCost = 0, familyCost = 0;
int staffQty = 0, familyQty = 0;
if (prescriptionInfos.Any())
{
var allIds = prescriptionInfos.Select(x => x.Id).ToList();
var allHerbs = await Context.Queryable()
.Where(ph => allIds.Contains(ph.PrescriptionId))
.ToListAsync();
var usageGroups = allHerbs
.GroupBy(h => new { h.PrescriptionId, h.MedicinalId })
.Select(g => new
{
g.Key.PrescriptionId,
g.Key.MedicinalId,
TotalUsedWeight = g.Sum(h => h.AmountG)
})
.ToList();
var allMedicinalIds = usageGroups.Select(u => u.MedicinalId).Distinct().ToList();
var fifoData = await GetFifoData(allMedicinalIds);
var fifoOffsets = new Dictionary();
var usageByPres = usageGroups
.GroupBy(u => u.PrescriptionId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var pInfo in prescriptionInfos)
{
decimal cost = 0;
if (usageByPres.TryGetValue(pInfo.Id, out var usages))
foreach (var u in usages)
cost += CalculateFifoCost(fifoData, fifoOffsets, u.MedicinalId, u.TotalUsedWeight);
cost = Math.Round(cost, 4, MidpointRounding.AwayFromZero);
if (pInfo.IsRelative)
{
familyCost += Math.Round(cost * 1.1m, 2, MidpointRounding.AwayFromZero);
familyQty++;
}
else if (pInfo.Type == 2)
{
staffCost += Math.Round(cost * 1.1m, 2, MidpointRounding.AwayFromZero);
staffQty++;
}
else
{
staffCost += Math.Round(cost, 2, MidpointRounding.AwayFromZero);
staffQty++;
}
}
}
// 2) 药材采购总花费(历史所有入库总额)
var materialCost = await Context.Queryable()
.SumAsync(l => l.TotalPrice);
// 3) 累计服务人数(不重复人员)
var serviceCount = prescriptionInfos.Select(x => x.PersonId).Distinct().Count();
// 4) 剩余药材库存价值:每种药材当前库存 × 最新入库单价
var medicinals = await Context.Queryable()
.Where(m => m.Number > 0)
.Select(m => new { m.Id, m.Number })
.ToListAsync();
decimal remainingValue = 0;
if (medicinals.Any())
{
var medIds = medicinals.Select(m => m.Id).ToList();
// 取每种药材最新的一条入库记录单价
var latestPrices = await Context.Queryable()
.Where(l => medIds.Contains(l.MedicinalId))
.GroupBy(l => l.MedicinalId)
.Select(l => new
{
MedicinalId = l.MedicinalId,
LatestPrice = SqlFunc.AggregateMax(l.UnitPrice)
})
.ToListAsync();
var priceDict = latestPrices.ToDictionary(x => x.MedicinalId, x => x.LatestPrice);
foreach (var med in medicinals)
{
if (priceDict.TryGetValue(med.Id, out var price))
remainingValue += Math.Round(med.Number * price, 2, MidpointRounding.AwayFromZero);
}
}
return new TotalOverviewDto
{
TotalCost = Math.Round(staffCost + familyCost, 2),
TotalQty = staffQty + familyQty,
StaffCost = Math.Round(staffCost, 2),
FamilyCost = Math.Round(familyCost, 2),
StaffQty = staffQty,
FamilyQty = familyQty,
MaterialCost = Math.Round(materialCost, 2),
ServiceCount = serviceCount,
RemainingMedicinalValue = Math.Round(remainingValue, 2),
};
}
///
public async Task GetYearlyStatistics(int year)
{
var currentYearData = await BuildYearData(year);
var lastYearData = await BuildYearData(year - 1);
return new YearlyStatisticsDto
{
Year = year.ToString(),
TotalCost = currentYearData.TotalCost,
TotalQty = currentYearData.TotalQty,
StaffCost = currentYearData.StaffCost,
FamilyCost = currentYearData.FamilyCost,
StaffQty = currentYearData.StaffQty,
FamilyQty = currentYearData.FamilyQty,
MaterialCost = currentYearData.MaterialCost,
ServiceCount = currentYearData.ServiceCount,
TotalCostTrend = CalcTrend(lastYearData.TotalCost, currentYearData.TotalCost),
TotalQtyTrend = CalcTrend(lastYearData.TotalQty, currentYearData.TotalQty),
MaterialCostTrend = CalcTrend(lastYearData.MaterialCost, currentYearData.MaterialCost),
ServiceCountTrend = CalcTrend(lastYearData.ServiceCount, currentYearData.ServiceCount),
};
}
///
public async Task> GetMonthlyDetails(int year)
{
var start = new DateTime(year, 1, 1);
var end = new DateTime(year, 12, 31, 23, 59, 59);
// 1) 当年所有处方(按时间升序,供 FIFO 用)
var prescriptionInfos = await Context.Queryable()
.LeftJoin((p, pe) => p.PersonId == pe.Id)
.Where((p, pe) => p.VisitDate >= start && p.VisitDate <= end)
.OrderBy((p, pe) => p.CreateTime)
.Select((p, pe) => new
{
p.Id,
p.VisitDate,
p.CreateTime,
IsRelative = pe.EmployeeId != null,
Type = pe.Type
})
.ToListAsync();
if (!prescriptionInfos.Any())
return BuildEmptyMonthList();
var allPrescriptionIds = prescriptionInfos.Select(x => x.Id).ToList();
// 2) 批量拉取处方药材明细
var allHerbs = await Context.Queryable()
.Where(ph => allPrescriptionIds.Contains(ph.PrescriptionId))
.ToListAsync();
// 3) 按 处方ID+药材ID 分组,得到每张处方消耗每种药材的总量
var usageGroups = allHerbs
.GroupBy(h => new { h.PrescriptionId, h.MedicinalId })
.Select(g => new
{
g.Key.PrescriptionId,
g.Key.MedicinalId,
TotalUsedWeight = g.Sum(h => h.AmountG)
})
.ToList();
// 4) FIFO 入库数据
var allMedicinalIds = usageGroups.Select(u => u.MedicinalId).Distinct().ToList();
var fifoData = await GetFifoData(allMedicinalIds);
// 5) 需要在"所有历史处方"维度跑 FIFO,才能保证成本正确(与 PrescriptionService 逻辑一致)
// 先把当年之前的"历史"也加进来跑偏移
var historyIds = await Context.Queryable()
.Where(p => p.VisitDate < start)
.OrderBy(p => p.CreateTime)
.Select(p => p.Id)
.ToListAsync();
var fifoOffsets = new Dictionary();
// 先消耗历史处方(建立偏移量),不需要计入结果
if (historyIds.Any())
{
var historyHerbs = await Context.Queryable()
.Where(ph => historyIds.Contains(ph.PrescriptionId))
.ToListAsync();
var historyUsage = historyHerbs
.GroupBy(h => new { h.PrescriptionId, h.MedicinalId })
.Select(g => new
{
g.Key.PrescriptionId,
g.Key.MedicinalId,
TotalUsedWeight = g.Sum(h => h.AmountG)
})
.ToList();
var historyByPres = historyUsage
.GroupBy(u => u.PrescriptionId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var hid in historyIds)
{
if (!historyByPres.TryGetValue(hid, out var usages)) continue;
foreach (var u in usages)
CalculateFifoCost(fifoData, fifoOffsets, u.MedicinalId, u.TotalUsedWeight);
}
}
// 6) 计算当年每张处方的成本,按月汇总
var usageByPres = usageGroups
.GroupBy(u => u.PrescriptionId)
.ToDictionary(g => g.Key, g => g.ToList());
// 月度桶:1–12
var monthlyStaffCost = new decimal[13];
var monthlyFamilyCost = new decimal[13];
var monthlyStaffQty = new int[13];
var monthlyFamilyQty = new int[13];
var presInfoDict = prescriptionInfos.ToDictionary(x => x.Id);
foreach (var pInfo in prescriptionInfos)
{
int month = pInfo.VisitDate?.Month ?? pInfo.CreateTime?.Month ?? 0;
if (month < 1 || month > 12) continue;
decimal cost = 0m;
if (usageByPres.TryGetValue(pInfo.Id, out var usages))
{
foreach (var u in usages)
cost += CalculateFifoCost(fifoData, fifoOffsets, u.MedicinalId, u.TotalUsedWeight);
}
cost = Math.Round(cost, 4, MidpointRounding.AwayFromZero);
if (pInfo.IsRelative)
{
cost = Math.Round(cost * 1.1m, 2, MidpointRounding.AwayFromZero);
monthlyFamilyCost[month] += cost;
monthlyFamilyQty[month]++;
}
else if (pInfo.Type == 2)
{
cost = Math.Round(cost * 1.1m, 2, MidpointRounding.AwayFromZero);
monthlyStaffCost[month] += cost;
monthlyStaffQty[month]++;
}
else
{
cost = Math.Round(cost, 2, MidpointRounding.AwayFromZero);
monthlyStaffCost[month] += cost;
monthlyStaffQty[month]++;
}
}
// 7) 药材花费:按入库记录的月份汇总当年花费
var materialLogs = await Context.Queryable()
.Where(l => l.CreateTime >= start && l.CreateTime <= end)
.Select(l => new { l.CreateTime, l.TotalPrice })
.ToListAsync();
var monthlyMaterialCost = new decimal[13];
foreach (var log in materialLogs)
{
int month = log.CreateTime?.Month ?? 0;
if (month >= 1 && month <= 12)
monthlyMaterialCost[month] += log.TotalPrice;
}
// 8) 组装返回结果
var result = new List(12);
for (int m = 1; m <= 12; m++)
{
result.Add(new MonthlyDetailDto
{
Month = $"{m}月",
StaffCost = Math.Round(monthlyStaffCost[m], 2),
FamilyCost = Math.Round(monthlyFamilyCost[m], 2),
StaffQty = monthlyStaffQty[m],
FamilyQty = monthlyFamilyQty[m],
MaterialCost = Math.Round(monthlyMaterialCost[m], 2),
});
}
return result;
}
// ─────────────────────────────────────────────────────────────
// 私有辅助方法
// ─────────────────────────────────────────────────────────────
///
/// 计算指定年份的聚合数据(用于年度统计与同比计算)
///
private async Task<(decimal TotalCost, int TotalQty, decimal StaffCost, decimal FamilyCost,
int StaffQty, int FamilyQty, decimal MaterialCost, int ServiceCount)> BuildYearData(int year)
{
var start = new DateTime(year, 1, 1);
var end = new DateTime(year, 12, 31, 23, 59, 59);
// 当年处方(按时间升序,FIFO 需要)
var prescriptionInfos = await Context.Queryable()
.LeftJoin((p, pe) => p.PersonId == pe.Id)
.Where((p, pe) => p.VisitDate >= start && p.VisitDate <= end)
.OrderBy((p, pe) => p.CreateTime)
.Select((p, pe) => new
{
p.Id,
p.PersonId,
IsRelative = pe.EmployeeId != null,
Type = pe.Type
})
.ToListAsync();
if (!prescriptionInfos.Any())
return (0, 0, 0, 0, 0, 0, 0, 0);
var allPrescriptionIds = prescriptionInfos.Select(x => x.Id).ToList();
var allHerbs = await Context.Queryable()
.Where(ph => allPrescriptionIds.Contains(ph.PrescriptionId))
.ToListAsync();
var usageGroups = allHerbs
.GroupBy(h => new { h.PrescriptionId, h.MedicinalId })
.Select(g => new
{
g.Key.PrescriptionId,
g.Key.MedicinalId,
TotalUsedWeight = g.Sum(h => h.AmountG)
})
.ToList();
var allMedicinalIds = usageGroups.Select(u => u.MedicinalId).Distinct().ToList();
var fifoData = await GetFifoData(allMedicinalIds);
// 历史偏移量(当年之前的处方)
var historyIds = await Context.Queryable()
.Where(p => p.VisitDate < start)
.OrderBy(p => p.CreateTime)
.Select(p => p.Id)
.ToListAsync();
var fifoOffsets = new Dictionary();
if (historyIds.Any())
{
var historyHerbs = await Context.Queryable()
.Where(ph => historyIds.Contains(ph.PrescriptionId))
.ToListAsync();
var historyUsage = historyHerbs
.GroupBy(h => new { h.PrescriptionId, h.MedicinalId })
.Select(g => new
{
g.Key.PrescriptionId,
g.Key.MedicinalId,
TotalUsedWeight = g.Sum(h => h.AmountG)
})
.GroupBy(u => u.PrescriptionId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var hid in historyIds)
{
if (!historyUsage.TryGetValue(hid, out var usages)) continue;
foreach (var u in usages)
CalculateFifoCost(fifoData, fifoOffsets, u.MedicinalId, u.TotalUsedWeight);
}
}
// 按处方计算成本
var usageByPres = usageGroups
.GroupBy(u => u.PrescriptionId)
.ToDictionary(g => g.Key, g => g.ToList());
decimal staffCost = 0, familyCost = 0;
int staffQty = 0, familyQty = 0;
foreach (var pInfo in prescriptionInfos)
{
decimal cost = 0;
if (usageByPres.TryGetValue(pInfo.Id, out var usages))
foreach (var u in usages)
cost += CalculateFifoCost(fifoData, fifoOffsets, u.MedicinalId, u.TotalUsedWeight);
cost = Math.Round(cost, 4, MidpointRounding.AwayFromZero);
if (pInfo.IsRelative)
{
familyCost += Math.Round(cost * 1.1m, 2, MidpointRounding.AwayFromZero);
familyQty++;
}
else if (pInfo.Type == 2)
{
staffCost += Math.Round(cost * 1.1m, 2, MidpointRounding.AwayFromZero);
staffQty++;
}
else
{
staffCost += Math.Round(cost, 2, MidpointRounding.AwayFromZero);
staffQty++;
}
}
// 药材花费:当年所有入库记录的总额
var materialCost = await Context.Queryable()
.Where(l => l.CreateTime >= start && l.CreateTime <= end)
.SumAsync(l => l.TotalPrice);
// 服务人数:当年有处方的不重复人员
var serviceCount = prescriptionInfos.Select(x => x.PersonId).Distinct().Count();
return (
Math.Round(staffCost + familyCost, 2),
staffQty + familyQty,
Math.Round(staffCost, 2),
Math.Round(familyCost, 2),
staffQty,
familyQty,
Math.Round(materialCost, 2),
serviceCount
);
}
///
/// 读取 FIFO 入库数据(按时间升序)
///
private async Task>> GetFifoData(List medicinalIds)
{
if (!medicinalIds.Any())
return new Dictionary>();
var logs = await Context.Queryable()
.Where(l => medicinalIds.Contains(l.MedicinalId))
.OrderBy(l => l.CreateTime)
.Select(l => new MedicinalFifoRecord
{
MedicinalId = l.MedicinalId,
AddWeight = l.AddWeight,
UnitPrice = l.UnitPrice
})
.ToListAsync();
return logs.GroupBy(l => l.MedicinalId)
.ToDictionary(g => g.Key, g => g.ToList());
}
///
/// 按 FIFO 规则(含累计偏移)计算单次消耗成本,并推进偏移量
///
private static decimal CalculateFifoCost(
Dictionary> fifoData,
Dictionary fifoOffsets,
long medicinalId,
decimal usedWeight)
{
if (usedWeight <= 0 || !fifoData.TryGetValue(medicinalId, out var records) || !records.Any())
return 0;
decimal alreadyUsed = fifoOffsets.GetValueOrDefault(medicinalId, 0);
decimal remainingToUse = usedWeight;
decimal totalCost = 0;
foreach (var record in records)
{
if (remainingToUse <= 0) break;
if (alreadyUsed >= record.AddWeight)
{
alreadyUsed -= record.AddWeight;
continue;
}
decimal available = record.AddWeight - alreadyUsed;
decimal toConsume = Math.Min(available, remainingToUse);
totalCost += toConsume * record.UnitPrice;
remainingToUse -= toConsume;
alreadyUsed = 0;
}
fifoOffsets[medicinalId] = fifoOffsets.GetValueOrDefault(medicinalId, 0) + usedWeight;
return Math.Round(totalCost, 4, MidpointRounding.AwayFromZero);
}
/// 计算同比增涨率(%),基期为 0 时返回 0
private static decimal CalcTrend(T baseVal, T currentVal) where T : struct, IConvertible
{
decimal b = Convert.ToDecimal(baseVal);
decimal c = Convert.ToDecimal(currentVal);
if (b == 0) return 0;
return Math.Round((c - b) / b * 100, 1, MidpointRounding.AwayFromZero);
}
/// 返回全零的 12 个月明细列表
private static List BuildEmptyMonthList()
{
var list = new List(12);
for (int m = 1; m <= 12; m++)
list.Add(new MonthlyDetailDto { Month = $"{m}月" });
return list;
}
}