// 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; } }