diff --git a/EmailBill.sln.DotSettings b/EmailBill.sln.DotSettings index 0298694..641ca3c 100644 --- a/EmailBill.sln.DotSettings +++ b/EmailBill.sln.DotSettings @@ -1,3 +1,4 @@  + True True True \ No newline at end of file diff --git a/Service/Budget/BudgetSavingsService.cs b/Service/Budget/BudgetSavingsService.cs new file mode 100644 index 0000000..8cf029b --- /dev/null +++ b/Service/Budget/BudgetSavingsService.cs @@ -0,0 +1,80 @@ +namespace Service.Budget; + +public interface IBudgetSavingsService +{ + Task GetSavingsDtoAsync( + BudgetPeriodType periodType, + DateTime? referenceDate = null, + IEnumerable? existingBudgets = null); +} + +public class BudgetSavingsService( + IBudgetRepository budgetRepository, + IBudgetArchiveRepository budgetArchiveRepository +) : IBudgetSavingsService +{ + public async Task GetSavingsDtoAsync( + BudgetPeriodType periodType, + DateTime? referenceDate = null, + IEnumerable? existingBudgets = null) + { + var budgets = existingBudgets; + + if (existingBudgets == null) + { + budgets = await budgetRepository.GetAllAsync(); + } + + if (budgets == null) + { + throw new InvalidOperationException("No budgets found."); + } + + budgets = budgets + // 排序顺序 1.硬性预算 2.月度->年度 3.实际金额倒叙 + .OrderBy(b => b.IsMandatoryExpense) + .ThenBy(b => b.Type) + .ThenByDescending(b => b.Limit); + + var year = referenceDate?.Year ?? DateTime.Now.Year; + var month = referenceDate?.Month ?? DateTime.Now.Month; + + if(periodType == BudgetPeriodType.Month) + { + return await GetForMonthAsync(budgets, year, month); + } + else if(periodType == BudgetPeriodType.Year) + { + return await GetForYearAsync(budgets, year); + } + + throw new NotSupportedException($"Period type {periodType} is not supported."); + } + + private async Task GetForMonthAsync( + IEnumerable budgets, + int year, + int month) + { + var result = new BudgetResult + { + + }; + + var monthlyBudgets = budgets + .Where(b => b.Type == BudgetPeriodType.Month); + var yearlyBudgets = budgets + .Where(b => b.Type == BudgetPeriodType.Year); + + // var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate); + + throw new NotImplementedException(); + } + + private async Task GetForYearAsync( + IEnumerable budgets, + int year) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Service/BudgetService.cs b/Service/Budget/BudgetService.cs similarity index 99% rename from Service/BudgetService.cs rename to Service/Budget/BudgetService.cs index f4ef175..fb50622 100644 --- a/Service/BudgetService.cs +++ b/Service/Budget/BudgetService.cs @@ -1,6 +1,4 @@ -using JetBrains.Annotations; - -namespace Service; +namespace Service.Budget; public interface IBudgetService { diff --git a/Service/EmailServices/EmailParse/EmailParseFormCCSVC.cs b/Service/EmailServices/EmailParse/EmailParseFormCCSVC.cs deleted file mode 100644 index 9f0c956..0000000 --- a/Service/EmailServices/EmailParse/EmailParseFormCCSVC.cs +++ /dev/null @@ -1,147 +0,0 @@ -using HtmlAgilityPack; - -namespace Service.EmailServices.EmailParse; - -public class EmailParseFormCcsvc( - ILogger logger, - IOpenAiService openAiService -) : EmailParseServicesBase(logger, openAiService) -{ - public override bool CanParse(string from, string subject, string body) - { - if (!from.Contains("ccsvc@message.cmbchina.com")) - { - return false; - } - - if (!subject.Contains("每日信用管家")) - { - return false; - } - - // 必须包含HTML标签 - if (!Regex.IsMatch(body, "<.*?>")) - { - return false; - } - - return true; - } - - public override async Task<( - string card, - string reason, - decimal amount, - decimal balance, - TransactionType type, - DateTime? occurredAt - )[]> ParseEmailContentAsync(string emailContent) - { - var doc = new HtmlDocument(); - doc.LoadHtml(emailContent); - - var result = new List<(string, string, decimal, decimal, TransactionType, DateTime?)>(); - - // 1. Get Date - var dateNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '您的消费明细如下')]"); - - var dateText = dateNode.InnerText.Trim(); - // "2025/12/21 您的消费明细如下:" - var dateMatch = Regex.Match(dateText, @"\d{4}/\d{1,2}/\d{1,2}"); - if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date)) - { - logger.LogWarning("Failed to parse date from: {DateText}", dateText); - return []; - } - - // 2. Get Balance (Available Limit) - decimal balance = 0; - // Find "可用额度" label - var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]"); - { - // Go up to TR - var tr = limitLabelNode.Ancestors("tr").FirstOrDefault(); - if (tr != null) - { - var prevTr = tr.PreviousSibling; - while (prevTr.Name != "tr") prevTr = prevTr.PreviousSibling; - - var balanceNode = prevTr.SelectSingleNode(".//font[contains(text(), '¥')]"); - var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim(); - decimal.TryParse(balanceStr, out balance); - } - } - - // 3. Get Transactions - var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']"); - foreach (var node in transactionNodes) - { - try - { - // Time - var timeNode = node.SelectSingleNode(".//span[@id='fixBand5']//font"); - var timeText = timeNode.InnerText.Trim(); // "10:13:43" - - DateTime? occurredAt = date; - if (!string.IsNullOrEmpty(timeText) && DateTime.TryParse($"{date:yyyy-MM-dd} {timeText}", out var dt)) - { - occurredAt = dt; - } - - // Info Block - var infoNode = node.SelectSingleNode(".//span[@id='fixBand12']"); - - // Amount - var amountNode = infoNode.SelectSingleNode(".//font[contains(text(), 'CNY')]"); - var amountText = amountNode.InnerText.Replace("CNY", "").Replace(" ", "").Trim(); - if (!decimal.TryParse(amountText, out var amount)) - { - continue; - } - - // Description - var descNode = infoNode.SelectSingleNode(".//tr[2]//font"); - var descText = descNode.InnerText; - // Replace   and non-breaking space (\u00A0) with normal space - descText = descText.Replace(" ", " "); - descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim(); - - // Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡" - var parts = descText.Split([' '], StringSplitOptions.RemoveEmptyEntries); - - string card = ""; - string reason = descText; - TransactionType type; - - if (parts.Length > 0 && parts[0].StartsWith("尾号")) - { - card = parts[0].Replace("尾号", ""); - } - - if (parts.Length > 2) - { - reason = string.Join(" ", parts.Skip(2)); - } - - // 招商信用卡特殊,消费金额为正数,退款为负数 - if(amount > 0) - { - type = TransactionType.Expense; - } - else - { - type = TransactionType.Income; - amount = Math.Abs(amount); - } - - result.Add((card, reason, amount, balance, type, occurredAt)); - } - catch (Exception ex) - { - logger.LogError(ex, "Error parsing transaction node"); - } - } - - return await Task.FromResult(result.ToArray()); - } -} \ No newline at end of file diff --git a/Service/EmailServices/EmailParse/EmailParseFormCcsvc.cs b/Service/EmailServices/EmailParse/EmailParseFormCcsvc.cs new file mode 100644 index 0000000..345f7cc --- /dev/null +++ b/Service/EmailServices/EmailParse/EmailParseFormCcsvc.cs @@ -0,0 +1,164 @@ +using HtmlAgilityPack; +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract +// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + +namespace Service.EmailServices.EmailParse; + +[UsedImplicitly] +public partial class EmailParseFormCcsvc( + ILogger logger, + IOpenAiService openAiService +) : EmailParseServicesBase(logger, openAiService) +{ + [GeneratedRegex("<.*?>")] + private static partial Regex HtmlRegex(); + + public override bool CanParse(string from, string subject, string body) + { + if (!from.Contains("ccsvc@message.cmbchina.com")) + { + return false; + } + + if (!subject.Contains("每日信用管家")) + { + return false; + } + + // 必须包含HTML标签 + return HtmlRegex().IsMatch(body); + } + + public override async Task<( + string card, + string reason, + decimal amount, + decimal balance, + TransactionType type, + DateTime? occurredAt + )[]> ParseEmailContentAsync(string emailContent) + { + var doc = new HtmlDocument(); + doc.LoadHtml(emailContent); + + var result = new List<(string, string, decimal, decimal, TransactionType, DateTime?)>(); + + // 1. Get Date + var dateNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '您的消费明细如下')]"); + if (dateNode == null) + { + logger.LogWarning("Date node not found"); + return []; + } + + var dateText = dateNode.InnerText.Trim(); + // "2025/12/21 您的消费明细如下:" + var dateMatch = Regex.Match(dateText, @"\d{4}/\d{1,2}/\d{1,2}"); + if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date)) + { + logger.LogWarning("Failed to parse date from: {DateText}", dateText); + return []; + } + + // 2. Get Balance (Available Limit) + decimal balance = 0; + // Find "可用额度" label + var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]"); + if (limitLabelNode != null) + { + // Go up to TR + var tr = limitLabelNode.Ancestors("tr").FirstOrDefault(); + if (tr != null) + { + var prevTr = tr.PreviousSibling; + while (prevTr != null && prevTr.Name != "tr") prevTr = prevTr.PreviousSibling; + + if (prevTr != null) + { + var balanceNode = prevTr.SelectSingleNode(".//font[contains(text(), '¥')]"); + if (balanceNode != null) + { + var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim(); + decimal.TryParse(balanceStr, out balance); + } + } + } + } + + // 3. Get Transactions + var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']"); + if (transactionNodes != null) + { + foreach (var node in transactionNodes) + { + string card = ""; + try + { + // Time + var timeNode = node.SelectSingleNode(".//span[@id='fixBand5']//font"); + var timeText = timeNode?.InnerText.Trim(); // "10:13:43" + + DateTime? occurredAt = date; + if (!string.IsNullOrEmpty(timeText) && DateTime.TryParse($"{date:yyyy-MM-dd} {timeText}", out var dt)) + { + occurredAt = dt; + } + + // Info Block + var infoNode = node.SelectSingleNode(".//span[@id='fixBand12']"); + if (infoNode == null) continue; + + // Amount + var amountNode = infoNode.SelectSingleNode(".//font[contains(text(), 'CNY')]"); + var amountText = amountNode?.InnerText.Replace("CNY", "").Replace(" ", "").Trim(); + if (!decimal.TryParse(amountText, out var amount)) + { + continue; + } + + // Description + var descNode = infoNode.SelectSingleNode(".//tr[2]//font"); + var descText = descNode?.InnerText ?? ""; + // Replace   and non-breaking space (\u00A0) with normal space + descText = descText.Replace(" ", " "); + descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim(); + + // Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡" + var parts = descText.Split([' '], StringSplitOptions.RemoveEmptyEntries); + + var reason = descText; + TransactionType type; + + if (parts.Length > 0 && parts[0].StartsWith("尾号")) + { + card = parts[0].Replace("尾号", ""); + } + + if (parts.Length > 2) + { + reason = string.Join(" ", parts.Skip(2)); + } + + // 招商信用卡特殊,消费金额为正数,退款为负数 + if(amount > 0) + { + type = TransactionType.Expense; + } + else + { + type = TransactionType.Income; + amount = Math.Abs(amount); + } + + result.Add((card, reason, amount, balance, type, occurredAt)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error parsing transaction node"); + } + } + } + + return await Task.FromResult(result.ToArray()); + } +} \ No newline at end of file diff --git a/Service/GlobalUsings.cs b/Service/GlobalUsings.cs index daeb865..cb6c8b8 100644 --- a/Service/GlobalUsings.cs +++ b/Service/GlobalUsings.cs @@ -14,4 +14,5 @@ global using System.Text.Json.Nodes; global using Microsoft.Extensions.Configuration; global using Common; global using System.Net; -global using System.Text.Encodings.Web; \ No newline at end of file +global using System.Text.Encodings.Web; +global using JetBrains.Annotations; \ No newline at end of file diff --git a/Service/Jobs/BudgetArchiveJob.cs b/Service/Jobs/BudgetArchiveJob.cs index cde9bca..fbafcee 100644 --- a/Service/Jobs/BudgetArchiveJob.cs +++ b/Service/Jobs/BudgetArchiveJob.cs @@ -1,4 +1,5 @@ using Quartz; +using Service.Budget; namespace Service.Jobs; diff --git a/WebApi/Controllers/BudgetController.cs b/WebApi/Controllers/BudgetController.cs index 7815ea5..0313fec 100644 --- a/WebApi/Controllers/BudgetController.cs +++ b/WebApi/Controllers/BudgetController.cs @@ -1,4 +1,6 @@ -namespace WebApi.Controllers; +using Service.Budget; + +namespace WebApi.Controllers; [ApiController] [Route("api/[controller]/[action]")]