diff --git a/AGENTS.md b/AGENTS.md index 461e9ed..e8f91b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,85 +2,81 @@ Guidelines for AI coding agents working on BillAI - a microservices bill analysis system. -**Current Version:** 1.4.0 | Go: 1.24.0 | Node: 20+ | Python: 3.12+ +**Version:** See `CHANGELOG.md` for current version. Latest tag usually matches. ## Architecture + - `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000) - `server/` - Go 1.24 + Gin + MongoDB (API, port 8080) - `analyzer/` - Python 3.12 + FastAPI (Data cleaning, port 8001) -SvelteKit proxies `/api/*` requests to Go backend via `web/src/routes/api/[...path]/+server.ts`. +**Proxy Caveat:** SvelteKit proxies `/api/*` to Go via `web/src/routes/api/[...path]/+server.ts`, but **only GET and POST are forwarded**. The Go backend intentionally uses `POST` for mutations (update, delete, manual create) to work around this. If you add PUT/PATCH/DELETE endpoints, you must also add them to the proxy. ## Build/Lint/Test Commands -### Frontend (web/) +### Frontend (`web/`) ```bash npm run dev # Start dev server npm run build # Production build -npm run check # TypeScript check -npm run lint # Prettier + ESLint -npm run format # Format with Prettier -npm run test # Run all tests (CI mode) +npm run check # TypeScript + Svelte check +npm run lint # Prettier --check + ESLint +npm run format # Prettier --write +npm run test # vitest --run (CI mode) npx vitest run src/xxx.spec.ts # Run single test file -npx vitest run -t "pattern" # Run by name pattern ``` -### Backend (server/) +**Note:** `web/` has a `yarn.lock` but scripts in `package.json` use `npm run`. + +### Backend (`server/`) ```bash -go run . # Start server -go build -o server . # Build binary +go run . # Start server (reads server/config.yaml) +go build -o server . go test ./... # Run all tests -go test ./handler/... # Run handler tests go test -run TestName ./... # Run single test go test -v ./handler/... # Verbose output ``` -### Analyzer (analyzer/) +### Analyzer (`analyzer/`) ```bash -python server.py # Start FastAPI -uvicorn server:app --reload # Hot reload +python server.py # Start FastAPI (has `if __name__ == "__main__"`) +uvicorn server:app --reload # Requires cwd == analyzer/ pytest # Run all tests pytest test_jd_cleaner.py # Single test file -pytest -k "pattern" # Run by pattern ``` ### Docker ```bash -docker-compose up -d --build # Start/rebuild all services -docker-compose logs -f server # Follow logs -docker-compose down # Stop services +docker compose up -d --build --remove-orphans # Start/rebuild all +docker compose logs -f server # Follow logs +docker compose down # Stop services ``` -## Code Style +## Code Style & Conventions -### General -- **Comments:** Chinese common for business logic; English for technical. -- **Conventions:** Follow existing patterns. Check `package.json`/`go.mod`/`requirements.txt` before adding dependencies. +### TypeScript/Svelte (`web/`) +- **Formatting:** Prettier (tabs, single quotes, `trailingComma: none`, printWidth 100) +- **Imports:** Use `$lib` alias. No relative imports for lib modules. +- **Svelte 5:** Runes (`$state`, `$derived`, `$effect`, `$props`). Events: `onclick={fn}`. +- **Types:** `export interface` for models. Frontend uses `camelCase`, API uses `snake_case`. Converters live in `$lib/models/bill.ts`. +- **Auth:** Token stored in `localStorage` key `auth`. Always use `apiFetch()` from `$lib/api.ts` for authenticated requests. -### TypeScript/Svelte (web/) -- **Formatting:** Prettier (tabs, single quotes, printWidth 100) -- **Naming:** `PascalCase` for types/components, `camelCase` for variables -- **Imports:** Use `$lib` alias, `$app/*` for SvelteKit builtins. No relative paths for lib modules. -- **Svelte 5:** Use runes (`$state`, `$derived`, `$effect`, `$props`). Event: `onclick={fn}`. -- **Types:** `export interface` for models. Frontend `camelCase`, API `snake_case`. Converters in `$lib/models/bill.ts`. -- **Error Handling:** Check `response.ok`, throw `Error(\`HTTP ${status}\`)`. On 401: `auth.logout()` + redirect. -- **Auth:** `createAuthStore()` in `$lib/stores/auth.ts`. Token in `localStorage` key `auth`. Use `apiFetch()` in `$lib/api.ts`. - -### Go Backend (server/) +### Go (`server/`) +- **Module:** `billai-server` (import path). Use this in `go test` / `go build` when outside the directory. - **Layer:** `handler` → `service` → `adapter`/`repository` → `model`. No business logic in handlers. -- **Struct tags:** JSON `snake_case`, `omitempty` optional. Pointer for optional patch fields. Sensitive: `json:"-"`. -- **Error handling:** 500 for DB errors, 400 for bad requests, 404 not found. Wrap with `fmt.Errorf("context: %w", err)`. -- **Response:** `Result bool`, `Message`, `Data *T`. Auth: `success bool`, `error`, `data`. -- **Time:** Use custom `LocalTime` type (serializes as `2006-01-02 15:04:05`). -- **Soft delete:** Never hard-delete. Filter `is_deleted: false` in queries. +- **Struct tags:** JSON `snake_case`, `omitempty` for optional. Pointer types for optional patch fields. Sensitive fields: `json:"-"`. +- **Response shapes:** + - Business APIs: `result bool`, `message string`, `data *T` + - Auth APIs: `success bool`, `error string`, `data *T` (and `code` for error types) +- **Time:** Custom `LocalTime` type serializes as `"2006-01-02 15:04:05"`. +- **Soft delete:** Never hard-delete. All queries filter `is_deleted: false`. -### Python Analyzer (analyzer/) -- **Style:** PEP 8. `snake_case` variables, `UPPER_CASE` constants. Prefix private globals with `_`. -- **Type hints:** Mandatory. Use `Optional[str]` or `str | None`. +### Python (`analyzer/`) +- **Style:** PEP 8. `snake_case` vars, `UPPER_CASE` constants. Prefix private globals with `_`. +- **Type hints:** Mandatory. Prefer `str | None` or `Optional[str]`. - **Models:** `pydantic.BaseModel` for API schemas. - **Cleaners:** Extend `BaseCleaner(ABC)` from `cleaners/base.py`. Category rules in `config/category.yaml`. -## Key Patterns +## Key Patterns & Quirks ### API Flow ``` @@ -89,35 +85,63 @@ Browser → SvelteKit proxy → Go (Gin) → handler → service → adapter → ``` ### Authentication -- JWT (HS256). Token in `localStorage` key `auth`. Header: `Authorization: Bearer `. -- `middleware.AuthRequired()` wraps `/api/*` (except `/api/auth/*`). -- 401 anywhere → `auth.logout()` + redirect `/login`. +- JWT (HS256). Header: `Authorization: Bearer `. +- `middleware.AuthRequired()` guards authed routes. Public routes: `/api/auth/*`, `/api/changelog`, `/health`. +- Frontend `apiFetch()` intercepts 401 → `auth.logout()` + redirect `/login`. -### File Processing -Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type → Deduplicate → Clean → Save to MongoDB. +### File Processing Pipeline +Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type (alipay/wechat/jd) → Deduplicate against MongoDB → Clean via Python → Save cleaned data. ### Adapter (Go ↔ Python) -`adapter.Cleaner` interface: HTTP (`adapter/http`, default) or subprocess (`adapter/python`). Set via `ANALYZER_MODE` env var. +`adapter.Cleaner` interface. Two modes: +- `http` (default): calls FastAPI at `ANALYZER_URL` +- `subprocess`: spawns `python analyzer/clean_bill.py` + +Set via `ANALYZER_MODE` env var or `server/config.yaml` `analyzer.mode`. + +### Config Precedence +Go backend reads `server/config.yaml`, but Docker compose sets env vars (`ANALYZER_URL`, `MONGO_URI`, `JWT_SECRET`, etc.) that override it. + +### SvelteKit Config Notes +- `svelte.config.js` uses `adapter-node` for Docker SSR. +- `csrf.trustedOrigins: ['*']` disables CSRF checks. +- `onwarn` ignores all `a11y_*` warnings (chart components). + +### Deployment +- Gitea Actions self-hosted runner (`.gitea/workflows/deploy.yaml`), not GitHub. +- `deploy.sh` is the manual deployment script (same logic as CI). + +### Test Coverage +Sparse. Existing tests: +- `web/src/demo.spec.ts` / `page.svelte.spec.ts` +- `server/service/changelog_test.go` +- `analyzer/test_jd_cleaner.py` ## Important Files + | File | Role | |---|---| -| `web/src/lib/api.ts` | Central API client, auth injection | -| `web/src/lib/stores/auth.ts` | Auth state, JWT handling | -| `web/src/lib/models/bill.ts` | UIBill model + converters | -| `server/main.go` | Entry point | -| `server/handler/upload.go` | Full upload pipeline | -| `server/handler/bills.go` | List/filter bills | -| `server/model/bill.go` | Bill models, LocalTime type | -| `server/adapter/adapter.go` | Cleaner interface | -| `server/repository/mongo/repository.go` | MongoDB implementation | -| `analyzer/server.py` | FastAPI entry | +| `web/src/lib/api.ts` | Central API client, auth injection, all API functions | +| `web/src/lib/stores/auth.ts` | Auth state, JWT handling, localStorage key `auth` | +| `web/src/lib/models/bill.ts` | UIBill model + snake_case ↔ camelCase converters | +| `web/src/routes/api/[...path]/+server.ts` | SvelteKit → Go proxy (GET/POST only) | +| `server/main.go` | Entry point, wires adapter + repository + router | +| `server/config.yaml` | Go backend config (Mongo, auth, paths, analyzer mode) | +| `server/router/router.go` | Route table, auth group definitions | +| `server/handler/upload.go` | Full upload pipeline handler | +| `server/handler/bills.go` | List/filter/update/delete bills | +| `server/model/bill.go` | Bill models, LocalTime type, BSON/JSON marshaling | +| `server/adapter/adapter.go` | Cleaner interface definition | +| `server/repository/mongo/repository.go` | MongoDB implementation, soft-delete queries | +| `analyzer/server.py` | FastAPI entry, bill detection/clean endpoints | | `analyzer/cleaners/base.py` | BaseCleaner ABC | -| `analyzer/category.py` | Category inference | +| `analyzer/category.py` | Category inference engine | +| `docker-compose.yaml` | Full stack orchestration | ## Agent Guidelines -- **Before coding:** Search codebase to understand existing patterns and dependencies -- **Dependencies:** Check `package.json`/`go.mod`/`requirements.txt` before adding new packages -- **Tests:** Always run relevant test suite before committing changes -- **Git commits:** Provide clear messages explaining the "why" of changes -- **File references:** Use relative `file_path:line_number` format (e.g., `server/handler/changelog.go:12`) when mentioning code locations + +- **Before coding:** Search codebase to understand existing patterns and dependencies. +- **Dependencies:** Check `package.json`/`go.mod`/`requirements.txt` before adding new packages. +- **Tests:** Run the relevant test suite before committing. If no tests exist for your change, verify manually. +- **Git commits:** Provide clear messages explaining the "why" of changes. +- **File references:** Use relative `file_path:line_number` format (e.g., `server/handler/changelog.go:12`) when mentioning code locations. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..078c29c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@AGENTS.md diff --git a/analyzer/cleaners/alipay.py b/analyzer/cleaners/alipay.py index fab7eef..2ebc60e 100644 --- a/analyzer/cleaners/alipay.py +++ b/analyzer/cleaners/alipay.py @@ -100,45 +100,54 @@ class AlipayCleaner(BaseCleaner): def _aggregate_refunds(self, refund_rows: list) -> dict: """聚合退款金额""" order_refunds = {} - + for row in refund_rows: if len(row) >= 11: refund_order_no = row[9].strip() refund_merchant_no = row[10].strip() refund_amount = parse_amount(row[6]) - + original_order = refund_order_no.split("_")[0] if "_" in refund_order_no else refund_order_no key = original_order if original_order else refund_merchant_no - + if key: if key not in order_refunds: - order_refunds[key] = Decimal("0") - order_refunds[key] += refund_amount + order_refunds[key] = { + "amount": Decimal("0"), + "merchant_order_no": refund_merchant_no, + "refund_order_no": refund_order_no, + "time": row[0], + "merchant": row[2], + "description": row[4] if len(row) > 4 else "", + } + order_refunds[key]["amount"] += refund_amount print(f" 退款记录: {row[0]} | {row[2]} | {refund_amount}元") - + return order_refunds def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list: """处理支出记录""" final_rows = [] - + matched_keys = set() + for row in expense_rows: if len(row) >= 12: order_no = row[9].strip() merchant_no = row[10].strip() expense_amount = parse_amount(row[6]) - + # 查找对应的退款 refund_amount = Decimal("0") matched_key = None - - for key, amount in order_refunds.items(): + + for key, refund in order_refunds.items(): if key and (order_no == key or merchant_no == key or order_no.startswith(key)): - refund_amount = amount + refund_amount = refund["amount"] matched_key = key break - + if matched_key: + matched_keys.add(matched_key) if refund_amount >= expense_amount: # 全额退款,删除 self.stats["fully_refunded"] += 1 @@ -148,10 +157,10 @@ class AlipayCleaner(BaseCleaner): remaining = expense_amount - refund_amount new_row = row.copy() new_row[6] = format_amount(remaining) - + original_remark = new_row[11] if len(new_row) > 11 else "" new_row[11] = f"原金额{expense_amount}元,退款{refund_amount}元{';' + original_remark if original_remark else ''}" - + final_rows.append(new_row) self.stats["partially_refunded"] += 1 print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}元") @@ -163,7 +172,22 @@ class AlipayCleaner(BaseCleaner): self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1 else: final_rows.append(row) - + + # 本批次内未匹配到对应支出的退款,交由调用方做跨批次核销 + self.unresolved_refunds = [ + { + "order_no": key, + "merchant_order_no": refund["merchant_order_no"], + "refund_order_no": refund["refund_order_no"], + "amount": float(format_amount(refund["amount"])), + "time": refund["time"], + "merchant": refund["merchant"], + "description": refund["description"], + } + for key, refund in order_refunds.items() + if key not in matched_keys + ] + return final_rows def _is_platform_merchant(self, merchant: str) -> bool: diff --git a/analyzer/cleaners/base.py b/analyzer/cleaners/base.py index b930b1b..5854665 100644 --- a/analyzer/cleaners/base.py +++ b/analyzer/cleaners/base.py @@ -220,6 +220,9 @@ class BaseCleaner(ABC): "category_adjusted": 0, "final_count": 0, } + + # 本次清理中未能在同批次内匹配到对应支出的退款(跨批次核销用) + self.unresolved_refunds: list[dict] = [] def set_date_range(self, start_date: date | None, end_date: date | None): """设置日期筛选范围""" diff --git a/analyzer/server.py b/analyzer/server.py index 043e79b..76a0e0a 100644 --- a/analyzer/server.py +++ b/analyzer/server.py @@ -52,6 +52,7 @@ class CleanResponse(BaseModel): bill_type: str message: str output_path: Optional[str] = None + unresolved_refunds: list[dict] = [] class CategoryRequest(BaseModel): @@ -138,27 +139,27 @@ def do_clean( start: str = None, end: str = None, output_format: str = "csv" -) -> tuple[bool, str, str]: +) -> tuple[bool, str, str, list[dict]]: """ 执行清洗逻辑 - + Returns: - (success, bill_type, message) + (success, bill_type, message, unresolved_refunds) """ # 检查文件是否存在 if not Path(input_path).exists(): - return False, "", f"文件不存在: {input_path}" - + return False, "", f"文件不存在: {input_path}", [] + # 检测账单类型 if bill_type == "auto": detected_type = detect_bill_type(input_path) if detected_type is None: - return False, "", "无法识别账单类型" + return False, "", "无法识别账单类型", [] bill_type = detected_type - + # 计算日期范围 start_date, end_date = compute_date_range_from_values(year, month, start, end) - + # 创建对应的清理器 try: if bill_type == "alipay": @@ -167,15 +168,15 @@ def do_clean( cleaner = JDCleaner(input_path, output_path, output_format) else: cleaner = WechatCleaner(input_path, output_path, output_format) - + cleaner.set_date_range(start_date, end_date) cleaner.clean() - + type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"} - return True, bill_type, f"✅ {type_names.get(bill_type, bill_type)}账单清洗完成" - + return True, bill_type, f"✅ {type_names.get(bill_type, bill_type)}账单清洗完成", cleaner.unresolved_refunds + except Exception as e: - return False, bill_type, f"清洗失败: {str(e)}" + return False, bill_type, f"清洗失败: {str(e)}", [] # ============================================================================= @@ -215,7 +216,7 @@ async def clean_bill(request: CleanRequest): 接收账单文件路径,执行清洗后输出到指定路径 """ - success, bill_type, message = do_clean( + success, bill_type, message, unresolved_refunds = do_clean( input_path=request.input_path, output_path=request.output_path, bill_type=request.bill_type or "auto", @@ -225,15 +226,16 @@ async def clean_bill(request: CleanRequest): end=request.end, output_format=request.format or "csv" ) - + if not success: raise HTTPException(status_code=400, detail=message) - + return CleanResponse( success=True, bill_type=bill_type, message=message, - output_path=request.output_path + output_path=request.output_path, + unresolved_refunds=unresolved_refunds ) @@ -264,7 +266,7 @@ async def clean_bill_upload( output_path = tmp_output.name try: - success, detected_type, message = do_clean( + success, detected_type, message, unresolved_refunds = do_clean( input_path=input_path, output_path=output_path, bill_type=bill_type or "auto", @@ -274,15 +276,16 @@ async def clean_bill_upload( end=end, output_format=format or "csv" ) - + if not success: raise HTTPException(status_code=400, detail=message) - + return CleanResponse( success=True, bill_type=detected_type, message=message, - output_path=output_path + output_path=output_path, + unresolved_refunds=unresolved_refunds ) finally: diff --git a/server/adapter/adapter.go b/server/adapter/adapter.go index a71fc78..d5ccbc6 100644 --- a/server/adapter/adapter.go +++ b/server/adapter/adapter.go @@ -13,8 +13,20 @@ type CleanOptions struct { // CleanResult 清洗结果 type CleanResult struct { - BillType string // 检测到的账单类型: alipay/wechat/jd - Output string // 脚本输出信息 + BillType string // 检测到的账单类型: alipay/wechat/jd + Output string // 脚本输出信息 + UnresolvedRefunds []UnresolvedRefund // 本次清洗未在同批次内匹配到对应支出的退款 +} + +// UnresolvedRefund 本次清洗未在同批次内匹配到对应支出的退款 +type UnresolvedRefund struct { + OrderNo string // 原订单号(去除退款后缀) + MerchantOrderNo string // 商家订单号(备用匹配字段) + RefundOrderNo string // 退款行自身的完整订单号(用于备注追溯) + Amount float64 // 退款金额 + Time string // 退款时间 + Merchant string // 交易对方 + Description string // 商品说明 } // ConvertResult 格式转换结果 diff --git a/server/adapter/http/cleaner.go b/server/adapter/http/cleaner.go index dc96562..cc3918b 100644 --- a/server/adapter/http/cleaner.go +++ b/server/adapter/http/cleaner.go @@ -29,10 +29,22 @@ type CleanRequest struct { // CleanResponse HTTP 清洗响应 type CleanResponse struct { - Success bool `json:"success"` - BillType string `json:"bill_type"` - Message string `json:"message"` - OutputPath string `json:"output_path,omitempty"` + Success bool `json:"success"` + BillType string `json:"bill_type"` + Message string `json:"message"` + OutputPath string `json:"output_path,omitempty"` + UnresolvedRefunds []UnresolvedRefund `json:"unresolved_refunds,omitempty"` +} + +// UnresolvedRefund 本次清洗未在同批次内匹配到对应支出的退款(与 Python 端 dict 字段对应) +type UnresolvedRefund struct { + OrderNo string `json:"order_no"` + MerchantOrderNo string `json:"merchant_order_no"` + RefundOrderNo string `json:"refund_order_no"` + Amount float64 `json:"amount"` + Time string `json:"time"` + Merchant string `json:"merchant"` + Description string `json:"description"` } // ErrorResponse 错误响应 @@ -149,9 +161,23 @@ func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions } } + unresolvedRefunds := make([]adapter.UnresolvedRefund, 0, len(cleanResp.UnresolvedRefunds)) + for _, ur := range cleanResp.UnresolvedRefunds { + unresolvedRefunds = append(unresolvedRefunds, adapter.UnresolvedRefund{ + OrderNo: ur.OrderNo, + MerchantOrderNo: ur.MerchantOrderNo, + RefundOrderNo: ur.RefundOrderNo, + Amount: ur.Amount, + Time: ur.Time, + Merchant: ur.Merchant, + Description: ur.Description, + }) + } + return &adapter.CleanResult{ - BillType: cleanResp.BillType, - Output: cleanResp.Message, + BillType: cleanResp.BillType, + Output: cleanResp.Message, + UnresolvedRefunds: unresolvedRefunds, }, nil } diff --git a/server/handler/upload.go b/server/handler/upload.go index 83d0c46..a640ac1 100644 --- a/server/handler/upload.go +++ b/server/handler/upload.go @@ -223,7 +223,7 @@ func Upload(c *gin.Context) { End: req.End, Format: req.Format, } - _, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts) + cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts) if cleanErr != nil { service.CleanupExtractedFiles(extractedFiles) c.JSON(http.StatusInternalServerError, model.UploadResponse{ @@ -255,22 +255,37 @@ func Upload(c *gin.Context) { } service.CleanupExtractedFiles(extractedFiles) + repo := repository.GetRepository() + // 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录 var jdRelatedDeleted int64 - if billType == "jd" { - repo := repository.GetRepository() - if repo != nil { - deleted, err := repo.SoftDeleteJDRelatedBills() - if err != nil { - fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err) - } else if deleted > 0 { - jdRelatedDeleted = deleted - fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted) + if billType == "jd" && repo != nil { + deleted, err := repo.SoftDeleteJDRelatedBills() + if err != nil { + fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err) + } else if deleted > 0 { + jdRelatedDeleted = deleted + fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted) + } + } + + // 14. 核销跨批次退款(本次清洗中未在同批次内匹配到对应支出的退款) + var reconciledCount int + if repo != nil && cleanResult != nil { + for _, ur := range cleanResult.UnresolvedRefunds { + matched, rErr := repo.ReconcileRefund(billType, ur.OrderNo, ur.MerchantOrderNo, ur.Amount, ur.Time, ur.Merchant, ur.Description, ur.RefundOrderNo) + if rErr != nil { + fmt.Printf("⚠️ 退款核销失败: %v\n", rErr) + continue + } + if matched { + reconciledCount++ + fmt.Printf("💰 已核销退款: 订单%s, 金额%.2f元\n", ur.OrderNo, ur.Amount) } } } - // 14. 返回成功响应 + // 15. 返回成功响应 message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount) if dedupResult.DuplicateCount > 0 { message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount) @@ -278,18 +293,22 @@ func Upload(c *gin.Context) { if jdRelatedDeleted > 0 { message = fmt.Sprintf("%s,标记删除 %d 条重复的京东订单", message, jdRelatedDeleted) } + if reconciledCount > 0 { + message = fmt.Sprintf("%s,核销退款 %d 条", message, reconciledCount) + } c.JSON(http.StatusOK, model.UploadResponse{ Result: true, Message: message, Data: &model.UploadData{ - BillType: billType, - FileURL: fmt.Sprintf("/download/%s", outputFileName), - FileName: outputFileName, - RawCount: rawCount, - CleanedCount: cleanedCount, - DuplicateCount: dedupResult.DuplicateCount, - JDRelatedDeleted: jdRelatedDeleted, + BillType: billType, + FileURL: fmt.Sprintf("/download/%s", outputFileName), + FileName: outputFileName, + RawCount: rawCount, + CleanedCount: cleanedCount, + DuplicateCount: dedupResult.DuplicateCount, + JDRelatedDeleted: jdRelatedDeleted, + ReconciledRefundCount: reconciledCount, }, }) } diff --git a/server/model/response.go b/server/model/response.go index 77a4ab8..207a62e 100644 --- a/server/model/response.go +++ b/server/model/response.go @@ -2,13 +2,14 @@ package model // UploadData 上传响应数据 type UploadData struct { - BillType string `json:"bill_type,omitempty"` // alipay/wechat/jd - FileURL string `json:"file_url,omitempty"` // 下载链接 - FileName string `json:"file_name,omitempty"` // 文件名 - RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数 - CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数 - DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数 - JDRelatedDeleted int64 `json:"jd_related_deleted,omitempty"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录) + BillType string `json:"bill_type,omitempty"` // alipay/wechat/jd + FileURL string `json:"file_url,omitempty"` // 下载链接 + FileName string `json:"file_name,omitempty"` // 文件名 + RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数 + CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数 + DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数 + JDRelatedDeleted int64 `json:"jd_related_deleted,omitempty"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录) + ReconciledRefundCount int `json:"reconciled_refund_count,omitempty"` // 跨批次核销的退款记录数 } // UploadResponse 上传响应 diff --git a/server/repository/mongo/repository.go b/server/repository/mongo/repository.go index 397128e..2eaf958 100644 --- a/server/repository/mongo/repository.go +++ b/server/repository/mongo/repository.go @@ -4,6 +4,7 @@ package mongo import ( "context" "fmt" + "math" "time" "go.mongodb.org/mongo-driver/bson" @@ -16,6 +17,9 @@ import ( "billai-server/repository" ) +// refundEpsilon 退款核销后剩余金额的容差阈值,小于该值视为已全额退款 +const refundEpsilon = 0.005 + // Repository MongoDB 账单存储实现 type Repository struct { client *mongo.Client @@ -498,6 +502,74 @@ func (r *Repository) SoftDeleteJDRelatedBills() (int64, error) { return result.ModifiedCount, nil } +// ReconcileRefund 将跨批次退款核销到已存储的清洗后账单 +// 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录 +// 全额退款(剩余金额 <= refundEpsilon)则软删除;部分退款则扣减 amount 并追加备注 +func (r *Repository) ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (bool, error) { + if r.cleanedCollection == nil { + return false, fmt.Errorf("cleaned collection not initialized") + } + + if orderNo == "" && merchantOrderNo == "" { + return false, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var or []bson.M + if orderNo != "" { + or = append(or, bson.M{"transaction_id": orderNo}) + } + if merchantOrderNo != "" { + or = append(or, bson.M{"merchant_order_no": merchantOrderNo}) + } + filter := bson.M{ + "bill_type": billType, + "is_deleted": bson.M{"$ne": true}, + "$or": or, + } + + var bill model.CleanedBill + if err := r.cleanedCollection.FindOne(ctx, filter).Decode(&bill); err != nil { + if err == mongo.ErrNoDocuments { + return false, nil + } + return false, fmt.Errorf("查询待核销账单失败: %w", err) + } + + remaining := bill.Amount - refundAmount + now := time.Now() + + if remaining <= refundEpsilon { + update := bson.M{"$set": bson.M{ + "is_deleted": true, + "updated_at": now, + "remark": fmt.Sprintf("[退款核销]全额退款%.2f元(退款单号%s);%s", refundAmount, refundOrderNo, bill.Remark), + }} + _, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update) + if err != nil { + return false, fmt.Errorf("核销退款失败: %w", err) + } + return true, nil + } + + remaining = math.Round(remaining*100) / 100 + update := bson.M{"$set": bson.M{ + "amount": remaining, + "updated_at": now, + "remark": fmt.Sprintf("原金额%.2f元,退款%.2f元(退款单号%s);%s", bill.Amount, refundAmount, refundOrderNo, bill.Remark), + }} + _, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update) + if err != nil { + return false, fmt.Errorf("核销退款失败: %w", err) + } + return true, nil +} + +// 建议: 为提升 ReconcileRefund 查询性能,可为 bills_cleaned 添加索引 +// {transaction_id:1, bill_type:1} 和 {merchant_order_no:1, bill_type:1}(与现有"无索引"问题一并处理) + // GetClient 获取 MongoDB 客户端(用于兼容旧代码) func (r *Repository) GetClient() *mongo.Client { return r.client diff --git a/server/repository/repository.go b/server/repository/repository.go index 8230842..e4f3907 100644 --- a/server/repository/repository.go +++ b/server/repository/repository.go @@ -56,4 +56,10 @@ type BillRepository interface { // 用于避免京东账单与其他来源(微信、支付宝)账单重复计算 // 返回: 删除数量、错误 SoftDeleteJDRelatedBills() (int64, error) + + // ReconcileRefund 将跨批次退款核销到已存储的清洗后账单 + // 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录 + // 全额退款(剩余金额 <= 0.005)则软删除;部分退款则扣减 amount 并追加备注 + // 返回: 是否找到并核销了匹配记录、错误(未找到匹配记录不算错误,返回 matched=false) + ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (matched bool, err error) } diff --git a/web/src/lib/components/analysis/DailyTrendChart.svelte b/web/src/lib/components/analysis/DailyTrendChart.svelte index 2b33691..2bcd515 100644 --- a/web/src/lib/components/analysis/DailyTrendChart.svelte +++ b/web/src/lib/components/analysis/DailyTrendChart.svelte @@ -199,6 +199,20 @@ dayData.set(category, (dayData.get(category) || 0) + amount); }); + // 填充缺失的日期(零支出日期) + const now = new Date(); + const allDates: string[] = []; + const cursor = new Date(cutoffDate); + while (cursor <= now) { + allDates.push(formatLocalDate(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + allDates.forEach(dateStr => { + if (!dailyMap.has(dateStr)) { + dailyMap.set(dateStr, new Map()); + } + }); + const dayCount = dailyMap.size; // 根据天数决定聚合粒度