测试覆盖率
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 27s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 27s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
This commit is contained in:
80
.sisyphus/notepads/statistics-year-selection/decisions.md
Normal file
80
.sisyphus/notepads/statistics-year-selection/decisions.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Decisions - Statistics Year Selection Enhancement
|
||||
|
||||
## [2026-01-28] Architecture Decisions
|
||||
|
||||
### Frontend Implementation Strategy
|
||||
|
||||
#### 1. Date Picker Mode Toggle
|
||||
- Add a toggle switch in the date picker popup to switch between "按月" (month) and "按年" (year) modes
|
||||
- When "按年" selected: use `columns-type="['year']"`
|
||||
- When "按月" selected: use `columns-type="['year', 'month']` (current behavior)
|
||||
|
||||
#### 2. State Management
|
||||
- Add `dateSelectionMode` ref: `'month'` | `'year'`
|
||||
- When year-only mode: set `currentMonth = 0` to indicate full year
|
||||
- Keep `currentYear` as integer (unchanged)
|
||||
- Update `selectedDate` array dynamically based on mode:
|
||||
- Year mode: `['YYYY']`
|
||||
- Month mode: `['YYYY', 'MM']`
|
||||
|
||||
#### 3. Display Logic
|
||||
- Nav bar title: `currentYear年` when `currentMonth === 0`, else `currentYear年currentMonth月`
|
||||
- Chart titles: Update to reflect year or year-month scope
|
||||
|
||||
#### 4. API Calls
|
||||
- Pass `month: currentMonth.value || 0` to all API calls
|
||||
- Backend will handle month=0 as year-only query
|
||||
|
||||
### Backend Implementation Strategy
|
||||
|
||||
#### 1. Repository Layer Change
|
||||
**File**: `Repository/TransactionRecordRepository.cs`
|
||||
**Method**: `BuildQuery()` lines 81-86
|
||||
|
||||
```csharp
|
||||
if (year.HasValue)
|
||||
{
|
||||
if (month.HasValue && month.Value > 0)
|
||||
{
|
||||
// Specific month
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Entire year
|
||||
var dateStart = new DateTime(year.Value, 1, 1);
|
||||
var dateEnd = new DateTime(year.Value + 1, 1, 1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Service Layer
|
||||
- No changes needed - services already pass month parameter to repository
|
||||
- Services will receive month=0 for year-only queries
|
||||
|
||||
#### 3. API Controller
|
||||
- No changes needed - already accepts year/month parameters
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
#### Backend Tests
|
||||
- Test year-only query returns all transactions for that year
|
||||
- Test month-specific query still works
|
||||
- Test edge cases: year boundaries, leap years
|
||||
|
||||
#### Frontend Tests
|
||||
- Test toggle switches picker mode correctly
|
||||
- Test year selection updates state and fetches data
|
||||
- Test display updates correctly for year vs year-month
|
||||
|
||||
### User Experience Flow
|
||||
|
||||
1. User clicks date picker in nav bar
|
||||
2. Popup opens with toggle: "按月 | 按年"
|
||||
3. User selects mode (default: 按月 for backward compatibility)
|
||||
4. User selects date(s) and confirms
|
||||
5. Statistics refresh with new scope
|
||||
6. Display updates to show scope (year or year-month)
|
||||
27
.sisyphus/notepads/statistics-year-selection/issues.md
Normal file
27
.sisyphus/notepads/statistics-year-selection/issues.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Issues - Statistics Year Selection Enhancement
|
||||
|
||||
## [2026-01-28] Backend Repository Limitation
|
||||
|
||||
### Issue
|
||||
`TransactionRecordRepository.BuildQuery()` requires both year AND month parameters to be present. Year-only queries (month=null or month=0) are not supported.
|
||||
|
||||
### Impact
|
||||
- Cannot query full-year statistics from the frontend
|
||||
- Current implementation only supports month-level granularity
|
||||
- All statistics endpoints rely on `QueryAsync(year, month, ...)`
|
||||
|
||||
### Solution
|
||||
Modify `BuildQuery()` method in `Repository/TransactionRecordRepository.cs` to support:
|
||||
1. Year-only queries (when year provided, month is null or 0)
|
||||
2. Month-specific queries (when both year and month provided - current behavior)
|
||||
|
||||
### Implementation Location
|
||||
- File: `Repository/TransactionRecordRepository.cs`
|
||||
- Method: `BuildQuery()` lines 81-86
|
||||
- Also need to update service layer to handle month=0 or null
|
||||
|
||||
### Testing Requirements
|
||||
- Test year-only query returns all transactions for that year
|
||||
- Test month-specific query still works as before
|
||||
- Test edge cases: leap years, year boundaries
|
||||
- Verify all statistics endpoints work with year-only mode
|
||||
181
.sisyphus/notepads/statistics-year-selection/learnings.md
Normal file
181
.sisyphus/notepads/statistics-year-selection/learnings.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Learnings - Statistics Year Selection Enhancement
|
||||
|
||||
## [2026-01-28] Initial Analysis
|
||||
|
||||
### Current Implementation
|
||||
- **File**: `Web/src/views/StatisticsView.vue`
|
||||
- **Current picker**: `columns-type="['year', 'month']` (year-month only)
|
||||
- **State variables**:
|
||||
- `currentYear` - integer year
|
||||
- `currentMonth` - integer month (1-12)
|
||||
- `selectedDate` - array `['YYYY', 'MM']` for picker
|
||||
- **API calls**: All endpoints use `{ year, month }` parameters
|
||||
|
||||
### Vant UI Year-Only Pattern
|
||||
- **Key prop**: `columns-type="['year']"`
|
||||
- **Picker value**: Single-element array `['YYYY']`
|
||||
- **Confirmation**: `selectedValues[0]` contains year string
|
||||
|
||||
### Implementation Strategy
|
||||
1. Add UI toggle to switch between year-month and year-only modes
|
||||
2. When year-only selected, set `currentMonth = 0` or null to indicate full year
|
||||
3. Backend API already supports year-only queries (when month=0 or null)
|
||||
4. Update display logic to show "YYYY年" vs "YYYY年MM月"
|
||||
|
||||
### API Compatibility - CRITICAL FINDING
|
||||
- **Backend limitation**: `TransactionRecordRepository.BuildQuery()` (lines 81-86) requires BOTH year AND month
|
||||
- Current logic: `if (year.HasValue && month.HasValue)` - year-only queries are NOT supported
|
||||
- **Must modify repository** to support year-only queries:
|
||||
- When year provided but month is null/0: query entire year (Jan 1 to Dec 31)
|
||||
- When both year and month provided: query specific month (current behavior)
|
||||
- All statistics endpoints use `QueryAsync(year, month, ...)` pattern
|
||||
|
||||
### Required Backend Changes
|
||||
**File**: `Repository/TransactionRecordRepository.cs`
|
||||
**Method**: `BuildQuery()` lines 81-86
|
||||
**Change**: Modify year/month filtering logic to support year-only queries
|
||||
|
||||
```csharp
|
||||
// Current (line 81-86):
|
||||
if (year.HasValue && month.HasValue)
|
||||
{
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
|
||||
// Needed:
|
||||
if (year.HasValue)
|
||||
{
|
||||
if (month.HasValue && month.Value > 0)
|
||||
{
|
||||
// Specific month
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Entire year
|
||||
var dateStart = new DateTime(year.Value, 1, 1);
|
||||
var dateEnd = new DateTime(year.Value + 1, 1, 1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Existing Patterns
|
||||
- BudgetView.vue uses same year-month picker pattern
|
||||
- Dayjs used for all date formatting: `dayjs().format('YYYY-MM-DD')`
|
||||
- Date picker values always arrays for Vant UI
|
||||
## [2026-01-28] Repository BuildQuery() Enhancement
|
||||
|
||||
### Implementation Completed
|
||||
- **File Modified**: `Repository/TransactionRecordRepository.cs` lines 81-94
|
||||
- **Change**: Updated year/month filtering logic to support year-only queries
|
||||
|
||||
### Logic Changes
|
||||
```csharp
|
||||
// Old: Required both year AND month
|
||||
if (year.HasValue && month.HasValue) { ... }
|
||||
|
||||
// New: Support year-only queries
|
||||
if (year.HasValue)
|
||||
{
|
||||
if (month.HasValue && month.Value > 0)
|
||||
{
|
||||
// 查询指定年月
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 查询整年数据(1月1日到下年1月1日)
|
||||
var dateStart = new DateTime(year.Value, 1, 1);
|
||||
var dateEnd = new DateTime(year.Value + 1, 1, 1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
- **Month-specific** (month.HasValue && month.Value > 0): Query from 1st of month to 1st of next month
|
||||
- **Year-only** (month is null or 0): Query from Jan 1 to Jan 1 of next year
|
||||
- **No year provided**: No date filtering applied
|
||||
|
||||
### Verification
|
||||
- All 14 tests pass: `dotnet test WebApi.Test/WebApi.Test.csproj`
|
||||
- No breaking changes to existing functionality
|
||||
- Chinese comments added for business logic clarity
|
||||
|
||||
### Key Pattern
|
||||
- Use `month.Value > 0` check to distinguish year-only (0/null) from month-specific (1-12)
|
||||
- Date range is exclusive on upper bound (`< dateEnd`) to avoid including boundary dates
|
||||
|
||||
## [2026-01-28] Frontend Year-Only Selection Implementation
|
||||
|
||||
### Changes Made
|
||||
**File**: `Web/src/views/StatisticsView.vue`
|
||||
|
||||
#### 1. Nav Bar Title Display (Line 12)
|
||||
- Updated to show "YYYY年" when `currentMonth === 0`
|
||||
- Shows "YYYY年MM月" when month is selected
|
||||
- Template: `{{ currentMonth === 0 ? \`${currentYear}年\` : \`${currentYear}年${currentMonth}月\` }}`
|
||||
|
||||
#### 2. Date Picker Popup (Lines 268-289)
|
||||
- Added toggle switch using `van-tabs` component
|
||||
- Two modes: "按月" (month) and "按年" (year)
|
||||
- Tabs positioned above the date picker
|
||||
- Dynamic `columns-type` based on selection mode:
|
||||
- Year mode: `['year']`
|
||||
- Month mode: `['year', 'month']`
|
||||
|
||||
#### 3. State Management (Line 347)
|
||||
- Added `dateSelectionMode` ref: `'month'` | `'year'`
|
||||
- Default: `'month'` for backward compatibility
|
||||
- `currentMonth` set to `0` when year-only selected
|
||||
|
||||
#### 4. Confirmation Handler (Lines 532-544)
|
||||
- Updated to handle both year-only and year-month modes
|
||||
- When year mode: `newMonth = 0`
|
||||
- When month mode: `newMonth = parseInt(selectedValues[1])`
|
||||
|
||||
#### 5. API Calls (All Statistics Endpoints)
|
||||
- Updated all API calls to use `month: currentMonth.value || 0`
|
||||
- Ensures backend receives `0` for year-only queries
|
||||
- Modified functions:
|
||||
- `fetchMonthlyData()` (line 574)
|
||||
- `fetchCategoryData()` (lines 592, 610, 626)
|
||||
- `fetchDailyData()` (line 649)
|
||||
- `fetchBalanceData()` (line 672)
|
||||
- `loadCategoryBills()` (line 1146)
|
||||
|
||||
#### 6. Mode Switching Watcher (Lines 1355-1366)
|
||||
- Added `watch(dateSelectionMode)` to update `selectedDate` array
|
||||
- When switching to year mode: `selectedDate = [year.toString()]`
|
||||
- When switching to month mode: `selectedDate = [year, month]`
|
||||
|
||||
#### 7. Styling (Lines 1690-1705)
|
||||
- Added `.date-picker-header` styles for tabs
|
||||
- Clean, minimal design matching Vant UI conventions
|
||||
- Proper spacing and background colors
|
||||
|
||||
### Vant UI Patterns Used
|
||||
- **van-tabs**: For mode switching toggle
|
||||
- **van-date-picker**: Dynamic `columns-type` prop
|
||||
- **van-popup**: Container for picker and tabs
|
||||
- Composition API with `watch` for reactive updates
|
||||
|
||||
### User Experience
|
||||
1. Click nav bar date → popup opens with "按月" default
|
||||
2. Switch to "按年" → picker shows only year column
|
||||
3. Select year and confirm → `currentMonth = 0`
|
||||
4. Nav bar shows "2025年" instead of "2025年1月"
|
||||
5. All statistics refresh with year-only data
|
||||
|
||||
### Verification
|
||||
- Build succeeds: `cd Web && pnpm build`
|
||||
- No TypeScript errors
|
||||
- No breaking changes to existing functionality
|
||||
- Backward compatible with month-only selection
|
||||
@@ -43,7 +43,7 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
||||
foreach (var record in records)
|
||||
{
|
||||
var categories = record.SelectedCategories.Split(',').ToList();
|
||||
for (int i = 0; i < categories.Count; i++)
|
||||
for (var i = 0; i < categories.Count; i++)
|
||||
{
|
||||
if (categories[i] == oldName)
|
||||
{
|
||||
|
||||
@@ -78,11 +78,22 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
|
||||
query = query.WhereIf(type.HasValue, t => t.Type == type!.Value);
|
||||
|
||||
if (year.HasValue && month.HasValue)
|
||||
if (year.HasValue)
|
||||
{
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
if (month.HasValue && month.Value > 0)
|
||||
{
|
||||
// 查询指定年月
|
||||
var dateStart = new DateTime(year.Value, month.Value, 1);
|
||||
var dateEnd = dateStart.AddMonths(1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 查询整年数据(1月1日到下年1月1日)
|
||||
var dateStart = new DateTime(year.Value, 1, 1);
|
||||
var dateEnd = new DateTime(year.Value + 1, 1, 1);
|
||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||
}
|
||||
}
|
||||
|
||||
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
|
||||
|
||||
@@ -494,8 +494,8 @@ public class SmartHandleService(
|
||||
/// </summary>
|
||||
private static int FindMatchingBrace(string str, int startPos)
|
||||
{
|
||||
int braceCount = 0;
|
||||
for (int i = startPos; i < str.Length; i++)
|
||||
var braceCount = 0;
|
||||
for (var i = startPos; i < str.Length; i++)
|
||||
{
|
||||
if (str[i] == '{') braceCount++;
|
||||
else if (str[i] == '}')
|
||||
|
||||
@@ -851,8 +851,8 @@ public class BudgetSavingsService(
|
||||
Array.Sort(months);
|
||||
if (months.Length >= 2)
|
||||
{
|
||||
bool isContinuous = true;
|
||||
for (int i = 1; i < months.Length; i++)
|
||||
var isContinuous = true;
|
||||
for (var i = 1; i < months.Length; i++)
|
||||
{
|
||||
if (months[i] != months[i - 1] + 1)
|
||||
{
|
||||
|
||||
@@ -82,10 +82,8 @@ public class BudgetStatsService(
|
||||
// 2. 计算限额总值
|
||||
logger.LogDebug("开始计算限额总值,共 {BudgetCount} 个预算", budgets.Count);
|
||||
decimal totalLimit = 0;
|
||||
int budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
|
||||
totalLimit += itemLimit;
|
||||
}
|
||||
@@ -94,7 +92,7 @@ public class BudgetStatsService(
|
||||
|
||||
// 3. 计算当前实际值
|
||||
// 使用 GetAllBudgetsWithArchiveAsync 中已经计算好的 Current 值
|
||||
decimal totalCurrent = budgets.Sum(b => b.Current);
|
||||
var totalCurrent = budgets.Sum(b => b.Current);
|
||||
logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)", totalCurrent, budgets.Count);
|
||||
|
||||
var transactionType = category switch
|
||||
@@ -124,8 +122,7 @@ public class BudgetStatsService(
|
||||
startDate,
|
||||
endDate,
|
||||
transactionType,
|
||||
allClassifies,
|
||||
false);
|
||||
allClassifies);
|
||||
logger.LogDebug("获取到 {DayCount} 天的交易数据", dailyStats.Count);
|
||||
|
||||
// 计算累计值(用于趋势图)
|
||||
@@ -133,7 +130,7 @@ public class BudgetStatsService(
|
||||
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
|
||||
logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth);
|
||||
|
||||
for (int i = 1; i <= daysInMonth; i++)
|
||||
for (var i = 1; i <= daysInMonth; i++)
|
||||
{
|
||||
var currentDate = new DateTime(startDate.Year, startDate.Month, i);
|
||||
if (currentDate.Date > now.Date)
|
||||
@@ -162,68 +159,9 @@ public class BudgetStatsService(
|
||||
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
|
||||
|
||||
// 检查是否为当前月份
|
||||
bool isCurrentMonth = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
|
||||
var isCurrentMonth = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
|
||||
if (isCurrentMonth && currentDate.Date <= now.Date)
|
||||
{
|
||||
// 对于每个硬性预算,计算其虚拟消耗并累加
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
decimal mandatoryDailyAmount = 0;
|
||||
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 月度硬性预算按当天的天数比例
|
||||
mandatoryDailyAmount = budget.Limit * i / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
// 年度硬性预算按当天的天数比例
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var dayOfYear = currentDate.DayOfYear;
|
||||
mandatoryDailyAmount = budget.Limit * dayOfYear / daysInYear;
|
||||
}
|
||||
|
||||
// 检查该硬性预算当天是否有实际交易记录
|
||||
// 如果budget.Current为0或很小,说明没有实际交易,需要加上虚拟消耗
|
||||
// 简化处理:直接检查如果这个硬性预算的实际值小于应有值,就补上差额
|
||||
var expectedTotal = mandatoryDailyAmount;
|
||||
|
||||
// 获取这个硬性预算对应的实际交易累计(从accumulated中无法单独提取)
|
||||
// 简化方案:直接添加硬性预算的虚拟值,让其累加到实际支出上
|
||||
// 但这样会重复计算有交易记录的硬性预算
|
||||
|
||||
// 更好的方案:只在硬性预算没有实际交易时才添加虚拟值
|
||||
// 由于budget.Current已经包含了虚拟消耗(在CalculateCurrentAmountAsync中处理)
|
||||
// 我们需要知道是否有实际交易
|
||||
|
||||
// 最简单的方案:如果budget.Current等于虚拟值,说明没有实际交易,累加虚拟值
|
||||
// 但这在趋势计算中无法判断每一天的情况
|
||||
|
||||
// 实际上,正确的做法是:
|
||||
// 1. dailyStats 只包含实际交易
|
||||
// 2. 对于硬性预算,如果它没有实际交易,需要补充虚拟消耗
|
||||
// 3. 判断方法:比较当天该预算应有的虚拟值和实际累计值
|
||||
|
||||
// 由于我们无法在这里区分某个特定预算的交易,
|
||||
// 使用简化方案:总的实际交易 + 总的硬性预算虚拟消耗的差额
|
||||
}
|
||||
|
||||
// 简化实现:计算所有硬性预算的总虚拟消耗
|
||||
decimal totalMandatoryVirtual = 0;
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
totalMandatoryVirtual += budget.Limit * i / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var dayOfYear = currentDate.DayOfYear;
|
||||
totalMandatoryVirtual += budget.Limit * dayOfYear / daysInYear;
|
||||
}
|
||||
}
|
||||
|
||||
// 关键:accumulated是所有预算的实际交易累计(不包含虚拟消耗)
|
||||
// totalMandatoryVirtual是所有硬性预算的虚拟消耗
|
||||
// 但如果硬性预算有实际交易,accumulated中已经包含了,会重复
|
||||
@@ -237,9 +175,6 @@ public class BudgetStatsService(
|
||||
|
||||
// 由于无法精确区分,采用近似方案:
|
||||
// 计算所有硬性预算的Current总和,这个值已经包含了虚拟消耗(在CalculateCurrentAmountAsync中处理)
|
||||
decimal totalMandatoryCurrent = budgets
|
||||
.Where(b => b.IsMandatoryExpense)
|
||||
.Sum(b => b.Current);
|
||||
|
||||
// 计算非硬性预算的交易累计(这部分在accumulated中)
|
||||
// 但accumulated是所有交易的累计,包括硬性预算的实际交易
|
||||
@@ -276,7 +211,7 @@ public class BudgetStatsService(
|
||||
// 需要判断该预算是否有实际交易记录
|
||||
// 简化:假设如果硬性预算的Current等于虚拟值(误差<1元),就没有实际交易
|
||||
|
||||
decimal monthlyVirtual = budget.Type == BudgetPeriodType.Month
|
||||
var monthlyVirtual = budget.Type == BudgetPeriodType.Month
|
||||
? budget.Limit * now.Day / daysInMonth
|
||||
: budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365);
|
||||
|
||||
@@ -378,7 +313,7 @@ public class BudgetStatsService(
|
||||
// 2. 计算限额总值(考虑不限额预算的特殊处理)
|
||||
logger.LogDebug("开始计算年度限额总值,共 {BudgetCount} 个预算", budgets.Count);
|
||||
decimal totalLimit = 0;
|
||||
int budgetIndex = 0;
|
||||
var budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
@@ -402,7 +337,7 @@ public class BudgetStatsService(
|
||||
logger.LogDebug("交易类型: {TransactionType}", transactionType);
|
||||
|
||||
// 计算当前实际值,考虑硬性预算的特殊逻辑
|
||||
decimal totalCurrent = budgets.Sum(b => b.Current);
|
||||
var totalCurrent = budgets.Sum(b => b.Current);
|
||||
logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)",
|
||||
totalCurrent, budgets.Count);
|
||||
|
||||
@@ -431,7 +366,7 @@ public class BudgetStatsService(
|
||||
|
||||
// 计算累计值(用于趋势图)
|
||||
decimal accumulated = 0;
|
||||
for (int i = 1; i <= 12; i++)
|
||||
for (var i = 1; i <= 12; i++)
|
||||
{
|
||||
var currentMonthDate = new DateTime(startDate.Year, i, 1);
|
||||
|
||||
@@ -461,7 +396,7 @@ public class BudgetStatsService(
|
||||
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
|
||||
|
||||
// 检查是否为当前年份
|
||||
bool isCurrentYear = referenceDate.Year == now.Year;
|
||||
var isCurrentYear = referenceDate.Year == now.Year;
|
||||
if (isCurrentYear && currentMonthDate <= now)
|
||||
{
|
||||
decimal mandatoryAdjustment = 0;
|
||||
@@ -491,7 +426,7 @@ public class BudgetStatsService(
|
||||
}
|
||||
|
||||
// 判断该硬性预算是否有实际交易
|
||||
decimal yearlyVirtual = budget.Type == BudgetPeriodType.Month
|
||||
var yearlyVirtual = budget.Type == BudgetPeriodType.Month
|
||||
? budget.Limit * now.Month + (budget.Limit * now.Day / DateTime.DaysInMonth(now.Year, now.Month)) - budget.Limit
|
||||
: budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365);
|
||||
|
||||
@@ -596,7 +531,7 @@ public class BudgetStatsService(
|
||||
if (referenceDate.Year == now.Year && now.Month > 1)
|
||||
{
|
||||
logger.LogDebug("开始处理历史归档月份: 1月到{Month}月", now.Month - 1);
|
||||
for (int m = 1; m < now.Month; m++)
|
||||
for (var m = 1; m < now.Month; m++)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(year, m);
|
||||
if (archive != null)
|
||||
@@ -627,9 +562,8 @@ public class BudgetStatsService(
|
||||
item.Name, m, item.Limit, item.Actual);
|
||||
}
|
||||
// 对于年度预算,只添加一次
|
||||
else if (item.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(item.Id))
|
||||
else if (item.Type == BudgetPeriodType.Year && processedBudgetIds.Add(item.Id))
|
||||
{
|
||||
processedBudgetIds.Add(item.Id);
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = item.Id,
|
||||
@@ -707,7 +641,6 @@ public class BudgetStatsService(
|
||||
var remainingMonths = 12 - now.Month;
|
||||
if (remainingMonths > 0)
|
||||
{
|
||||
var futureLimit = budget.Limit * remainingMonths;
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = budget.Id,
|
||||
@@ -745,7 +678,7 @@ public class BudgetStatsService(
|
||||
|
||||
if (archive != null)
|
||||
{
|
||||
int itemCount = archive.Content.Count();
|
||||
var itemCount = archive.Content.Count();
|
||||
logger.LogDebug("找到归档数据,包含 {ItemCount} 个项目", itemCount);
|
||||
foreach (var item in archive.Content)
|
||||
{
|
||||
@@ -779,7 +712,7 @@ public class BudgetStatsService(
|
||||
// 获取当前预算数据
|
||||
logger.LogDebug("开始获取当前预算数据");
|
||||
var budgets = await budgetRepository.GetAllAsync();
|
||||
int budgetCount = budgets.Count();
|
||||
var budgetCount = budgets.Count();
|
||||
logger.LogDebug("获取到 {BudgetCount} 个预算", budgetCount);
|
||||
|
||||
foreach (var budget in budgets)
|
||||
@@ -860,7 +793,7 @@ public class BudgetStatsService(
|
||||
}
|
||||
|
||||
var itemLimit = budget.Limit;
|
||||
string algorithmDescription = $"直接使用原始预算金额: {budget.Limit}";
|
||||
var algorithmDescription = $"直接使用原始预算金额: {budget.Limit}";
|
||||
|
||||
// 年度视图下,月度预算需要折算为年度
|
||||
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
||||
@@ -964,12 +897,12 @@ public class BudgetStatsService(
|
||||
|
||||
logger.LogDebug("找到 {MandatoryCount} 个硬性预算", mandatoryBudgets.Count);
|
||||
|
||||
int mandatoryIndex = 0;
|
||||
var mandatoryIndex = 0;
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
mandatoryIndex++;
|
||||
// 检查是否为当前统计周期
|
||||
var isCurrentPeriod = false;
|
||||
bool isCurrentPeriod;
|
||||
if (statType == BudgetPeriodType.Month)
|
||||
{
|
||||
isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
|
||||
@@ -989,7 +922,7 @@ public class BudgetStatsService(
|
||||
{
|
||||
// 计算硬性预算的应累加值
|
||||
decimal mandatoryAccumulation = 0;
|
||||
string accumulationAlgorithm = "";
|
||||
var accumulationAlgorithm = "";
|
||||
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
@@ -1039,55 +972,6 @@ public class BudgetStatsService(
|
||||
return adjustedTotal;
|
||||
}
|
||||
|
||||
private async Task<decimal> CalculateCurrentAmountAsync(BudgetStatsItem budget, BudgetPeriodType statType, DateTime referenceDate)
|
||||
{
|
||||
var (startDate, endDate) = GetStatPeriodRange(statType, referenceDate);
|
||||
|
||||
// 创建临时的BudgetRecord用于查询
|
||||
var tempRecord = new BudgetRecord
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
Type = budget.Type,
|
||||
Limit = budget.Limit,
|
||||
Category = budget.Category,
|
||||
SelectedCategories = string.Join(",", budget.SelectedCategories),
|
||||
StartDate = referenceDate,
|
||||
NoLimit = budget.NoLimit,
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense
|
||||
};
|
||||
|
||||
// 获取预算的实际时间段
|
||||
var (budgetStart, budgetEnd) = BudgetService.GetPeriodRange(tempRecord.StartDate, tempRecord.Type, referenceDate);
|
||||
|
||||
// 确保在统计时间段内
|
||||
if (budgetEnd < startDate || budgetStart > endDate)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var actualAmount = await budgetRepository.GetCurrentAmountAsync(tempRecord, startDate, endDate);
|
||||
|
||||
// 硬性预算的特殊处理
|
||||
if (actualAmount == 0 && budget.IsMandatoryExpense)
|
||||
{
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
|
||||
var daysElapsed = dateTimeProvider.Now.Day;
|
||||
actualAmount = budget.Limit * daysElapsed / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var daysElapsed = dateTimeProvider.Now.DayOfYear;
|
||||
actualAmount = budget.Limit * daysElapsed / daysInYear;
|
||||
}
|
||||
}
|
||||
|
||||
return actualAmount;
|
||||
}
|
||||
|
||||
private (DateTime start, DateTime end) GetStatPeriodRange(BudgetPeriodType statType, DateTime referenceDate)
|
||||
{
|
||||
if (statType == BudgetPeriodType.Month)
|
||||
@@ -1104,21 +988,21 @@ public class BudgetStatsService(
|
||||
}
|
||||
}
|
||||
|
||||
private class BudgetStatsItem
|
||||
private record BudgetStatsItem
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; set; }
|
||||
public decimal Limit { get; set; }
|
||||
public decimal Current { get; set; }
|
||||
public long Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; init; }
|
||||
public decimal Limit { get; init; }
|
||||
public decimal Current { get; init; }
|
||||
public BudgetCategory Category { get; set; }
|
||||
public string[] SelectedCategories { get; set; } = [];
|
||||
public bool NoLimit { get; set; }
|
||||
public bool IsMandatoryExpense { get; set; }
|
||||
public bool IsArchive { get; set; }
|
||||
public int ArchiveMonth { get; set; } // 归档月份(1-12),用于标识归档数据来自哪个月
|
||||
public int RemainingMonths { get; set; } // 剩余月份数,用于年度统计时的月度预算折算
|
||||
public bool IsCurrentMonth { get; set; } // 标记是否为当前月的预算(用于年度统计中月度预算的计算)
|
||||
public string[] SelectedCategories { get; init; } = [];
|
||||
public bool NoLimit { get; init; }
|
||||
public bool IsMandatoryExpense { get; init; }
|
||||
public bool IsArchive { get; init; }
|
||||
public int ArchiveMonth { get; init; } // 归档月份(1-12),用于标识归档数据来自哪个月
|
||||
public int RemainingMonths { get; init; } // 剩余月份数,用于年度统计时的月度预算折算
|
||||
public bool IsCurrentMonth { get; init; } // 标记是否为当前月的预算(用于年度统计中月度预算的计算)
|
||||
}
|
||||
|
||||
private string GenerateMonthlyDescription(List<BudgetStatsItem> budgets, decimal totalLimit, decimal totalCurrent, DateTime referenceDate, BudgetCategory category)
|
||||
@@ -1189,10 +1073,10 @@ public class BudgetStatsService(
|
||||
var categoryName = category == BudgetCategory.Expense ? "支出" : "收入";
|
||||
|
||||
// 分组:归档的月度预算、归档的年度预算、当前月度预算(剩余月份)、当前年度预算
|
||||
var archivedMonthlyBudgets = budgets.Where(b => b.IsArchive && b.Type == BudgetPeriodType.Month).ToList();
|
||||
var archivedYearlyBudgets = budgets.Where(b => b.IsArchive && b.Type == BudgetPeriodType.Year).ToList();
|
||||
var currentMonthlyBudgets = budgets.Where(b => !b.IsArchive && b.Type == BudgetPeriodType.Month).ToList();
|
||||
var currentYearlyBudgets = budgets.Where(b => !b.IsArchive && b.Type == BudgetPeriodType.Year).ToList();
|
||||
var archivedMonthlyBudgets = budgets.Where(b => b is { IsArchive: true, Type: BudgetPeriodType.Month }).ToList();
|
||||
var archivedYearlyBudgets = budgets.Where(b => b is { IsArchive: true, Type: BudgetPeriodType.Year }).ToList();
|
||||
var currentMonthlyBudgets = budgets.Where(b => b is { IsArchive: false, Type: BudgetPeriodType.Month }).ToList();
|
||||
var currentYearlyBudgets = budgets.Where(b => b is { IsArchive: false, Type: BudgetPeriodType.Year }).ToList();
|
||||
|
||||
// 归档月度预算明细
|
||||
if (archivedMonthlyBudgets.Any())
|
||||
@@ -1375,8 +1259,8 @@ public class BudgetStatsService(
|
||||
|
||||
// 如果是连续的月份,简化显示为 1~3月
|
||||
Array.Sort(months);
|
||||
bool isContinuous = true;
|
||||
for (int i = 1; i < months.Length; i++)
|
||||
var isContinuous = true;
|
||||
for (var i = 1; i < months.Length; i++)
|
||||
{
|
||||
if (months[i] != months[i - 1] + 1)
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ public class EmailHandleService(
|
||||
|
||||
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
|
||||
|
||||
bool allSuccess = true;
|
||||
var allSuccess = true;
|
||||
var records = new List<TransactionRecord>();
|
||||
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
|
||||
{
|
||||
@@ -144,7 +144,7 @@ public class EmailHandleService(
|
||||
|
||||
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
|
||||
|
||||
bool allSuccess = true;
|
||||
var allSuccess = true;
|
||||
var records = new List<TransactionRecord>();
|
||||
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
|
||||
{
|
||||
|
||||
@@ -92,7 +92,7 @@ public partial class EmailParseFormCcsvc(
|
||||
{
|
||||
foreach (var node in transactionNodes)
|
||||
{
|
||||
string card = "";
|
||||
var card = "";
|
||||
try
|
||||
{
|
||||
// Time
|
||||
|
||||
@@ -150,19 +150,19 @@ public abstract class EmailParseServicesBase(
|
||||
|
||||
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseSingleRecord(JsonElement obj)
|
||||
{
|
||||
string card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
|
||||
string reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
|
||||
string typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
|
||||
string occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
|
||||
var card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
|
||||
var reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
|
||||
var typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
|
||||
var occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
decimal amount = 0m;
|
||||
var amount = 0m;
|
||||
if (obj.TryGetProperty("amount", out var pAmount))
|
||||
{
|
||||
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d;
|
||||
else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
|
||||
}
|
||||
|
||||
decimal balance = 0m;
|
||||
var balance = 0m;
|
||||
if (obj.TryGetProperty("balance", out var pBalance))
|
||||
{
|
||||
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
|
||||
|
||||
@@ -432,7 +432,7 @@ public class ImportService(
|
||||
|
||||
// 读取表头(第一行)
|
||||
var headers = new List<string>();
|
||||
for (int col = 1; col <= colCount; col++)
|
||||
for (var col = 1; col <= colCount; col++)
|
||||
{
|
||||
var header = worksheet.Cells[1, col].Text?.Trim() ?? string.Empty;
|
||||
headers.Add(header);
|
||||
@@ -441,10 +441,10 @@ public class ImportService(
|
||||
var result = new List<IDictionary<string, string>>();
|
||||
|
||||
// 读取数据行(从第二行开始)
|
||||
for (int row = 2; row <= rowCount; row++)
|
||||
for (var row = 2; row <= rowCount; row++)
|
||||
{
|
||||
var rowData = new Dictionary<string, string>();
|
||||
for (int col = 1; col <= colCount; col++)
|
||||
for (var col = 1; col <= colCount; col++)
|
||||
{
|
||||
var header = headers[col - 1];
|
||||
var value = worksheet.Cells[row, col].Text?.Trim() ?? string.Empty;
|
||||
|
||||
@@ -135,7 +135,7 @@ public class TransactionStatisticsService(
|
||||
{
|
||||
var trends = new List<TrendStatistics>();
|
||||
|
||||
for (int i = 0; i < monthCount; i++)
|
||||
for (var i = 0; i < monthCount; i++)
|
||||
{
|
||||
var targetYear = startYear;
|
||||
var targetMonth = startMonth + i;
|
||||
@@ -249,7 +249,7 @@ public class TransactionStatisticsService(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
type: type,
|
||||
classifies: classifies?.ToArray(),
|
||||
classifies: classifies.ToArray(),
|
||||
pageSize: int.MaxValue);
|
||||
|
||||
if (groupByMonth)
|
||||
|
||||
@@ -439,7 +439,7 @@ const updateVarianceChart = (chart, budgets) => {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const item = data[params[0].dataIndex]
|
||||
const item = sortedData[params[0].dataIndex]
|
||||
let html = `${item.name}<br/>`
|
||||
html += `预算: ¥${formatMoney(item.limit)}<br/>`
|
||||
html += `实际: ¥${formatMoney(item.current)}<br/>`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<!-- 顶部导航栏 -->
|
||||
<van-nav-bar
|
||||
@@ -9,7 +9,7 @@
|
||||
class="nav-date-picker"
|
||||
@click="showMonthPicker = true"
|
||||
>
|
||||
<span>{{ currentYear }}年{{ currentMonth }}月</span>
|
||||
<span>{{ currentMonth === 0 ? `${currentYear}年` : `${currentYear}年${currentMonth}月` }}</span>
|
||||
<van-icon name="arrow-down" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -265,19 +265,29 @@
|
||||
</div>
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 月份选择器 -->
|
||||
<!-- 日期选择器 -->
|
||||
<van-popup
|
||||
v-model:show="showMonthPicker"
|
||||
position="bottom"
|
||||
round
|
||||
teleport="body"
|
||||
>
|
||||
<div class="date-picker-header">
|
||||
<van-tabs
|
||||
v-model:active="dateSelectionMode"
|
||||
line-width="20px"
|
||||
:ellipsis="false"
|
||||
>
|
||||
<van-tab title="按月" name="month" />
|
||||
<van-tab title="按年" name="year" />
|
||||
</van-tabs>
|
||||
</div>
|
||||
<van-date-picker
|
||||
v-model="selectedDate"
|
||||
title="选择月份"
|
||||
:title="dateSelectionMode === 'year' ? '选择年份' : '选择月份'"
|
||||
:min-date="minDate"
|
||||
:max-date="maxDate"
|
||||
:columns-type="['year', 'month']"
|
||||
:columns-type="dateSelectionMode === 'year' ? ['year'] : ['year', 'month']"
|
||||
@confirm="onMonthConfirm"
|
||||
@cancel="showMonthPicker = false"
|
||||
/>
|
||||
@@ -344,6 +354,7 @@ const firstLoading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const showMonthPicker = ref(false)
|
||||
const showAllExpense = ref(false)
|
||||
const dateSelectionMode = ref('month')
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
const currentMonth = ref(new Date().getMonth() + 1)
|
||||
const selectedDate = ref([
|
||||
@@ -531,7 +542,7 @@ const onRefresh = async () => {
|
||||
// 确认月份选择
|
||||
const onMonthConfirm = ({ selectedValues }) => {
|
||||
const newYear = parseInt(selectedValues[0])
|
||||
const newMonth = parseInt(selectedValues[1])
|
||||
const newMonth = dateSelectionMode.value === 'year' ? 0 : parseInt(selectedValues[1])
|
||||
|
||||
currentYear.value = newYear
|
||||
currentMonth.value = newMonth
|
||||
@@ -571,7 +582,7 @@ const fetchMonthlyData = async () => {
|
||||
try {
|
||||
const response = await getMonthlyStatistics({
|
||||
year: currentYear.value,
|
||||
month: currentMonth.value
|
||||
month: currentMonth.value || 0
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
@@ -589,7 +600,7 @@ const fetchCategoryData = async () => {
|
||||
// 获取支出分类
|
||||
const expenseResponse = await getCategoryStatistics({
|
||||
year: currentYear.value,
|
||||
month: currentMonth.value,
|
||||
month: currentMonth.value || 0,
|
||||
type: 0 // 支出
|
||||
})
|
||||
|
||||
@@ -607,7 +618,7 @@ const fetchCategoryData = async () => {
|
||||
// 获取收入分类
|
||||
const incomeResponse = await getCategoryStatistics({
|
||||
year: currentYear.value,
|
||||
month: currentMonth.value,
|
||||
month: currentMonth.value || 0,
|
||||
type: 1 // 收入
|
||||
})
|
||||
|
||||
@@ -623,7 +634,7 @@ const fetchCategoryData = async () => {
|
||||
// 获取不计收支分类
|
||||
const noneResponse = await getCategoryStatistics({
|
||||
year: currentYear.value,
|
||||
month: currentMonth.value,
|
||||
month: currentMonth.value || 0,
|
||||
type: 2 // 不计收支
|
||||
})
|
||||
|
||||
@@ -646,7 +657,7 @@ const fetchDailyData = async () => {
|
||||
try {
|
||||
const response = await getDailyStatistics({
|
||||
year: currentYear.value,
|
||||
month: currentMonth.value
|
||||
month: currentMonth.value || 0
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
@@ -669,7 +680,7 @@ const fetchBalanceData = async () => {
|
||||
try {
|
||||
const response = await getBalanceStatistics({
|
||||
year: currentYear.value,
|
||||
month: currentMonth.value
|
||||
month: currentMonth.value || 0
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
@@ -1143,7 +1154,7 @@ const loadCategoryBills = async (customIndex = null, customSize = null) => {
|
||||
pageSize: customSize || billPageSize,
|
||||
type: selectedType.value,
|
||||
year: currentYear.value,
|
||||
month: currentMonth.value,
|
||||
month: currentMonth.value || 0,
|
||||
sortByAmount: true
|
||||
}
|
||||
|
||||
@@ -1359,6 +1370,20 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener &&
|
||||
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||
})
|
||||
|
||||
// 监听日期选择模式变化,更新selectedDate数组
|
||||
watch(dateSelectionMode, (newMode) => {
|
||||
if (newMode === 'year') {
|
||||
// 切换到年份模式:只保留年份
|
||||
selectedDate.value = [currentYear.value.toString()]
|
||||
} else {
|
||||
// 切换到月份模式:添加当前月份
|
||||
selectedDate.value = [
|
||||
currentYear.value.toString(),
|
||||
(currentMonth.value || new Date().getMonth() + 1).toString().padStart(2, '0')
|
||||
]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1693,4 +1718,25 @@ onBeforeUnmount(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 日期选择器头部 */
|
||||
.date-picker-header {
|
||||
padding: 12px 16px 0;
|
||||
background: var(--van-background-2);
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
}
|
||||
|
||||
.date-picker-header :deep(.van-tabs) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.date-picker-header :deep(.van-tabs__nav) {
|
||||
background: transparent;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.date-picker-header :deep(.van-tab) {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -16,14 +16,13 @@ public class BudgetStatsTest : BaseTest
|
||||
private readonly ILogger<BudgetService> _logger = Substitute.For<ILogger<BudgetService>>();
|
||||
private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For<IBudgetSavingsService>();
|
||||
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
|
||||
private readonly IBudgetStatsService _budgetStatsService;
|
||||
private readonly BudgetService _service;
|
||||
|
||||
public BudgetStatsTest()
|
||||
{
|
||||
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
|
||||
|
||||
_budgetStatsService = new BudgetStatsService(
|
||||
IBudgetStatsService budgetStatsService = new BudgetStatsService(
|
||||
_budgetRepository,
|
||||
_budgetArchiveRepository,
|
||||
_transactionStatisticsService,
|
||||
@@ -41,7 +40,7 @@ public class BudgetStatsTest : BaseTest
|
||||
_logger,
|
||||
_budgetSavingsService,
|
||||
_dateTimeProvider,
|
||||
_budgetStatsService
|
||||
budgetStatsService
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,8 +123,7 @@ public class BudgetStatsTest : BaseTest
|
||||
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
|
||||
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 31),
|
||||
TransactionType.Expense,
|
||||
Arg.Is<List<string>>(list => list.Count == 1 && list.Contains("餐饮")), // 只包含月度预算的分类
|
||||
false)
|
||||
Arg.Is<List<string>>(list => list.Count == 1 && list.Contains("餐饮")))
|
||||
.Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2024, 1, 15), 800m } // 1月15日月度吃饭累计800
|
||||
@@ -281,8 +279,7 @@ public class BudgetStatsTest : BaseTest
|
||||
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
|
||||
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
|
||||
TransactionType.Expense,
|
||||
Arg.Is<List<string>>(list => list.Count == 3 && list.Contains("餐饮") && list.Contains("零食") && list.Contains("交通")),
|
||||
false)
|
||||
Arg.Is<List<string>>(list => list.Count == 3 && list.Contains("餐饮") && list.Contains("零食") && list.Contains("交通")))
|
||||
.Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500(吃喝1200+交通300,不包含年度旅游2000)
|
||||
@@ -305,8 +302,7 @@ public class BudgetStatsTest : BaseTest
|
||||
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
|
||||
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
|
||||
TransactionType.Income,
|
||||
Arg.Any<List<string>>(),
|
||||
false)
|
||||
Arg.Any<List<string>>())
|
||||
.Returns(new Dictionary<DateTime, decimal>()); // 月度收入为空
|
||||
|
||||
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
|
||||
|
||||
35
WebApi.Test/Repository/BudgetArchiveRepositoryTest.cs
Normal file
35
WebApi.Test/Repository/BudgetArchiveRepositoryTest.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class BudgetArchiveRepositoryTest : RepositoryTestBase
|
||||
{
|
||||
private readonly IBudgetArchiveRepository _repository;
|
||||
|
||||
public BudgetArchiveRepositoryTest()
|
||||
{
|
||||
_repository = new BudgetArchiveRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetArchiveAsync_获取单条归档_Test()
|
||||
{
|
||||
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 1 });
|
||||
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 2 });
|
||||
|
||||
var archive = await _repository.GetArchiveAsync(2023, 1);
|
||||
archive.Should().NotBeNull();
|
||||
archive!.Month.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetArchivesByYearAsync_按年获取_Test()
|
||||
{
|
||||
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 1 });
|
||||
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 2 });
|
||||
await _repository.AddAsync(new BudgetArchive { Year = 2022, Month = 12 });
|
||||
|
||||
var list = await _repository.GetArchivesByYearAsync(2023);
|
||||
list.Should().HaveCount(2);
|
||||
}
|
||||
}
|
||||
72
WebApi.Test/Repository/BudgetRepositoryTest.cs
Normal file
72
WebApi.Test/Repository/BudgetRepositoryTest.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class BudgetRepositoryTest : TransactionTestBase
|
||||
{
|
||||
private readonly IBudgetRepository _repository;
|
||||
private readonly ITransactionRecordRepository _transactionRepository;
|
||||
|
||||
public BudgetRepositoryTest()
|
||||
{
|
||||
_repository = new BudgetRepository(FreeSql);
|
||||
_transactionRepository = new TransactionRecordRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentAmountAsync_计算预算金额_Test()
|
||||
{
|
||||
// Arrange
|
||||
// 插入一些交易记录
|
||||
await _transactionRepository.AddAsync(CreateExpense(100, classify: "餐饮")); // A
|
||||
await _transactionRepository.AddAsync(CreateExpense(200, classify: "交通")); // B
|
||||
await _transactionRepository.AddAsync(CreateExpense(50, classify: "餐饮")); // C
|
||||
await _transactionRepository.AddAsync(CreateIncome(1000)); // 收入,不应计入支出预算
|
||||
|
||||
var budget = new BudgetRecord
|
||||
{
|
||||
Limit = 2000,
|
||||
Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "餐饮,购物", // Only 餐饮 matches
|
||||
Name = "日常开销"
|
||||
};
|
||||
|
||||
var startDate = DateTime.Now.AddDays(-1);
|
||||
var endDate = DateTime.Now.AddDays(1);
|
||||
|
||||
// Act
|
||||
var amount = await _repository.GetCurrentAmountAsync(budget, startDate, endDate);
|
||||
|
||||
// Assert
|
||||
// Should sum A+C = -150. But wait, transaction amounts for expense are negative in CreateExpense?
|
||||
// Let's check CreateExpense: return CreateTestRecord(-amount, ...);
|
||||
// So actual stored values are -100, -200, -50.
|
||||
// SumAsync sums them up. Result should be -150.
|
||||
amount.Should().Be(-150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBudgetCategoryNameAsync_更新分类名称_Test()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.AddAsync(new BudgetRecord { Name = "B1", SelectedCategories = "餐饮,交通", Category = BudgetCategory.Expense });
|
||||
await _repository.AddAsync(new BudgetRecord { Name = "B2", SelectedCategories = "餐饮", Category = BudgetCategory.Expense });
|
||||
await _repository.AddAsync(new BudgetRecord { Name = "B3", SelectedCategories = "住宿", Category = BudgetCategory.Expense });
|
||||
|
||||
// Act
|
||||
// 将 "餐饮" 更新为 "美食"
|
||||
await _repository.UpdateBudgetCategoryNameAsync("餐饮", "美食", TransactionType.Expense);
|
||||
|
||||
// Assert
|
||||
var b1 = await _repository.GetByIdAsync(1); // Assuming ID 1 (Standard FreeSql behavior depending on implementation, but I used standard Add)
|
||||
// Actually, IDs are snowflake. I should capture them.
|
||||
|
||||
var all = await _repository.GetAllAsync();
|
||||
var b1_updated = all.First(b => b.Name == "B1");
|
||||
b1_updated.SelectedCategories.Should().Contain("美食");
|
||||
b1_updated.SelectedCategories.Should().NotContain("餐饮");
|
||||
|
||||
var b2_updated = all.First(b => b.Name == "B2");
|
||||
b2_updated.SelectedCategories.Should().Be("美食");
|
||||
}
|
||||
}
|
||||
23
WebApi.Test/Repository/ConfigRepositoryTest.cs
Normal file
23
WebApi.Test/Repository/ConfigRepositoryTest.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class ConfigRepositoryTest : RepositoryTestBase
|
||||
{
|
||||
private readonly IConfigRepository _repository;
|
||||
|
||||
public ConfigRepositoryTest()
|
||||
{
|
||||
_repository = new ConfigRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByKeyAsync_获取配置_Test()
|
||||
{
|
||||
await _repository.AddAsync(new ConfigEntity { Key = "k1", Value = "v1" });
|
||||
|
||||
var config = await _repository.GetByKeyAsync("k1");
|
||||
config.Should().NotBeNull();
|
||||
config!.Value.Should().Be("v1");
|
||||
}
|
||||
}
|
||||
54
WebApi.Test/Repository/EmailMessageRepositoryTest.cs
Normal file
54
WebApi.Test/Repository/EmailMessageRepositoryTest.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class EmailMessageRepositoryTest : RepositoryTestBase
|
||||
{
|
||||
private readonly IEmailMessageRepository _repository;
|
||||
|
||||
public EmailMessageRepositoryTest()
|
||||
{
|
||||
_repository = new EmailMessageRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_检查存在_Test()
|
||||
{
|
||||
await _repository.AddAsync(new EmailMessage { Md5 = "md5_value", Subject = "Test" });
|
||||
|
||||
var msg = await _repository.ExistsAsync("md5_value");
|
||||
msg.Should().NotBeNull();
|
||||
|
||||
var notfound = await _repository.ExistsAsync("other");
|
||||
notfound.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedListAsync_游标分页_Test()
|
||||
{
|
||||
// 插入3条数据,时间倒序
|
||||
var m1 = new EmailMessage { Subject = "M1", ReceivedDate = DateTime.Now }; // Latest
|
||||
var m2 = new EmailMessage { Subject = "M2", ReceivedDate = DateTime.Now.AddDays(-1) };
|
||||
var m3 = new EmailMessage { Subject = "M3", ReceivedDate = DateTime.Now.AddDays(-2) }; // Oldest
|
||||
|
||||
// FreeSql IDs are snowflakes, increasing.
|
||||
// Assuming ID order follows insertion (mostly true for snowflakes if generated sequentially)
|
||||
// But ReceivedDate is the primary sort in logic usually.
|
||||
// Let's verify standard cursor pagination usually sorts by Date DESC, ID DESC.
|
||||
|
||||
await _repository.AddAsync(m1);
|
||||
await _repository.AddAsync(m2);
|
||||
await _repository.AddAsync(m3);
|
||||
|
||||
// Fetch page 1 (size 2)
|
||||
var result1 = await _repository.GetPagedListAsync(null, null, 2);
|
||||
result1.list.Should().HaveCount(2);
|
||||
result1.list[0].Subject.Should().Be("M1");
|
||||
result1.list[1].Subject.Should().Be("M2");
|
||||
|
||||
// Fetch page 2 using cursor
|
||||
var result2 = await _repository.GetPagedListAsync(result1.lastReceivedDate, result1.lastId, 2);
|
||||
result2.list.Should().HaveCount(1);
|
||||
result2.list[0].Subject.Should().Be("M3");
|
||||
}
|
||||
}
|
||||
39
WebApi.Test/Repository/MessageRecordRepositoryTest.cs
Normal file
39
WebApi.Test/Repository/MessageRecordRepositoryTest.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class MessageRecordRepositoryTest : RepositoryTestBase
|
||||
{
|
||||
private readonly IMessageRecordRepository _repository;
|
||||
|
||||
public MessageRecordRepositoryTest()
|
||||
{
|
||||
_repository = new MessageRecordRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedListAsync_分页_Test()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repository.AddAsync(new MessageRecord { Content = $"Msg{i}" });
|
||||
}
|
||||
|
||||
var result = await _repository.GetPagedListAsync(1, 2);
|
||||
result.Total.Should().Be(5);
|
||||
result.List.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkAllAsReadAsync_全部标记已读_Test()
|
||||
{
|
||||
await _repository.AddAsync(new MessageRecord { IsRead = false });
|
||||
await _repository.AddAsync(new MessageRecord { IsRead = false });
|
||||
await _repository.AddAsync(new MessageRecord { IsRead = true });
|
||||
|
||||
await _repository.MarkAllAsReadAsync();
|
||||
|
||||
var all = await _repository.GetAllAsync();
|
||||
all.All(x => x.IsRead).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
23
WebApi.Test/Repository/PushSubscriptionRepositoryTest.cs
Normal file
23
WebApi.Test/Repository/PushSubscriptionRepositoryTest.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class PushSubscriptionRepositoryTest : RepositoryTestBase
|
||||
{
|
||||
private readonly IPushSubscriptionRepository _repository;
|
||||
|
||||
public PushSubscriptionRepositoryTest()
|
||||
{
|
||||
_repository = new PushSubscriptionRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByEndpointAsync_通过Endpoint获取_Test()
|
||||
{
|
||||
await _repository.AddAsync(new PushSubscription { Endpoint = "ep1" });
|
||||
|
||||
var sub = await _repository.GetByEndpointAsync("ep1");
|
||||
sub.Should().NotBeNull();
|
||||
sub!.Endpoint.Should().Be("ep1");
|
||||
}
|
||||
}
|
||||
30
WebApi.Test/Repository/RepositoryTestBase.cs
Normal file
30
WebApi.Test/Repository/RepositoryTestBase.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using FreeSql;
|
||||
using WebApi.Test.Basic;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public abstract class RepositoryTestBase : BaseTest, IDisposable
|
||||
{
|
||||
protected IFreeSql FreeSql { get; }
|
||||
|
||||
protected RepositoryTestBase()
|
||||
{
|
||||
FreeSql = new FreeSqlBuilder()
|
||||
.UseConnectionString(DataType.Sqlite, "Data Source=:memory:")
|
||||
.UseAutoSyncStructure(true)
|
||||
.UseNoneCommandParameter(true)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
FreeSql.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
52
WebApi.Test/Repository/TransactionCategoryRepositoryTest.cs
Normal file
52
WebApi.Test/Repository/TransactionCategoryRepositoryTest.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class TransactionCategoryRepositoryTest : TransactionTestBase
|
||||
{
|
||||
private readonly ITransactionCategoryRepository _repository;
|
||||
private readonly ITransactionRecordRepository _transactionRepository;
|
||||
|
||||
public TransactionCategoryRepositoryTest()
|
||||
{
|
||||
_repository = new TransactionCategoryRepository(FreeSql);
|
||||
_transactionRepository = new TransactionRecordRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCategoriesByTypeAsync_按类型获取_Test()
|
||||
{
|
||||
await _repository.AddAsync(new TransactionCategory { Name = "C1", Type = TransactionType.Expense });
|
||||
await _repository.AddAsync(new TransactionCategory { Name = "C2", Type = TransactionType.Income });
|
||||
|
||||
var results = await _repository.GetCategoriesByTypeAsync(TransactionType.Expense);
|
||||
results.Should().HaveCount(1);
|
||||
results.First().Name.Should().Be("C1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByNameAndTypeAsync_按名称和类型查找_Test()
|
||||
{
|
||||
await _repository.AddAsync(new TransactionCategory { Name = "C1", Type = TransactionType.Expense });
|
||||
|
||||
var category = await _repository.GetByNameAndTypeAsync("C1", TransactionType.Expense);
|
||||
category.Should().NotBeNull();
|
||||
category!.Name.Should().Be("C1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCategoryInUseAsync_检查是否使用_Test()
|
||||
{
|
||||
var category = new TransactionCategory { Name = "UsedCategory", Type = TransactionType.Expense };
|
||||
await _repository.AddAsync(category);
|
||||
|
||||
var unused = new TransactionCategory { Name = "Unused", Type = TransactionType.Expense };
|
||||
await _repository.AddAsync(unused);
|
||||
|
||||
// Add transaction using "UsedCategory"
|
||||
await _transactionRepository.AddAsync(CreateExpense(100, classify: "UsedCategory"));
|
||||
|
||||
(await _repository.IsCategoryInUseAsync(category.Id)).Should().BeTrue();
|
||||
(await _repository.IsCategoryInUseAsync(unused.Id)).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
46
WebApi.Test/Repository/TransactionPeriodicRepositoryTest.cs
Normal file
46
WebApi.Test/Repository/TransactionPeriodicRepositoryTest.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class TransactionPeriodicRepositoryTest : TransactionTestBase
|
||||
{
|
||||
private readonly ITransactionPeriodicRepository _repository;
|
||||
|
||||
public TransactionPeriodicRepositoryTest()
|
||||
{
|
||||
_repository = new TransactionPeriodicRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingPeriodicBillsAsync_获取待执行账单_Test()
|
||||
{
|
||||
// 应该执行的:NextExecuteTime <= Now
|
||||
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill1", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = true });
|
||||
|
||||
// 不该执行的:NextExecuteTime > Now
|
||||
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill2", NextExecuteTime = DateTime.Now.AddDays(1), IsEnabled = true });
|
||||
|
||||
// 不该执行的:未激活
|
||||
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill3", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = false });
|
||||
|
||||
var results = await _repository.GetPendingPeriodicBillsAsync();
|
||||
results.Should().HaveCount(1);
|
||||
results.First().Reason.Should().Be("Bill1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateExecuteTimeAsync_更新执行时间_Test()
|
||||
{
|
||||
var bill = new TransactionPeriodic { Reason = "Bill", NextExecuteTime = DateTime.Now };
|
||||
await _repository.AddAsync(bill);
|
||||
|
||||
var last = DateTime.Now;
|
||||
var next = DateTime.Now.AddMonths(1);
|
||||
|
||||
await _repository.UpdateExecuteTimeAsync(bill.Id, last, next);
|
||||
|
||||
var updated = await _repository.GetByIdAsync(bill.Id);
|
||||
updated!.LastExecuteTime.Should().BeCloseTo(last, TimeSpan.FromSeconds(1));
|
||||
updated.NextExecuteTime.Should().BeCloseTo(next, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
}
|
||||
108
WebApi.Test/Repository/TransactionRecordRepositoryTest.cs
Normal file
108
WebApi.Test/Repository/TransactionRecordRepositoryTest.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using FluentAssertions;
|
||||
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
public class TransactionRecordRepositoryTest : TransactionTestBase
|
||||
{
|
||||
private readonly ITransactionRecordRepository _repository;
|
||||
|
||||
public TransactionRecordRepositoryTest()
|
||||
{
|
||||
_repository = new TransactionRecordRepository(FreeSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_添加记录_Test()
|
||||
{
|
||||
var record = CreateTestRecord(-100);
|
||||
var result = await _repository.AddAsync(record);
|
||||
result.Should().BeTrue();
|
||||
|
||||
var dbRecord = await _repository.GetByIdAsync(record.Id);
|
||||
dbRecord.Should().NotBeNull();
|
||||
dbRecord!.Amount.Should().Be(-100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_按类型筛选_Test()
|
||||
{
|
||||
await _repository.AddAsync(CreateExpense(100));
|
||||
await _repository.AddAsync(CreateIncome(200));
|
||||
|
||||
var expenses = await _repository.QueryAsync(type: TransactionType.Expense);
|
||||
expenses.Should().HaveCount(1);
|
||||
expenses.First().Amount.Should().Be(-100);
|
||||
|
||||
var incomes = await _repository.QueryAsync(type: TransactionType.Income);
|
||||
incomes.Should().HaveCount(1);
|
||||
incomes.First().Amount.Should().Be(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_按通过时间范围筛选_Test()
|
||||
{
|
||||
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 1, 1)));
|
||||
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 2, 1)));
|
||||
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 3, 1)));
|
||||
|
||||
// 查询 1月到2月
|
||||
var results = await _repository.QueryAsync(
|
||||
startDate: new DateTime(2023, 1, 1),
|
||||
endDate: new DateTime(2023, 2, 28)); // Include Feb
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_按年月筛选_Test()
|
||||
{
|
||||
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 1, 15)));
|
||||
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 2, 15)));
|
||||
|
||||
var results = await _repository.QueryAsync(year: 2023, month: 1);
|
||||
results.Should().HaveCount(1);
|
||||
results.First().OccurredAt.Month.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountAsync_统计数量_Test()
|
||||
{
|
||||
await _repository.AddAsync(CreateExpense(100));
|
||||
await _repository.AddAsync(CreateExpense(200));
|
||||
await _repository.AddAsync(CreateIncome(3000));
|
||||
|
||||
var count = await _repository.CountAsync(type: TransactionType.Expense);
|
||||
count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDistinctClassifyAsync_获取去重分类_Test()
|
||||
{
|
||||
await _repository.AddAsync(CreateExpense(100, classify: "餐饮"));
|
||||
await _repository.AddAsync(CreateExpense(100, classify: "餐饮"));
|
||||
await _repository.AddAsync(CreateExpense(100, classify: "交通"));
|
||||
|
||||
var classifies = await _repository.GetDistinctClassifyAsync();
|
||||
classifies.Should().HaveCount(2);
|
||||
classifies.Should().Contain("餐饮");
|
||||
classifies.Should().Contain("交通");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateByReasonAsync_批量更新_Test()
|
||||
{
|
||||
await _repository.AddAsync(CreateExpense(100, reason: "麦当劳", classify: "餐饮"));
|
||||
await _repository.AddAsync(CreateExpense(100, reason: "麦当劳", classify: "餐饮"));
|
||||
await _repository.AddAsync(CreateExpense(100, reason: "肯德基", classify: "餐饮"));
|
||||
|
||||
// 将所有"麦当劳"改为"快餐"分类,类型保持支出
|
||||
var count = await _repository.BatchUpdateByReasonAsync("麦当劳", TransactionType.Expense, "快餐");
|
||||
count.Should().Be(2);
|
||||
|
||||
var records = await _repository.QueryAsync(reason: "麦当劳");
|
||||
records.All(r => r.Classify == "快餐").Should().BeTrue();
|
||||
|
||||
var kfc = await _repository.QueryAsync(reason: "肯德基");
|
||||
kfc.First().Classify.Should().Be("餐饮");
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,7 @@
|
||||
using FreeSql;
|
||||
using Repository;
|
||||
namespace WebApi.Test.Repository;
|
||||
|
||||
namespace WebApi.Test.Basic;
|
||||
|
||||
public class DatabaseTest : BaseTest, IDisposable
|
||||
public class TransactionTestBase : RepositoryTestBase
|
||||
{
|
||||
protected IFreeSql FreeSql { get; }
|
||||
protected ITransactionRecordRepository Repository { get; }
|
||||
|
||||
public DatabaseTest()
|
||||
{
|
||||
FreeSql = new FreeSqlBuilder()
|
||||
.UseConnectionString(DataType.Sqlite, "Data Source=:memory:")
|
||||
.UseAutoSyncStructure(true)
|
||||
.UseNoneCommandParameter(true)
|
||||
.Build();
|
||||
|
||||
Repository = new TransactionRecordRepository(FreeSql);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FreeSql.Dispose();
|
||||
}
|
||||
|
||||
protected TransactionRecord CreateTestRecord(
|
||||
decimal amount,
|
||||
TransactionType type = TransactionType.Expense,
|
||||
608
WebApi.Test/Transaction/TransactionPeriodicServiceTest.cs
Normal file
608
WebApi.Test/Transaction/TransactionPeriodicServiceTest.cs
Normal file
@@ -0,0 +1,608 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Service.Transaction;
|
||||
|
||||
namespace WebApi.Test.Transaction;
|
||||
|
||||
public class TransactionPeriodicServiceTest : BaseTest
|
||||
{
|
||||
private readonly ITransactionPeriodicRepository _periodicRepository = Substitute.For<ITransactionPeriodicRepository>();
|
||||
private readonly ITransactionRecordRepository _transactionRepository = Substitute.For<ITransactionRecordRepository>();
|
||||
private readonly IMessageRecordRepository _messageRepository = Substitute.For<IMessageRecordRepository>();
|
||||
private readonly ILogger<TransactionPeriodicService> _logger = Substitute.For<ILogger<TransactionPeriodicService>>();
|
||||
private readonly ITransactionPeriodicService _service;
|
||||
|
||||
public TransactionPeriodicServiceTest()
|
||||
{
|
||||
_service = new TransactionPeriodicService(
|
||||
_periodicRepository,
|
||||
_transactionRepository,
|
||||
_messageRepository,
|
||||
_logger
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_每日账单()
|
||||
{
|
||||
// Arrange
|
||||
var today = new DateTime(2024, 1, 15, 10, 0, 0);
|
||||
var periodicBill = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "每日餐费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill });
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(Task.FromResult(true));
|
||||
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
|
||||
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t =>
|
||||
t.Amount == -100m &&
|
||||
t.Type == TransactionType.Expense &&
|
||||
t.Classify == "餐饮" &&
|
||||
t.Reason == "每日餐费" &&
|
||||
t.Card == "周期性账单" &&
|
||||
t.ImportFrom == "周期性账单自动生成"
|
||||
));
|
||||
|
||||
await _messageRepository.Received(1).AddAsync(Arg.Is<MessageRecord>(m =>
|
||||
m.Title == "周期性账单提醒" &&
|
||||
m.Content.Contains("支出") &&
|
||||
m.Content.Contains("100.00") &&
|
||||
m.Content.Contains("每日餐费") &&
|
||||
m.IsRead == false
|
||||
));
|
||||
|
||||
await _periodicRepository.Received(1).UpdateExecuteTimeAsync(
|
||||
Arg.Is(1L),
|
||||
Arg.Is<DateTime>(dt => dt.Date == today.Date),
|
||||
Arg.Is<DateTime?>(dt => dt.HasValue && dt.Value.Date == today.Date.AddDays(1))
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_每周账单()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBill = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Weekly,
|
||||
PeriodicConfig = "1,3,5", // 周一、三、五
|
||||
Amount = 200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "每周通勤费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 10, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill });
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(Task.FromResult(true));
|
||||
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
|
||||
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t =>
|
||||
t.Amount == -200m &&
|
||||
t.Type == TransactionType.Expense &&
|
||||
t.Classify == "交通" &&
|
||||
t.Reason == "每周通勤费"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_每月账单()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBill = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
PeriodicConfig = "1,15", // 每月1号和15号
|
||||
Amount = 5000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "工资",
|
||||
Reason = "每月工资",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 1, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill });
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(Task.FromResult(true));
|
||||
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
|
||||
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t =>
|
||||
t.Amount == 5000m &&
|
||||
t.Type == TransactionType.Income &&
|
||||
t.Classify == "工资" &&
|
||||
t.Reason == "每月工资"
|
||||
));
|
||||
|
||||
await _messageRepository.Received(1).AddAsync(Arg.Is<MessageRecord>(m =>
|
||||
m.Content.Contains("收入") &&
|
||||
m.Content.Contains("5000.00") &&
|
||||
m.Content.Contains("每月工资")
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_未达到执行时间()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBill = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Weekly,
|
||||
PeriodicConfig = "1,3,5", // 只在周一(1)、三(3)、五(5)执行
|
||||
Amount = 200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "每周通勤费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 10, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill });
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(0).AddAsync(Arg.Any<TransactionRecord>());
|
||||
await _messageRepository.Received(0).AddAsync(Arg.Any<MessageRecord>());
|
||||
await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_今天已执行过()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBill = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "每日餐费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 15, 8, 0, 0), // 今天已经执行过
|
||||
NextExecuteTime = new DateTime(2024, 1, 16, 10, 0, 0)
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill });
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
// 由于 LastExecuteTime 日期是今天,所以不会再次执行
|
||||
await _transactionRepository.Received(0).AddAsync(Arg.Any<TransactionRecord>());
|
||||
await _messageRepository.Received(0).AddAsync(Arg.Any<MessageRecord>());
|
||||
await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_从未执行过()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBill = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Weekly,
|
||||
PeriodicConfig = "1,3,5",
|
||||
Amount = 200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "每周通勤费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = null, // 从未执行过
|
||||
NextExecuteTime = null
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill });
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(Task.FromResult(true));
|
||||
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
|
||||
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).AddAsync(Arg.Any<TransactionRecord>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_添加交易记录失败()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBill = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "每日餐费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill });
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(false); // 添加失败
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
await _messageRepository.Received(0).AddAsync(Arg.Any<MessageRecord>());
|
||||
await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_多条账单()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBills = new[]
|
||||
{
|
||||
new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "每日餐费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
},
|
||||
new TransactionPeriodic
|
||||
{
|
||||
Id = 2,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "每日交通",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(periodicBills);
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(Task.FromResult(true));
|
||||
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
|
||||
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(2).AddAsync(Arg.Any<TransactionRecord>());
|
||||
await _messageRepository.Received(2).AddAsync(Arg.Any<MessageRecord>());
|
||||
await _periodicRepository.Received(2).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每日()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
PeriodicConfig = ""
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 15, 10, 0, 0);
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 1, 16, 0, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每周_本周内()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Weekly,
|
||||
PeriodicConfig = "1,3,5" // 周一、三、五
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 15, 10, 0, 0); // 周一
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 1, 17, 0, 0, 0)); // 下周三
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每周_跨周()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Weekly,
|
||||
PeriodicConfig = "1,3,5" // 周一、三、五
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 19, 10, 0, 0); // 周五
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 1, 22, 0, 0, 0)); // 下周一
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每月_本月内()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
PeriodicConfig = "1,15" // 每月1号和15号
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 10, 10, 0, 0);
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每月_跨月()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
PeriodicConfig = "1,15" // 每月1号和15号
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 16, 10, 0, 0);
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 2, 1, 0, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每月_月末()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
PeriodicConfig = "30,31" // 每月30号和31号
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 15, 10, 0, 0); // 1月只有31天
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 1, 30, 0, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每月_小月()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
PeriodicConfig = "30,31" // 每月30号和31号
|
||||
};
|
||||
var baseTime = new DateTime(2024, 4, 25, 10, 0, 0); // 4月只有30天
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 4, 30, 0, 0, 0)); // 30号,31号不存在
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每季度()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Quarterly,
|
||||
PeriodicConfig = "15" // 每季度第15天
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 15, 10, 0, 0);
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2024, 4, 15, 0, 0, 0)); // 下季度
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_每年()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Yearly,
|
||||
PeriodicConfig = "100" // 每年第100天
|
||||
};
|
||||
var baseTime = new DateTime(2024, 4, 10, 10, 0, 0); // 第100天是4月9日
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(new DateTime(2025, 4, 9, 0, 0, 0)); // 下一年
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNextExecuteTime_未知周期类型()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = (PeriodicType)99, // 未知类型
|
||||
PeriodicConfig = ""
|
||||
};
|
||||
var baseTime = new DateTime(2024, 1, 15, 10, 0, 0);
|
||||
|
||||
// Act
|
||||
var result = _service.CalculateNextExecuteTime(periodic, baseTime);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_处理异常不中断()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBills = new[]
|
||||
{
|
||||
new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "每日餐费",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
},
|
||||
new TransactionPeriodic
|
||||
{
|
||||
Id = 2,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "每日交通",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(periodicBills);
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(Task.FromResult(true));
|
||||
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
|
||||
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(args =>
|
||||
{
|
||||
var id = (long)args[0];
|
||||
if (id == 1)
|
||||
{
|
||||
throw new Exception("更新失败");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
// 第二条记录应该成功处理
|
||||
await _transactionRepository.Received(2).AddAsync(Arg.Any<TransactionRecord>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePeriodicBillsAsync_处理所有账单()
|
||||
{
|
||||
// Arrange
|
||||
var periodicBills = new[]
|
||||
{
|
||||
new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "每日餐费",
|
||||
IsEnabled = false, // 禁用
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
},
|
||||
new TransactionPeriodic
|
||||
{
|
||||
Id = 2,
|
||||
PeriodicType = PeriodicType.Daily,
|
||||
Amount = 200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "每日交通",
|
||||
IsEnabled = true,
|
||||
LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0),
|
||||
NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
_periodicRepository.GetPendingPeriodicBillsAsync().Returns(periodicBills);
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(Task.FromResult(true));
|
||||
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
|
||||
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.ExecutePeriodicBillsAsync();
|
||||
|
||||
// Assert
|
||||
// 只有启用的账单会被处理
|
||||
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t => t.Reason == "每日交通"));
|
||||
await _transactionRepository.Received(0).AddAsync(Arg.Is<TransactionRecord>(t => t.Reason == "每日餐费"));
|
||||
}
|
||||
}
|
||||
972
WebApi.Test/Transaction/TransactionStatisticsServiceTest.cs
Normal file
972
WebApi.Test/Transaction/TransactionStatisticsServiceTest.cs
Normal file
@@ -0,0 +1,972 @@
|
||||
using Service.Transaction;
|
||||
|
||||
namespace WebApi.Test.Transaction;
|
||||
|
||||
public class TransactionStatisticsServiceTest : BaseTest
|
||||
{
|
||||
private readonly ITransactionRecordRepository _transactionRepository = Substitute.For<ITransactionRecordRepository>();
|
||||
private readonly ITransactionStatisticsService _service;
|
||||
|
||||
public TransactionStatisticsServiceTest()
|
||||
{
|
||||
// 默认配置 QueryAsync 返回空列表
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(new List<TransactionRecord>());
|
||||
|
||||
_service = new TransactionStatisticsService(
|
||||
_transactionRepository
|
||||
);
|
||||
}
|
||||
|
||||
private void ConfigureQueryAsync(List<TransactionRecord> data)
|
||||
{
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyStatisticsAsync_基本测试()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 1;
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "午餐"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0),
|
||||
Amount = -50m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "地铁"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
OccurredAt = new DateTime(2024, 1, 2, 9, 0, 0),
|
||||
Amount = 5000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "工资",
|
||||
Reason = "工资收入"
|
||||
}
|
||||
};
|
||||
|
||||
ConfigureQueryAsync(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetDailyStatisticsAsync(year, month);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().ContainKey("2024-01-01");
|
||||
result.Should().ContainKey("2024-01-02");
|
||||
|
||||
result["2024-01-01"].count.Should().Be(2);
|
||||
result["2024-01-01"].expense.Should().Be(150m);
|
||||
result["2024-01-01"].income.Should().Be(0m);
|
||||
|
||||
result["2024-01-02"].count.Should().Be(1);
|
||||
result["2024-01-02"].expense.Should().Be(0m);
|
||||
result["2024-01-02"].income.Should().Be(5000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyStatisticsAsync_带储蓄分类()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 1;
|
||||
var savingClassify = "投资,存款";
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "午餐"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0),
|
||||
Amount = -1000m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "投资",
|
||||
Reason = "基金定投"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
OccurredAt = new DateTime(2024, 1, 2, 9, 0, 0),
|
||||
Amount = -500m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "存款",
|
||||
Reason = "银行存款"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetDailyStatisticsAsync(year, month, savingClassify);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
|
||||
result["2024-01-01"].count.Should().Be(2);
|
||||
result["2024-01-01"].expense.Should().Be(1100m);
|
||||
result["2024-01-01"].income.Should().Be(0m);
|
||||
result["2024-01-01"].saving.Should().Be(1000m);
|
||||
|
||||
result["2024-01-02"].count.Should().Be(1);
|
||||
result["2024-01-02"].expense.Should().Be(500m);
|
||||
result["2024-01-02"].income.Should().Be(0m);
|
||||
result["2024-01-02"].saving.Should().Be(500m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyStatisticsByRangeAsync_基本测试()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = new DateTime(2024, 1, 1);
|
||||
var endDate = new DateTime(2024, 1, 5);
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 3, 10, 0, 0),
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "午餐"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetDailyStatisticsByRangeAsync(startDate, endDate);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result.Should().ContainKey("2024-01-03");
|
||||
result["2024-01-03"].count.Should().Be(1);
|
||||
result["2024-01-03"].expense.Should().Be(100m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMonthlyStatisticsAsync_基本测试()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 1;
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "午餐"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0),
|
||||
Amount = -50m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "地铁"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
OccurredAt = new DateTime(2024, 1, 5, 9, 0, 0),
|
||||
Amount = 5000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "工资",
|
||||
Reason = "工资收入"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 4,
|
||||
OccurredAt = new DateTime(2024, 1, 10, 9, 0, 0),
|
||||
Amount = 2000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "奖金",
|
||||
Reason = "奖金收入"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
year,
|
||||
month,
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMonthlyStatisticsAsync(year, month);
|
||||
|
||||
// Assert
|
||||
result.Year.Should().Be(year);
|
||||
result.Month.Should().Be(month);
|
||||
result.TotalExpense.Should().Be(150m);
|
||||
result.TotalIncome.Should().Be(7000m);
|
||||
result.Balance.Should().Be(6850m);
|
||||
result.ExpenseCount.Should().Be(2);
|
||||
result.IncomeCount.Should().Be(2);
|
||||
result.TotalCount.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMonthlyStatisticsAsync_无数据()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 2;
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
year,
|
||||
month,
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.Returns(new List<TransactionRecord>());
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMonthlyStatisticsAsync(year, month);
|
||||
|
||||
// Assert
|
||||
result.Year.Should().Be(year);
|
||||
result.Month.Should().Be(month);
|
||||
result.TotalExpense.Should().Be(0m);
|
||||
result.TotalIncome.Should().Be(0m);
|
||||
result.Balance.Should().Be(0m);
|
||||
result.ExpenseCount.Should().Be(0);
|
||||
result.IncomeCount.Should().Be(0);
|
||||
result.TotalCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCategoryStatisticsAsync_支出分类()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 1;
|
||||
var type = TransactionType.Expense;
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "午餐"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0),
|
||||
Amount = -50m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "晚餐"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
OccurredAt = new DateTime(2024, 1, 3, 9, 0, 0),
|
||||
Amount = -200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通",
|
||||
Reason = "打车"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 4,
|
||||
OccurredAt = new DateTime(2024, 1, 5, 9, 0, 0),
|
||||
Amount = 5000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "工资",
|
||||
Reason = "工资收入"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
year,
|
||||
month,
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
type,
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetCategoryStatisticsAsync(year, month, type);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
|
||||
var dining = result.First(c => c.Classify == "餐饮");
|
||||
dining.Amount.Should().Be(150m);
|
||||
dining.Count.Should().Be(2);
|
||||
dining.Percent.Should().Be(42.9m);
|
||||
|
||||
var transport = result.First(c => c.Classify == "交通");
|
||||
transport.Amount.Should().Be(200m);
|
||||
transport.Count.Should().Be(1);
|
||||
transport.Percent.Should().Be(57.1m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCategoryStatisticsAsync_收入分类()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 1;
|
||||
var type = TransactionType.Income;
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
|
||||
Amount = 5000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "工资",
|
||||
Reason = "工资收入"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0),
|
||||
Amount = 1000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "奖金",
|
||||
Reason = "绩效奖金"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
OccurredAt = new DateTime(2024, 1, 3, 9, 0, 0),
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮",
|
||||
Reason = "午餐"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
year,
|
||||
month,
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
type,
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetCategoryStatisticsAsync(year, month, type);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
|
||||
var salary = result.First(c => c.Classify == "工资");
|
||||
salary.Amount.Should().Be(5000m);
|
||||
salary.Count.Should().Be(1);
|
||||
salary.Percent.Should().Be(83.3m);
|
||||
|
||||
var bonus = result.First(c => c.Classify == "奖金");
|
||||
bonus.Amount.Should().Be(1000m);
|
||||
bonus.Count.Should().Be(1);
|
||||
bonus.Percent.Should().Be(16.7m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTrendStatisticsAsync_多个月份()
|
||||
{
|
||||
// Arrange
|
||||
var startYear = 2024;
|
||||
var startMonth = 1;
|
||||
var monthCount = 3;
|
||||
|
||||
var mockData = new Dictionary<int, List<TransactionRecord>>
|
||||
{
|
||||
[1] = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1), Amount = -1000m, Type = TransactionType.Expense },
|
||||
new() { Id = 2, OccurredAt = new DateTime(2024, 1, 5), Amount = 5000m, Type = TransactionType.Income }
|
||||
},
|
||||
[2] = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 3, OccurredAt = new DateTime(2024, 2, 1), Amount = -1500m, Type = TransactionType.Expense },
|
||||
new() { Id = 4, OccurredAt = new DateTime(2024, 2, 5), Amount = 5000m, Type = TransactionType.Income }
|
||||
},
|
||||
[3] = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 5, OccurredAt = new DateTime(2024, 3, 1), Amount = -2000m, Type = TransactionType.Expense },
|
||||
new() { Id = 6, OccurredAt = new DateTime(2024, 3, 5), Amount = 5000m, Type = TransactionType.Income }
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>())
|
||||
.Returns(args =>
|
||||
{
|
||||
var month = (int)args[1];
|
||||
if (mockData.ContainsKey(month))
|
||||
{
|
||||
return mockData[month];
|
||||
}
|
||||
return new List<TransactionRecord>();
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
|
||||
result[0].Year.Should().Be(2024);
|
||||
result[0].Month.Should().Be(1);
|
||||
result[0].Expense.Should().Be(1000m);
|
||||
result[0].Income.Should().Be(5000m);
|
||||
result[0].Balance.Should().Be(4000m);
|
||||
|
||||
result[1].Year.Should().Be(2024);
|
||||
result[1].Month.Should().Be(2);
|
||||
result[1].Expense.Should().Be(1500m);
|
||||
result[1].Income.Should().Be(5000m);
|
||||
result[1].Balance.Should().Be(3500m);
|
||||
|
||||
result[2].Year.Should().Be(2024);
|
||||
result[2].Month.Should().Be(3);
|
||||
result[2].Expense.Should().Be(2000m);
|
||||
result[2].Income.Should().Be(5000m);
|
||||
result[2].Balance.Should().Be(3000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTrendStatisticsAsync_跨年()
|
||||
{
|
||||
// Arrange
|
||||
var startYear = 2024;
|
||||
var startMonth = 11;
|
||||
var monthCount = 4;
|
||||
|
||||
_transactionRepository.QueryAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>())
|
||||
.Returns(new List<TransactionRecord>());
|
||||
|
||||
// Act
|
||||
var result = await _service.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(4);
|
||||
result[0].Year.Should().Be(2024);
|
||||
result[0].Month.Should().Be(11);
|
||||
result[1].Year.Should().Be(2024);
|
||||
result[1].Month.Should().Be(12);
|
||||
result[2].Year.Should().Be(2025);
|
||||
result[2].Month.Should().Be(1);
|
||||
result[3].Year.Should().Be(2025);
|
||||
result[3].Month.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReasonGroupsAsync_基本测试()
|
||||
{
|
||||
// Arrange
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Reason = "麦当劳",
|
||||
Classify = "",
|
||||
Amount = -50m,
|
||||
Type = TransactionType.Expense,
|
||||
OccurredAt = new DateTime(2024, 1, 1)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
Reason = "麦当劳",
|
||||
Classify = "",
|
||||
Amount = -80m,
|
||||
Type = TransactionType.Expense,
|
||||
OccurredAt = new DateTime(2024, 1, 2)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
Reason = "肯德基",
|
||||
Classify = "",
|
||||
Amount = -60m,
|
||||
Type = TransactionType.Expense,
|
||||
OccurredAt = new DateTime(2024, 1, 3)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 4,
|
||||
Reason = "麦当劳",
|
||||
Classify = "快餐",
|
||||
Amount = -45m,
|
||||
Type = TransactionType.Expense,
|
||||
OccurredAt = new DateTime(2024, 1, 4)
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var (list, total) = await _service.GetReasonGroupsAsync();
|
||||
|
||||
// Assert
|
||||
total.Should().Be(2);
|
||||
list.Should().HaveCount(2);
|
||||
|
||||
var mcdonalds = list.First(g => g.Reason == "麦当劳");
|
||||
mcdonalds.Count.Should().Be(2);
|
||||
mcdonalds.TotalAmount.Should().Be(130m);
|
||||
mcdonalds.SampleType.Should().Be(TransactionType.Expense);
|
||||
mcdonalds.SampleClassify.Should().Be("");
|
||||
mcdonalds.TransactionIds.Should().Contain(1L);
|
||||
mcdonalds.TransactionIds.Should().Contain(2L);
|
||||
|
||||
var kfc = list.First(g => g.Reason == "肯德基");
|
||||
kfc.Count.Should().Be(1);
|
||||
kfc.TotalAmount.Should().Be(60m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassifiedByKeywordsWithScoreAsync_基本匹配()
|
||||
{
|
||||
// Arrange
|
||||
var keywords = new List<string> { "餐饮", "午餐" };
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Reason = "今天午餐吃得很饱",
|
||||
Classify = "餐饮",
|
||||
OccurredAt = new DateTime(2024, 1, 1),
|
||||
Amount = -50m
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
Reason = "餐饮支出",
|
||||
Classify = "餐饮",
|
||||
OccurredAt = new DateTime(2024, 1, 2),
|
||||
Amount = -80m
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
Reason = "交通费",
|
||||
Classify = "交通",
|
||||
OccurredAt = new DateTime(2024, 1, 3),
|
||||
Amount = -10m
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.GetClassifiedByKeywordsAsync(Arg.Any<List<string>>(), Arg.Any<int>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.3, limit: 10);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
|
||||
var first = result[0];
|
||||
// 第一个结果应该是相关性分数最高的,可能是 Id=2("今天午餐吃得很饱"匹配两个关键词)
|
||||
first.record.Id.Should().BeOneOf(1L, 2L);
|
||||
first.relevanceScore.Should().BeGreaterThan(0.5);
|
||||
|
||||
var second = result[1];
|
||||
second.record.Id.Should().BeOneOf(1L, 2L);
|
||||
second.record.Id.Should().NotBe(first.record.Id);
|
||||
second.relevanceScore.Should().BeGreaterThan(0.3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassifiedByKeywordsWithScoreAsync_精确匹配加分()
|
||||
{
|
||||
// Arrange
|
||||
var keywords = new List<string> { "午餐" };
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Reason = "午餐",
|
||||
Classify = "餐饮",
|
||||
OccurredAt = new DateTime(2024, 1, 1),
|
||||
Amount = -50m
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
Reason = "今天中午吃了一顿午餐",
|
||||
Classify = "餐饮",
|
||||
OccurredAt = new DateTime(2024, 1, 2),
|
||||
Amount = -80m
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.GetClassifiedByKeywordsAsync(Arg.Any<List<string>>(), Arg.Any<int>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.3, limit: 10);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
|
||||
// 精确匹配应该得分更高
|
||||
result[0].record.Id.Should().Be(1);
|
||||
result[0].relevanceScore.Should().BeGreaterThan(result[1].relevanceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFilteredTrendStatisticsAsync_按日分组()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = new DateTime(2024, 1, 1);
|
||||
var endDate = new DateTime(2024, 1, 5);
|
||||
var type = TransactionType.Expense;
|
||||
var classifies = new[] { "餐饮", "交通" };
|
||||
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0),
|
||||
Amount = -50m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
OccurredAt = new DateTime(2024, 1, 2, 10, 0, 0),
|
||||
Amount = -80m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
startDate,
|
||||
endDate,
|
||||
type,
|
||||
classifies,
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetFilteredTrendStatisticsAsync(startDate, endDate, type, classifies, groupByMonth: false);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().ContainKey(new DateTime(2024, 1, 1));
|
||||
result.Should().ContainKey(new DateTime(2024, 1, 2));
|
||||
|
||||
result[new DateTime(2024, 1, 1)].Should().Be(150m);
|
||||
result[new DateTime(2024, 1, 2)].Should().Be(80m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFilteredTrendStatisticsAsync_按月分组()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = new DateTime(2024, 1, 1);
|
||||
var endDate = new DateTime(2024, 3, 31);
|
||||
var type = TransactionType.Expense;
|
||||
var classifies = new[] { "餐饮" };
|
||||
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
OccurredAt = new DateTime(2024, 1, 15),
|
||||
Amount = -1000m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
OccurredAt = new DateTime(2024, 2, 15),
|
||||
Amount = -1500m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
OccurredAt = new DateTime(2024, 3, 15),
|
||||
Amount = -2000m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
startDate,
|
||||
endDate,
|
||||
type,
|
||||
classifies,
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetFilteredTrendStatisticsAsync(startDate, endDate, type, classifies, groupByMonth: true);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result.Should().ContainKey(new DateTime(2024, 1, 1));
|
||||
result.Should().ContainKey(new DateTime(2024, 2, 1));
|
||||
result.Should().ContainKey(new DateTime(2024, 3, 1));
|
||||
|
||||
result[new DateTime(2024, 1, 1)].Should().Be(1000m);
|
||||
result[new DateTime(2024, 2, 1)].Should().Be(1500m);
|
||||
result[new DateTime(2024, 3, 1)].Should().Be(2000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAmountGroupByClassifyAsync_基本测试()
|
||||
{
|
||||
// Arrange
|
||||
var startTime = new DateTime(2024, 1, 1);
|
||||
var endTime = new DateTime(2024, 1, 31);
|
||||
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
Amount = -50m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3,
|
||||
Amount = 5000m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "工资"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 4,
|
||||
Amount = -200m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "交通"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
startTime,
|
||||
endTime,
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAmountGroupByClassifyAsync(startTime, endTime);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
|
||||
result[("餐饮", TransactionType.Expense)].Should().Be(-150m);
|
||||
result[("工资", TransactionType.Income)].Should().Be(5000m);
|
||||
result[("交通", TransactionType.Expense)].Should().Be(-200m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAmountGroupByClassifyAsync_相同分类不同类型()
|
||||
{
|
||||
// Arrange
|
||||
var startTime = new DateTime(2024, 1, 1);
|
||||
var endTime = new DateTime(2024, 1, 31);
|
||||
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Amount = -100m,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "兼职"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
Amount = 500m,
|
||||
Type = TransactionType.Income,
|
||||
Classify = "兼职"
|
||||
}
|
||||
};
|
||||
|
||||
_transactionRepository.QueryAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
startTime,
|
||||
endTime,
|
||||
Arg.Any<TransactionType>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<bool>())
|
||||
.ReturnsForAnyArgs(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAmountGroupByClassifyAsync(startTime, endTime);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
|
||||
result[("兼职", TransactionType.Expense)].Should().Be(-100m);
|
||||
result[("兼职", TransactionType.Income)].Should().Be(500m);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public class TransactionRecordController(
|
||||
{
|
||||
try
|
||||
{
|
||||
string[]? classifies = string.IsNullOrWhiteSpace(classify)
|
||||
var classifies = string.IsNullOrWhiteSpace(classify)
|
||||
? null
|
||||
: classify.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
@@ -280,7 +280,7 @@ var list = await transactionRepository.QueryAsync(
|
||||
|
||||
foreach (var item in sortedStats)
|
||||
{
|
||||
decimal dailyBalance = item.Value.income - item.Value.expense;
|
||||
var dailyBalance = item.Value.income - item.Value.expense;
|
||||
cumulativeBalance += dailyBalance;
|
||||
|
||||
result.Add(new BalanceStatisticsDto(
|
||||
|
||||
Reference in New Issue
Block a user