diff --git a/SimpleAdmin.Application/Services/Statistics/Dto/StatisticsDto.cs b/SimpleAdmin.Application/Services/Statistics/Dto/StatisticsDto.cs
new file mode 100644
index 0000000..93c7653
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Statistics/Dto/StatisticsDto.cs
@@ -0,0 +1,113 @@
+// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
+//
+// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
+// 1.请不要删除和修改根目录下的LICENSE文件。
+// 2.请不要删除和修改SimpleAdmin源码头部的版权声明。
+// 3.分发源码时候,请注明软件出处 https://gitee.com/dotnetmoyu/SimpleAdmin
+
+namespace SimpleAdmin.Application.Services.Statistics
+{
+ ///
+ /// 年度统计数据 DTO(对应前端 YearlyStatisticsDto)
+ ///
+ public class YearlyStatisticsDto
+ {
+ /// 统计年份(如 "2025")
+ public string Year { get; set; }
+
+ /// 处方总花费(元)
+ public decimal TotalCost { get; set; }
+
+ /// 处方总数量(张)
+ public int TotalQty { get; set; }
+
+ /// 员工处方花费(元)
+ public decimal StaffCost { get; set; }
+
+ /// 家属处方花费(元)
+ public decimal FamilyCost { get; set; }
+
+ /// 员工处方数量(张)
+ public int StaffQty { get; set; }
+
+ /// 家属处方数量(张)
+ public int FamilyQty { get; set; }
+
+ /// 药材耗材花费(元)—— 当年所有入库药材总价
+ public decimal MaterialCost { get; set; }
+
+ /// 服务人数(当年有过处方记录的不重复人员数)
+ public int ServiceCount { get; set; }
+
+ // ---------- 同比趋势(百分比,正数为增长,负数为下降) ----------
+
+ /// 处方总花费同比趋势 (%)
+ public decimal TotalCostTrend { get; set; }
+
+ /// 处方总数量同比趋势 (%)
+ public decimal TotalQtyTrend { get; set; }
+
+ /// 药材花费同比趋势 (%)
+ public decimal MaterialCostTrend { get; set; }
+
+ /// 服务人数同比趋势 (%)
+ public decimal ServiceCountTrend { get; set; }
+ }
+
+ ///
+ /// 月度明细数据 DTO(对应前端 MonthlyDetailDto)
+ ///
+ public class MonthlyDetailDto
+ {
+ /// 月份显示(如 "1月")
+ public string Month { get; set; }
+
+ /// 员工处方花费(元)
+ public decimal StaffCost { get; set; }
+
+ /// 家属处方花费(元)
+ public decimal FamilyCost { get; set; }
+
+ /// 员工处方数量(张)
+ public int StaffQty { get; set; }
+
+ /// 家属处方数量(张)
+ public int FamilyQty { get; set; }
+
+ /// 药材耗材花费(元)—— 当月入库总价
+ public decimal MaterialCost { get; set; }
+ }
+
+ ///
+ /// 全量总览 DTO(不区分年份,含剩余药材价值)
+ ///
+ public class TotalOverviewDto
+ {
+ /// 处方总花费(元,含所有年份)
+ public decimal TotalCost { get; set; }
+
+ /// 处方总数量(张)
+ public int TotalQty { get; set; }
+
+ /// 员工处方花费(元)
+ public decimal StaffCost { get; set; }
+
+ /// 家属处方花费(元)
+ public decimal FamilyCost { get; set; }
+
+ /// 员工处方数量(张)
+ public int StaffQty { get; set; }
+
+ /// 家属处方数量(张)
+ public int FamilyQty { get; set; }
+
+ /// 药材采购总花费(元,历史所有入库总额)
+ public decimal MaterialCost { get; set; }
+
+ /// 累计服务人数(不重复人员)
+ public int ServiceCount { get; set; }
+
+ /// 当前剩余药材库存价值(元,按最新入库单价估算)
+ public decimal RemainingMedicinalValue { get; set; }
+ }
+}
diff --git a/SimpleAdmin.Application/Services/Statistics/IStatisticsService.cs b/SimpleAdmin.Application/Services/Statistics/IStatisticsService.cs
new file mode 100644
index 0000000..0c3a3ed
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Statistics/IStatisticsService.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
+//
+// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
+// 1.请不要删除和修改根目录下的LICENSE文件。
+// 2.请不要删除和修改SimpleAdmin源码头部的版权声明。
+// 3.分发源码时候,请注明软件出处 https://gitee.com/dotnetmoyu/SimpleAdmin
+
+namespace SimpleAdmin.Application.Services.Statistics
+{
+ ///
+ /// 统计分析 Service 接口
+ ///
+ public interface IStatisticsService : ITransient
+ {
+ ///
+ /// 获取全量总览数据(不区分年份,含剩余药材库存价值)
+ ///
+ Task GetTotalOverview();
+
+ ///
+ /// 获取指定年份的年度汇总统计数据(含同比趋势)
+ ///
+ /// 年份,如 2025
+ Task GetYearlyStatistics(int year);
+
+ ///
+ /// 获取指定年份的逐月明细数据(返回 12 条,无数据月份补零)
+ ///
+ /// 年份,如 2025
+ Task> GetMonthlyDetails(int year);
+ }
+}
diff --git a/SimpleAdmin.Application/Services/Statistics/StatisticsService.cs b/SimpleAdmin.Application/Services/Statistics/StatisticsService.cs
new file mode 100644
index 0000000..870b41f
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Statistics/StatisticsService.cs
@@ -0,0 +1,526 @@
+// 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
+ })
+ .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
+ {
+ 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
+ })
+ .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
+ {
+ 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
+ })
+ .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
+ {
+ 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;
+ }
+}
diff --git a/SimpleAdmin.Application/SimpleAdmin.Application.xml b/SimpleAdmin.Application/SimpleAdmin.Application.xml
index 4676701..939e70e 100644
--- a/SimpleAdmin.Application/SimpleAdmin.Application.xml
+++ b/SimpleAdmin.Application/SimpleAdmin.Application.xml
@@ -215,6 +215,127 @@
+
+
+ 年度统计数据 DTO(对应前端 YearlyStatisticsDto)
+
+
+
+ 统计年份(如 "2025")
+
+
+ 处方总花费(元)
+
+
+ 处方总数量(张)
+
+
+ 员工处方花费(元)
+
+
+ 家属处方花费(元)
+
+
+ 员工处方数量(张)
+
+
+ 家属处方数量(张)
+
+
+ 药材耗材花费(元)—— 当年所有入库药材总价
+
+
+ 服务人数(当年有过处方记录的不重复人员数)
+
+
+ 处方总花费同比趋势 (%)
+
+
+ 处方总数量同比趋势 (%)
+
+
+ 药材花费同比趋势 (%)
+
+
+ 服务人数同比趋势 (%)
+
+
+
+ 月度明细数据 DTO(对应前端 MonthlyDetailDto)
+
+
+
+ 月份显示(如 "1月")
+
+
+ 员工处方花费(元)
+
+
+ 家属处方花费(元)
+
+
+ 员工处方数量(张)
+
+
+ 家属处方数量(张)
+
+
+ 药材耗材花费(元)—— 当月入库总价
+
+
+
+ 全量总览 DTO(不区分年份,含剩余药材价值)
+
+
+
+ 处方总花费(元,含所有年份)
+
+
+ 处方总数量(张)
+
+
+ 员工处方花费(元)
+
+
+ 家属处方花费(元)
+
+
+ 员工处方数量(张)
+
+
+ 家属处方数量(张)
+
+
+ 药材采购总花费(元,历史所有入库总额)
+
+
+ 累计服务人数(不重复人员)
+
+
+ 当前剩余药材库存价值(元,按最新入库单价估算)
+
+
+
+ 统计分析 Service 接口
+
+
+
+
+ 获取全量总览数据(不区分年份,含剩余药材库存价值)
+
+
+
+
+ 获取指定年份的年度汇总统计数据(含同比趋势)
+
+ 年份,如 2025
+
+
+
+ 获取指定年份的逐月明细数据(返回 12 条,无数据月份补零)
+
+ 年份,如 2025
+
机构服务
@@ -845,5 +966,40 @@
+
+
+ 统计分析 Service 实现
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 计算指定年份的聚合数据(用于年度统计与同比计算)
+
+
+
+
+ 读取 FIFO 入库数据(按时间升序)
+
+
+
+
+ 按 FIFO 规则(含累计偏移)计算单次消耗成本,并推进偏移量
+
+
+
+ 计算同比增涨率(%),基期为 0 时返回 0
+
+
+ 返回全零的 12 个月明细列表
+
diff --git a/SimpleAdmin.SqlSugar/SqlSugar.Development.json b/SimpleAdmin.SqlSugar/SqlSugar.Development.json
index b9f6e60..a945d35 100644
--- a/SimpleAdmin.SqlSugar/SqlSugar.Development.json
+++ b/SimpleAdmin.SqlSugar/SqlSugar.Development.json
@@ -5,7 +5,7 @@
"ConnectionStrings": [
{
"ConfigId": "SimpleAdmin", //租户ID
- "ConnectionString": "AllowLoadLocalInfile=true;Server=58.17.132.2;User ID=admin;Password=so^867z$4S;Port=16306;Database=WeiBingJu;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
+ "ConnectionString": "AllowLoadLocalInfile=true;Server=116.168.197.2;User ID=admin;Password=so^867z$4S;Port=16306;Database=WeiBingJu;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
"DbType": "MySql", //数据库类型 WeiBingJu
"IsAutoCloseConnection": true //是否自动释放 weibingjutest
}
diff --git a/SimpleAdmin.SqlSugar/SqlSugar.Production.json b/SimpleAdmin.SqlSugar/SqlSugar.Production.json
index 90b9151..01d1779 100644
--- a/SimpleAdmin.SqlSugar/SqlSugar.Production.json
+++ b/SimpleAdmin.SqlSugar/SqlSugar.Production.json
@@ -5,7 +5,7 @@
"ConnectionStrings": [
{
"ConfigId": "SimpleAdmin", //租户ID
- "ConnectionString": "AllowLoadLocalInfile=true;Server=58.17.132.2;User ID=admin;Password=so^867z$4S;Port=16306;Database=WeiBingJu;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
+ "ConnectionString": "AllowLoadLocalInfile=true;Server=116.168.197.2;User ID=admin;Password=so^867z$4S;Port=16306;Database=WeiBingJu;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
"DbType": "MySql", //数据库类型
"IsAutoCloseConnection": true //是否自动释放
}
diff --git a/SimpleAdmin.Web.Core/AntDeploy.json b/SimpleAdmin.Web.Core/AntDeploy.json
new file mode 100644
index 0000000..c8a7e49
--- /dev/null
+++ b/SimpleAdmin.Web.Core/AntDeploy.json
@@ -0,0 +1,57 @@
+{
+ "Env": [],
+ "IIsConfig": {
+ "SdkType": null,
+ "WebSiteName": "",
+ "LastEnvName": null,
+ "EnvPairList": []
+ },
+ "WindowsServiveConfig": {
+ "ServiceName": "",
+ "SdkType": null,
+ "LastEnvName": null,
+ "EnvPairList": []
+ },
+ "LinuxServiveConfig": {
+ "ServiceName": "",
+ "EnvParam": "",
+ "LastEnvName": null,
+ "EnvPairList": []
+ },
+ "DockerConfig": {
+ "Prot": "",
+ "AspNetCoreEnv": "",
+ "LastEnvName": null,
+ "RemoveDaysFromPublished": "10",
+ "WorkDir": "",
+ "Volume": "",
+ "Other": "",
+ "EnvPairList": []
+ },
+ "DockerImageConfig": {
+ "BaseHttpProxy": "",
+ "BaseImage": "",
+ "BaseImageCredential": {
+ "UserName": "",
+ "Password": ""
+ },
+ "TargetImage": "",
+ "TargetHttpProxy": "",
+ "TargetTags": [
+ ""
+ ],
+ "TargetImageCredential": {
+ "UserName": "",
+ "Password": ""
+ },
+ "ImageFormat": "Docker",
+ "Entrypoint": [
+ ""
+ ],
+ "Cmd": [
+ ""
+ ],
+ "IgnoreList": [],
+ "SkipExistingImages": false
+ }
+}
\ No newline at end of file
diff --git a/SimpleAdmin.Web.Core/Controllers/Application/WeiBingJu/StatisticsController.cs b/SimpleAdmin.Web.Core/Controllers/Application/WeiBingJu/StatisticsController.cs
new file mode 100644
index 0000000..2bfd80f
--- /dev/null
+++ b/SimpleAdmin.Web.Core/Controllers/Application/WeiBingJu/StatisticsController.cs
@@ -0,0 +1,63 @@
+// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
+//
+// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
+// 1.请不要删除和修改根目录下的LICENSE文件。
+// 2.请不要删除和修改SimpleAdmin源码头部的版权声明。
+// 3.分发源码时候,请注明软件出处 https://gitee.com/dotnetmoyu/SimpleAdmin
+
+using MoYu.FriendlyException;
+using SimpleAdmin.Application.Services.Statistics;
+
+namespace SimpleAdmin.Web.Core;
+
+///
+/// 统计分析
+///
+[ApiDescriptionSettings("Application", Tag = "统计分析")]
+[Route("biz/weibingju/statistics")]
+[RolePermission]
+public class StatisticsController : IDynamicApiController
+{
+ private readonly IStatisticsService _service;
+
+ public StatisticsController(IStatisticsService service)
+ {
+ _service = service;
+ }
+
+ ///
+ /// 获取全量总览数据(不区分年份,含剩余药材库存价值)
+ ///
+ [HttpGet("overview")]
+ [DisplayName("获取全量总览数据")]
+ public async Task GetTotalOverview()
+ {
+ return await _service.GetTotalOverview();
+ }
+
+ ///
+ /// 获取年度统计数据(含同比趋势)
+ ///
+ [HttpGet("yearly")]
+ [DisplayName("获取年度统计数据")]
+ public async Task GetYearlyStatistics([FromQuery] string year)
+ {
+ if (!int.TryParse(year, out int yearInt) || yearInt < 2000 || yearInt > 2100)
+ throw Oops.Bah("年份参数不合法");
+
+ return await _service.GetYearlyStatistics(yearInt);
+ }
+
+ ///
+ /// 获取月度明细数据(返回 12 个月,无数据月份自动补零)
+ ///
+ [HttpGet("monthly")]
+ [DisplayName("获取月度明细数据")]
+ public async Task GetMonthlyDetails([FromQuery] string year)
+ {
+ if (!int.TryParse(year, out int yearInt) || yearInt < 2000 || yearInt > 2100)
+ throw Oops.Bah("年份参数不合法");
+
+ return await _service.GetMonthlyDetails(yearInt);
+ }
+}
diff --git a/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml b/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml
index 5ecb907..d5f8800 100644
--- a/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml
+++ b/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml
@@ -420,6 +420,26 @@
获取关系类型
+
+
+ 统计分析
+
+
+
+
+ 获取全量总览数据(不区分年份,含剩余药材库存价值)
+
+
+
+
+ 获取年度统计数据(含同比趋势)
+
+
+
+
+ 获取月度明细数据(返回 12 个月,无数据月份自动补零)
+
+
B端登录控制器
diff --git a/SimpleAdmin.Web.Entry/AntDeploy.json b/SimpleAdmin.Web.Entry/AntDeploy.json
index f46bc36..3212d13 100644
--- a/SimpleAdmin.Web.Entry/AntDeploy.json
+++ b/SimpleAdmin.Web.Entry/AntDeploy.json
@@ -7,7 +7,7 @@
{
"UserName": "root",
"Pwd": "DA9C12691417F47DE6F7FED4AC9DC2B0BFFAA5D2C8E3609D29A2595461736381",
- "Host": "58.17.132.2:12005",
+ "Host": "116.168.197.2:12005",
"NickName": "10楼liunx未病局测试环境",
"IIsFireUrl": null,
"DockerFireUrl": "",