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]")]