diff --git a/.gitignore b/.gitignore index 6ddf0c0..42c6246 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ server/billai-server server/uploads/ server/outputs/ *.log + +mongodata/ \ No newline at end of file diff --git a/analyzer/cleaners/base.py b/analyzer/cleaners/base.py index 8a2e1c7..b930b1b 100644 --- a/analyzer/cleaners/base.py +++ b/analyzer/cleaners/base.py @@ -25,9 +25,10 @@ def parse_date(date_str: str) -> date: def parse_amount(amount_str: str) -> Decimal: - """解析金额字符串为Decimal(去掉¥符号)""" + """解析金额字符串为Decimal(去掉¥/¥符号)""" try: - clean = amount_str.replace("¥", "").replace(" ", "").strip() + # 同时处理全角¥和半角¥ + clean = amount_str.replace("¥", "").replace("¥", "").replace(" ", "").strip() return Decimal(clean) except: return Decimal("0") diff --git a/analyzer/config/category.yaml b/analyzer/config/category.yaml index cd93abd..292f872 100644 --- a/analyzer/config/category.yaml +++ b/analyzer/config/category.yaml @@ -218,6 +218,7 @@ - 煲仔饭 - 蛙来哒 # 牛蛙餐厅 - 粒上皇 # 炒货零食店 + - 盒马 # 转账红包 转账红包: diff --git a/analyzer/server.py b/analyzer/server.py index 799c284..19cca9d 100644 --- a/analyzer/server.py +++ b/analyzer/server.py @@ -25,6 +25,9 @@ from cleaners.base import compute_date_range_from_values from cleaners import AlipayCleaner, WechatCleaner from category import infer_category, get_all_categories, get_all_income_categories +# 应用版本 +APP_VERSION = "0.0.1" + # ============================================================================= # Pydantic 模型 @@ -183,7 +186,7 @@ app = FastAPI( @app.get("/health", response_model=HealthResponse) async def health_check(): """健康检查""" - return HealthResponse(status="ok", version="1.0.0") + return HealthResponse(status="ok", version=APP_VERSION) @app.post("/clean", response_model=CleanResponse) diff --git a/docker-compose.yaml b/docker-compose.yaml index aed7a02..ec5dccc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -88,8 +88,8 @@ services: MONGO_INITDB_ROOT_USERNAME: admin MONGO_INITDB_ROOT_PASSWORD: password volumes: - - ./mongo/data/db:/data/db - - ./mongo/data/configdb:/data/configdb + - ./mongodata/db:/data/db + - ./mongodata/configdb:/data/configdb healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s diff --git a/data/微信支付账单流水文件(20251207-20260107)——【解压密码可在微信支付公众号查看】.csv b/mock_data/微信支付账单流水文件(20251207-20260107)——【解压密码可在微信支付公众号查看】.csv similarity index 100% rename from data/微信支付账单流水文件(20251207-20260107)——【解压密码可在微信支付公众号查看】.csv rename to mock_data/微信支付账单流水文件(20251207-20260107)——【解压密码可在微信支付公众号查看】.csv diff --git a/data/支付宝交易明细(20251207-20260107).csv b/mock_data/支付宝交易明细(20251207-20260107).csv similarity index 100% rename from data/支付宝交易明细(20251207-20260107).csv rename to mock_data/支付宝交易明细(20251207-20260107).csv diff --git a/server/config.yaml b/server/config.yaml index ad9a80e..d0c513a 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -1,5 +1,8 @@ # BillAI 服务器配置文件 +# 应用版本 +version: "0.0.1" + # 服务配置 server: port: 8080 diff --git a/server/config/config.go b/server/config/config.go index c256eeb..c0e28ea 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -11,6 +11,7 @@ import ( // Config 服务配置 type Config struct { + Version string // 应用版本 Port string // 服务端口 ProjectRoot string // 项目根目录 PythonPath string // Python 解释器路径 @@ -31,6 +32,7 @@ type Config struct { // configFile YAML 配置文件结构 type configFile struct { + Version string `yaml:"version"` Server struct { Port int `yaml:"port"` } `yaml:"server"` @@ -117,6 +119,7 @@ func Load() { flag.Parse() // 设置默认值 + Global.Version = "0.0.1" Global.Port = getEnvOrDefault("PORT", "8080") Global.ProjectRoot = getDefaultProjectRoot() Global.PythonPath = getDefaultPythonPath() @@ -145,6 +148,9 @@ func Load() { // 加载配置文件 if cfg := loadConfigFile(configPath); cfg != nil { fmt.Printf("📄 加载配置文件: %s\n", configPath) + if cfg.Version != "" { + Global.Version = cfg.Version + } if cfg.Server.Port > 0 { Global.Port = fmt.Sprintf("%d", cfg.Server.Port) } diff --git a/server/handler/bills.go b/server/handler/bills.go index 1857a0d..b873d56 100644 --- a/server/handler/bills.go +++ b/server/handler/bills.go @@ -165,3 +165,36 @@ func parsePageParam(s string, defaultVal int) int { } return val } + +// MonthlyStatsResponse 月度统计响应 +type MonthlyStatsResponse struct { + Result bool `json:"result"` + Message string `json:"message,omitempty"` + Data []model.MonthlyStat `json:"data,omitempty"` +} + +// MonthlyStats 获取月度统计数据(全部数据,不受筛选条件影响) +func MonthlyStats(c *gin.Context) { + repo := repository.GetRepository() + if repo == nil { + c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{ + Result: false, + Message: "数据库未连接", + }) + return + } + + stats, err := repo.GetMonthlyStats() + if err != nil { + c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{ + Result: false, + Message: "查询失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, MonthlyStatsResponse{ + Result: true, + Data: stats, + }) +} diff --git a/server/main.go b/server/main.go index 0160452..a040911 100644 --- a/server/main.go +++ b/server/main.go @@ -65,8 +65,8 @@ func main() { // 注册路由 router.Setup(r, router.Config{ - OutputDir: outputDirAbs, - PythonPath: pythonPathAbs, + OutputDir: outputDirAbs, + Version: config.Global.Version, }) // 监听系统信号 diff --git a/server/model/bill.go b/server/model/bill.go index e8fa26f..3502c3b 100644 --- a/server/model/bill.go +++ b/server/model/bill.go @@ -38,3 +38,10 @@ type CleanedBill struct { SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名 UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳) } + +// MonthlyStat 月度统计数据 +type MonthlyStat struct { + Month string `bson:"month" json:"month"` // 月份 YYYY-MM + Expense float64 `bson:"expense" json:"expense"` // 月支出总额 + Income float64 `bson:"income" json:"income"` // 月收入总额 +} diff --git a/server/repository/mongo/repository.go b/server/repository/mongo/repository.go index 0ec82da..a5a4983 100644 --- a/server/repository/mongo/repository.go +++ b/server/repository/mongo/repository.go @@ -303,6 +303,86 @@ func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) { return r.GetCleanedBills(filter) } +// GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响) +func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 使用聚合管道按月份分组统计 + // 先按月份和收支类型分组,再汇总 + pipeline := mongo.Pipeline{ + // 添加月份字段 + {{Key: "$addFields", Value: bson.D{ + {Key: "month", Value: bson.D{ + {Key: "$dateToString", Value: bson.D{ + {Key: "format", Value: "%Y-%m"}, + {Key: "date", Value: "$time"}, + }}, + }}, + }}}, + // 按月份和收支类型分组 + {{Key: "$group", Value: bson.D{ + {Key: "_id", Value: bson.D{ + {Key: "month", Value: "$month"}, + {Key: "income_expense", Value: "$income_expense"}, + }}, + {Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}}, + }}}, + // 按月份重新分组,汇总收入和支出 + {{Key: "$group", Value: bson.D{ + {Key: "_id", Value: "$_id.month"}, + {Key: "expense", Value: bson.D{ + {Key: "$sum", Value: bson.D{ + {Key: "$cond", Value: bson.A{ + bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "支出"}}}, + "$total", + 0, + }}, + }}, + }}, + {Key: "income", Value: bson.D{ + {Key: "$sum", Value: bson.D{ + {Key: "$cond", Value: bson.A{ + bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "收入"}}}, + "$total", + 0, + }}, + }}, + }}, + }}}, + // 按月份排序 + {{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, + } + + cursor, err := r.cleanedCollection.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("月度统计聚合失败: %w", err) + } + defer cursor.Close(ctx) + + // 解析结果 + var results []struct { + Month string `bson:"_id"` + Expense float64 `bson:"expense"` + Income float64 `bson:"income"` + } + if err := cursor.All(ctx, &results); err != nil { + return nil, fmt.Errorf("解析月度统计结果失败: %w", err) + } + + // 转换为 MonthlyStat + stats := make([]model.MonthlyStat, len(results)) + for i, r := range results { + stats[i] = model.MonthlyStat{ + Month: r.Month, + Expense: r.Expense, + Income: r.Income, + } + } + + return stats, nil +} + // GetClient 获取 MongoDB 客户端(用于兼容旧代码) func (r *Repository) GetClient() *mongo.Client { return r.client diff --git a/server/repository/repository.go b/server/repository/repository.go index 429ccef..60e6d6c 100644 --- a/server/repository/repository.go +++ b/server/repository/repository.go @@ -36,6 +36,10 @@ type BillRepository interface { // 返回: 总支出、总收入、错误 GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error) + // GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响) + // 返回: 月度统计列表、错误 + GetMonthlyStats() ([]model.MonthlyStat, error) + // GetBillsNeedReview 获取需要复核的账单 GetBillsNeedReview() ([]model.CleanedBill, error) diff --git a/server/router/router.go b/server/router/router.go index a3fdd33..3229f6b 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -11,14 +11,14 @@ import ( // Config 路由配置参数 type Config struct { - OutputDir string // 输出目录(用于静态文件服务) - PythonPath string // Python 路径(用于健康检查显示) + OutputDir string // 输出目录(用于静态文件服务) + Version string // 应用版本 } // Setup 设置所有路由 func Setup(r *gin.Engine, cfg Config) { // 健康检查 - r.GET("/health", healthCheck(cfg.PythonPath)) + r.GET("/health", healthCheck(cfg.Version)) // API 路由组 setupAPIRoutes(r) @@ -28,11 +28,11 @@ func Setup(r *gin.Engine, cfg Config) { } // healthCheck 健康检查处理器 -func healthCheck(pythonPath string) gin.HandlerFunc { +func healthCheck(version string) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "python_path": pythonPath, + "status": "ok", + "version": version, }) } } @@ -49,5 +49,8 @@ func setupAPIRoutes(r *gin.Engine) { // 账单查询 api.GET("/bills", handler.ListBills) + + // 月度统计(全部数据) + api.GET("/monthly-stats", handler.MonthlyStats) } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d22f371..0865eef 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -56,6 +56,19 @@ export interface ReviewResponse { data?: ReviewData; } +// 月度统计数据 +export interface MonthlyStat { + month: string; // YYYY-MM + expense: number; + income: number; +} + +export interface MonthlyStatsResponse { + result: boolean; + message?: string; + data?: MonthlyStat[]; +} + export interface BillRecord { time: string; category: string; @@ -109,6 +122,17 @@ export async function getReviewRecords(fileName: string): Promise { + const response = await fetch(`${API_BASE}/api/monthly-stats`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return response.json(); +} + // 下载文件 URL export function getDownloadUrl(fileUrl: string): string { return `${API_BASE}${fileUrl}`; diff --git a/web/src/lib/components/ui/month-range-picker/index.ts b/web/src/lib/components/ui/month-range-picker/index.ts new file mode 100644 index 0000000..fd06ba1 --- /dev/null +++ b/web/src/lib/components/ui/month-range-picker/index.ts @@ -0,0 +1,3 @@ +import MonthRangePicker from './month-range-picker.svelte'; + +export { MonthRangePicker }; diff --git a/web/src/lib/components/ui/month-range-picker/month-range-picker.svelte b/web/src/lib/components/ui/month-range-picker/month-range-picker.svelte new file mode 100644 index 0000000..44e15c2 --- /dev/null +++ b/web/src/lib/components/ui/month-range-picker/month-range-picker.svelte @@ -0,0 +1,293 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + +
+ +
+

