Merge pull request #1 from FadingLight9291117/dev
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
feat: implement cross-batch Alipay refund reconciliation
This commit is contained in:
152
AGENTS.md
152
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 <token>`.
|
||||
- `middleware.AuthRequired()` wraps `/api/*` (except `/api/auth/*`).
|
||||
- 401 anywhere → `auth.logout()` + redirect `/login`.
|
||||
- JWT (HS256). Header: `Authorization: Bearer <token>`.
|
||||
- `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.
|
||||
|
||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
"""设置日期筛选范围"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 格式转换结果
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 上传响应
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 根据天数决定聚合粒度
|
||||
|
||||
Reference in New Issue
Block a user