Files
WeiBingJuInterface/SimpleAdmin.Application/Services/Statistics/StatisticsService.cs
T
2026-02-24 15:05:47 +08:00

527 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
/// <summary>
/// 统计分析 Service 实现
/// </summary>
public class StatisticsService : DbRepository<Prescription>, IStatisticsService
{
// ─────────────────────────────────────────────────────────────
// 公开接口
// ─────────────────────────────────────────────────────────────
/// <inheritdoc/>
public async Task<TotalOverviewDto> GetTotalOverview()
{
// 1) 所有处方(按创建时间升序,FIFO 需要)
var prescriptionInfos = await Context.Queryable<Prescription>()
.LeftJoin<Person>((p, pe) => p.PersonId == pe.Id)
.OrderBy((p, pe) => p.CreateTime)
.Select((p, pe) => new
{
p.Id,
p.PersonId,
IsRelative = pe.EmployeeId != null
})
.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<PrescriptionHerb>()
.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<long, decimal>();
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
{
staffCost += Math.Round(cost, 2, MidpointRounding.AwayFromZero);
staffQty++;
}
}
}
// 2) 药材采购总花费(历史所有入库总额)
var materialCost = await Context.Queryable<MedicinalAddLog>()
.SumAsync(l => l.TotalPrice);
// 3) 累计服务人数(不重复人员)
var serviceCount = prescriptionInfos.Select(x => x.PersonId).Distinct().Count();
// 4) 剩余药材库存价值:每种药材当前库存 × 最新入库单价
var medicinals = await Context.Queryable<Medicinal>()
.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<MedicinalAddLog>()
.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),
};
}
/// <inheritdoc/>
public async Task<YearlyStatisticsDto> 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),
};
}
/// <inheritdoc/>
public async Task<List<MonthlyDetailDto>> 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<Prescription>()
.LeftJoin<Person>((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
})
.ToListAsync();
if (!prescriptionInfos.Any())
return BuildEmptyMonthList();
var allPrescriptionIds = prescriptionInfos.Select(x => x.Id).ToList();
// 2) 批量拉取处方药材明细
var allHerbs = await Context.Queryable<PrescriptionHerb>()
.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<Prescription>()
.Where(p => p.VisitDate < start)
.OrderBy(p => p.CreateTime)
.Select(p => p.Id)
.ToListAsync();
var fifoOffsets = new Dictionary<long, decimal>();
// 先消耗历史处方(建立偏移量),不需要计入结果
if (historyIds.Any())
{
var historyHerbs = await Context.Queryable<PrescriptionHerb>()
.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
{
cost = Math.Round(cost, 2, MidpointRounding.AwayFromZero);
monthlyStaffCost[month] += cost;
monthlyStaffQty[month]++;
}
}
// 7) 药材花费:按入库记录的月份汇总当年花费
var materialLogs = await Context.Queryable<MedicinalAddLog>()
.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<MonthlyDetailDto>(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;
}
// ─────────────────────────────────────────────────────────────
// 私有辅助方法
// ─────────────────────────────────────────────────────────────
/// <summary>
/// 计算指定年份的聚合数据(用于年度统计与同比计算)
/// </summary>
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<Prescription>()
.LeftJoin<Person>((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
})
.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<PrescriptionHerb>()
.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<Prescription>()
.Where(p => p.VisitDate < start)
.OrderBy(p => p.CreateTime)
.Select(p => p.Id)
.ToListAsync();
var fifoOffsets = new Dictionary<long, decimal>();
if (historyIds.Any())
{
var historyHerbs = await Context.Queryable<PrescriptionHerb>()
.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
{
staffCost += Math.Round(cost, 2, MidpointRounding.AwayFromZero);
staffQty++;
}
}
// 药材花费:当年所有入库记录的总额
var materialCost = await Context.Queryable<MedicinalAddLog>()
.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
);
}
/// <summary>
/// 读取 FIFO 入库数据(按时间升序)
/// </summary>
private async Task<Dictionary<long, List<MedicinalFifoRecord>>> GetFifoData(List<long> medicinalIds)
{
if (!medicinalIds.Any())
return new Dictionary<long, List<MedicinalFifoRecord>>();
var logs = await Context.Queryable<MedicinalAddLog>()
.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());
}
/// <summary>
/// 按 FIFO 规则(含累计偏移)计算单次消耗成本,并推进偏移量
/// </summary>
private static decimal CalculateFifoCost(
Dictionary<long, List<MedicinalFifoRecord>> fifoData,
Dictionary<long, decimal> 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);
}
/// <summary>计算同比增涨率(%),基期为 0 时返回 0</summary>
private static decimal CalcTrend<T>(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);
}
/// <summary>返回全零的 12 个月明细列表</summary>
private static List<MonthlyDetailDto> BuildEmptyMonthList()
{
var list = new List<MonthlyDetailDto>(12);
for (int m = 1; m <= 12; m++)
list.Add(new MonthlyDetailDto { Month = $"{m}月" });
return list;
}
}