开始日期

+
+ + + {internalStartYear || 'YYYY'} + + + {#each years as year} + {year} + {/each} + + + + + {internalStartMonth ? String(internalStartMonth).padStart(2, '0') : 'MM'} + + + {#each months as month} + {String(month).padStart(2, '0')}月 + {/each} + + + + + {internalStartDay ? String(internalStartDay).padStart(2, '0') : 'DD'} + + + {#each startDays as day} + {String(day).padStart(2, '0')}日 + {/each} + + +
+
+ + +
+

结束日期

+
+ + + {internalEndYear || 'YYYY'} + + + {#each years as year} + {year} + {/each} + + + + + {internalEndMonth ? String(internalEndMonth).padStart(2, '0') : 'MM'} + + + {#each months as month} + {String(month).padStart(2, '0')}月 + {/each} + + + + + {internalEndDay ? String(internalEndDay).padStart(2, '0') : 'DD'} + + + {#each endDays as day} + {String(day).padStart(2, '0')}日 + {/each} + + +
+
+
+
+
diff --git a/web/src/routes/analysis/+page.svelte b/web/src/routes/analysis/+page.svelte index 3a0727b..1360d60 100644 --- a/web/src/routes/analysis/+page.svelte +++ b/web/src/routes/analysis/+page.svelte @@ -1,11 +1,10 @@