todo
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Ccsvc/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=strftime/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=strftime/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||||
80
Service/Budget/BudgetSavingsService.cs
Normal file
80
Service/Budget/BudgetSavingsService.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
namespace Service.Budget;
|
||||||
|
|
||||||
|
public interface IBudgetSavingsService
|
||||||
|
{
|
||||||
|
Task<BudgetResult> GetSavingsDtoAsync(
|
||||||
|
BudgetPeriodType periodType,
|
||||||
|
DateTime? referenceDate = null,
|
||||||
|
IEnumerable<BudgetRecord>? existingBudgets = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BudgetSavingsService(
|
||||||
|
IBudgetRepository budgetRepository,
|
||||||
|
IBudgetArchiveRepository budgetArchiveRepository
|
||||||
|
) : IBudgetSavingsService
|
||||||
|
{
|
||||||
|
public async Task<BudgetResult> GetSavingsDtoAsync(
|
||||||
|
BudgetPeriodType periodType,
|
||||||
|
DateTime? referenceDate = null,
|
||||||
|
IEnumerable<BudgetRecord>? 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<BudgetResult> GetForMonthAsync(
|
||||||
|
IEnumerable<BudgetRecord> 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<BudgetResult> GetForYearAsync(
|
||||||
|
IEnumerable<BudgetRecord> budgets,
|
||||||
|
int year)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using JetBrains.Annotations;
|
namespace Service.Budget;
|
||||||
|
|
||||||
namespace Service;
|
|
||||||
|
|
||||||
public interface IBudgetService
|
public interface IBudgetService
|
||||||
{
|
{
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
using HtmlAgilityPack;
|
|
||||||
|
|
||||||
namespace Service.EmailServices.EmailParse;
|
|
||||||
|
|
||||||
public class EmailParseFormCcsvc(
|
|
||||||
ILogger<EmailParseFormCcsvc> 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
164
Service/EmailServices/EmailParse/EmailParseFormCcsvc.cs
Normal file
164
Service/EmailServices/EmailParse/EmailParseFormCcsvc.cs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
using HtmlAgilityPack;
|
||||||
|
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||||
|
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
|
||||||
|
|
||||||
|
namespace Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
|
[UsedImplicitly]
|
||||||
|
public partial class EmailParseFormCcsvc(
|
||||||
|
ILogger<EmailParseFormCcsvc> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,4 +14,5 @@ global using System.Text.Json.Nodes;
|
|||||||
global using Microsoft.Extensions.Configuration;
|
global using Microsoft.Extensions.Configuration;
|
||||||
global using Common;
|
global using Common;
|
||||||
global using System.Net;
|
global using System.Net;
|
||||||
global using System.Text.Encodings.Web;
|
global using System.Text.Encodings.Web;
|
||||||
|
global using JetBrains.Annotations;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Quartz;
|
using Quartz;
|
||||||
|
using Service.Budget;
|
||||||
|
|
||||||
namespace Service.Jobs;
|
namespace Service.Jobs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace WebApi.Controllers;
|
using Service.Budget;
|
||||||
|
|
||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
|
|||||||
Reference in New Issue
Block a user