527 lines
20 KiB
C#
527 lines
20 KiB
C#
// 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;
|
||
}
|
||
}
|