diff --git a/.github/workflows/dart-check.yml b/.github/workflows/dart-check.yml new file mode 100644 index 0000000..714985a --- /dev/null +++ b/.github/workflows/dart-check.yml @@ -0,0 +1,20 @@ +name: dart-check +on: + push: + paths: ['packages/finance/dart/**'] + pull_request: + paths: ['packages/finance/dart/**'] +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/finance/dart + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get + - run: dart analyze + - run: dart test diff --git a/.github/workflows/dart-publish.yml b/.github/workflows/dart-publish.yml new file mode 100644 index 0000000..7d3662d --- /dev/null +++ b/.github/workflows/dart-publish.yml @@ -0,0 +1,16 @@ +name: publish-dart +on: + release: + types: [published] +jobs: + publishing: + if: startsWith(github.ref, 'refs/tags/dart/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: k-paxian/dart-package-publisher@master + with: + credentialJson: \${{ secrets.PUBDEV_CREDENTIAL_JSON }} + relativePath: packages/finance/dart + force: true + flutter: false diff --git a/.gitignore b/.gitignore index be0fe38..988798d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,82 @@ -# Python +# ─── Python ─────────────────────────────────────────────── __pycache__/ *.py[cod] *$py.class *.so .Python +*.egg-info/ +*.egg +.venv*/ +venv/ +ENV/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ +lib/ lib64/ parts/ sdist/ var/ wheels/ -*.egg-info/ +*.whl .installed.cfg -*.egg -.venv/ -venv/ -ENV/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage/ +htmlcov/ -# Data -data/ +# ─── Dart / Flutter ────────────────────────────────────── +.dart_tool/ +.pub/ +.builders/ +build/ +*.freezed.dart +*.g.dart +.pub-preload-cache/ +*.js_ +*.js.deps +*.js.map + +# ─── Data / Environment ────────────────────────────────── .env +.env.local +.env.production +*.db +data/ +*.log +logs/ -# IDE +# ─── IDE / Editor ──────────────────────────────────────── .idea/ .vscode/ *.swp *.swo +*~ +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates -# OS +# ─── OS ────────────────────────────────────────────────── .DS_Store Thumbs.db +Desktop.ini +ehthumbs.db -# Terraform +# ─── Terraform ─────────────────────────────────────────── .terraform/ -terraform/terraform.tfstate -terraform/terraform.tfstate.backup +*.tfstate +*.tfstate.backup +*.tfvars + +# ─── Build Artifacts ───────────────────────────────────── +*.tar.gz +*.zip +*.dmg +*.pkg diff --git a/CHANGELOG.md b/CHANGELOG.md index 92078b7..856d2db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,116 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/). +## [0.2.0] - 2026-06-14 + +### Added + +#### Finance 模块 + +新增 `packages/finance/` 三层包结构,实现财务记录标准化、分类和统计的核心流程: + +``` +SourceRecord → NormalizedRecord → ClassificationResult → Statistics + ↓ (可选) + Journal / JournalEntry / JournalEntryLine +``` + +##### `packages/finance/dart/`(版本 0.2.0) + +Dart 领域模型与共享 DTO: +- `Journal` / `JournalEntry` / `JournalEntryLine` 凭证模型(freezed 不可变,`copyWith` + JSON 序列化) +- 8 个枚举:`SourceType` / `IngestionStatus` / `RecordType` / `Direction` / `NormalizationStatus` / `ClassifierKind` / `ReviewStatus` / `LineType`(`@JsonEnum` + `@JsonValue` 显式映射 + `unknown` 兜底) +- 测试:`journal_test.dart` + +##### `packages/finance/fastapi/`(版本 0.1.0) + +FastAPI 后端,Python 3.12+,SQLAlchemy 2.0 + SQLite: + +- **4 个 ORM 模型**: + - `SourceRecord`(`source_type` / `raw_text` / `evidence_refs` / `ingestion_status`) + - `NormalizedRecord`(`amount_cents` / `direction` / `department` / `person` / `business_date`) + - `RecordLink`(SourceRecord ←→ NormalizedRecord 多对多关联) + - `ClassificationResult`(`category` / `classifier_kind` / `confidence` / `review_status` / `is_active`) + +- **Pydantic schema 层**:Create/Read/Update 分离,字段约束: + - `amount_cents >= 0`(`Field(ge=0)`) + - `raw_text > 65535` → reject(422) + - `description > 1000` → 静默截断 + +- **Alembic 迁移**:2 个脚本(M1 基础实体 + M2 tags JSON 修复) + +- **3 组 REST 路由器**: + - `source_records`:SourceRecord CRUD + NormalizedRecord CRUD + `POST /source-records/{id}/normalize` + - `classifications`:创建 + 列表 + `PATCH` 审核/软删除 + - `statistics`:4 个统计端点 — `summary`(汇总卡片)、`breakdown?dimension=`(分组)、`trend?granularity=`(趋势)、`drilldown`(明细),支持 department / person / record_type / direction / currency / taxonomy+category 等多维度筛选 + +- **Normalizer 接口 + 注册机制**,内置 2 个实现: + - `CsvRowNormalizer`:解析标准列 CSV(9 列自动映射,含 department / person / counterparty 可选列) + - `ManualNormalizer`:手工录入兜底(raw_text → description) + +- **测试**:7 个文件,30 项测试,涵盖 schema 验证、路由集成、normalizers、statistics 全端点 +- **测试基础设施**:Alembic 自动迁移 + SQLite `PRAGMA foreign_keys=ON` + 每测试独立临时数据库 + +##### `packages/finance/flutter/`(版本 0.1.0) + +Flutter API 适配层: +- `FinanceApiClient`:Dart HTTP 客户端,封装所有后端 REST 调用(CRUD + normalize + classify + 4 个 statistics 端点) +- Mockito 测试:`client_test.dart` + `client_test.mocks.dart` +- `main.dart`:独立验证壳(非 Studio 集成,仅用于单独启动测试) + +##### `examples/finance/` + +前端演示 Demo: +- `index.html`:HTML/CSS/JS 实现的完整 5 步产品流程(录入 → 标准化确认 → 关键词预分类 → 批量审核 → 统计看板) +- `seed.py`:种子数据脚本 + `demo.db` 预填充数据库 +- `RUN_DEMO.md` / `README.md`:启动说明 + +#### Studio Finance 集成 + +在 `packages/finance/` 三层之上新增的 Studio 专属层: + +- **新建 `src/studio/packages/qtadmin-finance/` 包** + - `FinanceModuleConfig`:API base URL + `enableReviewQueue` / `enableStatistics` 功能开关 + - `FinanceWorkspaceScreen`:Flutter 工作区屏幕,包含: + - 3 张统计卡片(record count / amount / classified count) + - 手工录入表单(raw text / date / amount / department / person / description + record type + direction) + - 审核队列(分类浏览、单条编辑、审核对话、多选批量确认) + - 部门分布面板(top 5)+ 月度趋势面板(最近 6 期) + - 加载 / 错误 / 空数据三种状态 UI,`¥1,234.56` 中文金额格式化 + - Widget 测试(表单验证、录入、编辑、批量审核)+ Route 测试 + +- **路由注册**:`src/studio/lib/router.dart` 新增 `finance` 路由(`label: '财务管理'`,icon `Icons.account_balance_outlined`),API base URL 通过 `ScreenContext.financeConfig` 注入 +- **`ScreenContext`** 新增 `financeConfig` 字段 +- **`src/studio/lib/blocs/app_bloc.dart`**:传递 `financeConfig` 至 `ScreenContext` +- **`src/studio/pubspec.yaml`**:新增 `qtadmin_finance` path 依赖 + +#### CI 与发布管线 + +- `.github/workflows/dart-check.yml`:push/PR 触发 `packages/finance/dart/**`,执行 `dart pub get` → `dart analyze` → `dart test` +- `.github/workflows/dart-publish.yml`:标签 `dart/` 前缀触发 pub.dev 自动发布 + +#### 文档 + +- `docs/dev/finance-integration-plan.md`:finance 模块集成策略、分期计划(P0–P4) +- `docs/user-guide/finance.md`:用户指南 +- `docs/user-guide/human.md`:人力资源职能用户手册 +- `docs/user-guide/asset.md`:资产职能用户手册 +- `docs/user-guide/business.md`:业务线用户手册 +- `docs/user-guide/index.md`:用户手册索引 +- `docs/add/hr-email-import.md`:招聘邮箱导入程序架构设计 +- `STATUS.md`:项目状态概览 + +### Changed + +- `docs/myst.yml`:目录结构调整,保留 `user-guide` 目录 +- 侧边栏导航:新增"财务管理"入口,使用 `RouteConfig.all` 统一迭代 + +### Removed + +- `.agents/skills/product-brd/SKILL.md` +- `.agents/skills/product-drd/SKILL.md` +- `.agents/skills/product-prd/SKILL.md` + ## [0.1.0] - 2026-05-09 ### Studio diff --git a/docs/dev/finance-integration-plan.md b/docs/dev/finance-integration-plan.md new file mode 100644 index 0000000..6503029 --- /dev/null +++ b/docs/dev/finance-integration-plan.md @@ -0,0 +1,157 @@ +# Finance Integration Plan + +## 背景 + +`quanttide-finance-toolkit` 已经在 `qtadmin` 内形成镜像落点: + +- `quanttide-finance-toolkit/packages/fastapi` 对应 `qtadmin/packages/finance/fastapi` +- `quanttide-finance-toolkit/packages/dart` 对应 `qtadmin/packages/finance/dart` +- `quanttide-finance-toolkit/packages/flutter` 对应 `qtadmin/packages/finance/flutter` +- `quanttide-finance-toolkit/demo` 对应 `qtadmin/examples/finance` + +当前问题不是“如何把 toolkit 再搬一次”,而是: + +1. 哪一侧是唯一开发主线 +2. finance 如何进入 `qtadmin` 的 Studio 领域结构 +3. 后端、共享 DTO、Studio UI 三层如何分责 + +## 现状判断 + +### 已确认事实 + +- `qtadmin/packages/finance` 与 `quanttide-finance-toolkit/packages` 的核心代码基本一致。 +- 差异主要集中在脚本包装、文档位置和仓库外围文件,不在核心业务实现。 +- `qtadmin` 主仓已包含 finance demo 与后端测试。 +- `src/studio/packages/` 目前没有 finance 领域包,说明 finance 尚未真正进入 Studio 架构。 + +### 风险 + +- 若继续双仓双写,finance 会持续分叉。 +- 若直接把 `packages/finance/flutter` 当作 Studio 模块使用,会把演示壳和正式客户端耦合在一起。 +- 若过早并入 `src/provider`,会在权限、组织、租户模型尚未稳定时放大维护成本。 + +## 目标 + +### 目标一:唯一事实来源 + +将 `qtadmin/packages/finance` 设为 finance 的唯一开发主线。 + +### 目标二:分层清晰 + +finance 在 `qtadmin` 内分为三层: + +- `packages/finance/fastapi`:独立后端能力 +- `packages/finance/dart`:共享 DTO / domain model +- `packages/finance/flutter`:Flutter API adapter +- `src/studio/packages/qtadmin-finance`:Studio 专属页面、状态管理、领域视图 + +### 目标三:先接主流程,再补外围 + +Studio 集成优先覆盖: + +1. 录入 +2. 分类审核 +3. 统计看板 + +凭证层不是第一阶段重点。 + +## 目标架构 + +```text +qtadmin/ +├── packages/ +│ └── finance/ +│ ├── fastapi/ # finance backend +│ ├── dart/ # shared DTO and models +│ └── flutter/ # API client and Flutter adapter +├── examples/ +│ └── finance/ # demo and manual verification +└── src/ + └── studio/ + └── packages/ + └── qtadmin-finance/ + ├── lib/ + │ ├── finance.dart + │ └── src/ + │ ├── config/ + │ ├── screens/ + │ └── views/ + └── test/ +``` + +## 决策 + +### 1. 主线归属 + +- 日常开发只改 `qtadmin/packages/finance` +- `quanttide-finance-toolkit` 作为过渡仓或发布镜像,不再承担双向手工同步 + +### 2. Studio 集成方式 + +- 新建 `src/studio/packages/qtadmin-finance` +- 该包依赖 `packages/finance/flutter` 暴露的 API client +- 该包只承载 Studio 语境下的页面、路由目标、状态管理、交互视图 + +### 3. 后端接入方式 + +- 短期:`packages/finance/fastapi` 独立运行,Studio 通过 base URL 调用 +- 中期:待权限、组织、租户边界稳定后,再评估是否挂入统一 provider + +### 4. demo 定位 + +- `examples/finance` 保留为联调与产品验证环境 +- 不作为 Studio 正式实现的源码来源 + +## 分期实施 + +### P0:治理收口 + +- 明确 `qtadmin/packages/finance` 为唯一主线 +- 在 toolkit 仓补迁移说明 +- 停止双向手改 + +### P1:包边界固定 + +- 明确 `packages/finance/dart` 只做共享 DTO / model +- 明确 `packages/finance/flutter` 只做 adapter,不继续堆页面壳 +- 新建 `qtadmin-finance` 占位包 + +### P2:Studio 最小接入 + +- 在 Studio 中增加 finance 路由入口 +- 建立录入、审核、统计三个页面骨架 +- 将现有 finance API client 接入 Studio 层状态管理 +- 将 finance API base URL 提升到 Studio 应用配置,避免路由层硬编码 + +### P3:组织与权限 + +- 补组织维度筛选 +- 补角色权限 +- 补统一鉴权注入 + +### P4:仓库归档 + +- 评估 `quanttide-finance-toolkit` 是归档、镜像发布,还是作为外部只读仓保留 + +## 第一阶段工作项 + +### 必做 + +- 新增 `qtadmin-finance` 包骨架 +- 为 finance Studio 集成建立文档约束 +- 保持 finance demo、adapter、backend 三层可独立演进 +- 通过 `QTADMIN_FINANCE_API_BASE_URL` 管理 Studio 联调地址 + +### 暂不做 + +- 不立即改 `src/studio/lib/router.dart` +- 不立即将 finance 并入 `src/provider` +- 不立即改动凭证层设计 + +## 验收标准 + +- finance 只存在一套日常维护源码主线 +- `src/studio/packages/qtadmin-finance` 成为后续 Studio 集成落点 +- `packages/finance/flutter` 与 Studio UI 边界清晰 +- 后续新增 finance 功能不再需要在两个仓库间复制 +- Studio finance 不再依赖硬编码 `localhost` API 地址 diff --git a/docs/user-guide/finance.md b/docs/user-guide/finance.md index e69de29..5dbbd20 100644 --- a/docs/user-guide/finance.md +++ b/docs/user-guide/finance.md @@ -0,0 +1,108 @@ +# 财务管理 + +量潮财务管理模块提供财务记录的标准化录入、分类和统计分析能力。核心流程: + +``` +录入 → 标准化确认 → 分类审核 → 统计看板 +``` + +## 使用方式 + +### Studio 工作台 + +在量潮管理后台侧边栏点击"财务管理"进入 Finance 工作区。 + +工作区分为四个区域: + +**统计概览** — 顶部三张卡片显示当前范围内的汇总数据: +- Records:标准化记录总数 +- Amount:金额汇总 +- Classified:已确认分类的记录数 + +**手工录入** — 左侧"Manual Entry"面板: +1. 填写 Raw Text(原始描述,如"打车到机场,188 元") +2. 填写 Business Date(格式 `YYYY-MM-DD`) +3. 填写 Amount Cents(金额,单位为分,如 `18800` 表示 ¥188.00) +4. 选择 Record Type(expense / income / transfer / reimbursement / other) +5. 选择 Direction(outflow 支出 / inflow 收入) +6. 填写 Department 和 Person +7. 填写 Description +8. 点击"提交录入" + +录入成功后系统同时创建 SourceRecord 和 NormalizedRecord。 + +**审核队列** — 右侧"Review Queue"面板: +- 每条记录显示描述、日期、金额、部门、当前分类 +- 点击"编辑"可修改记录字段 +- 点击"审核"打开分类审核对话框,选择分类并确认/驳回 +- 勾选多条记录后点击"批量确认"可一次审核多条 + +**趋势与分布** — 下方面板: +- "Department Breakdown":按部门分组的金额与记录数排行 +- "Monthly Trend":月度金额与记录数趋势 + +### 命令行 + +```bash +# 启动后端服务 +cd packages/finance/fastapi +uvicorn fastapi_quanttide_finance.app:app --reload + +# 运行 Dart 测试 +cd packages/finance/dart +dart test + +# 运行 FastAPI 测试 +cd packages/finance/fastapi +python -m pytest +``` + +### 演示 Demo + +```bash +cd examples/finance +python seed.py # 生成种子数据 +# 打开 index.html 体验完整流程 +``` + +Demo 覆盖五步产品流程:录入 → 标准化确认 → 自动分类 → 批量审核 → 统计看板。 + +## 核心概念 + +| 术语 | 说明 | +|---|---| +| SourceRecord | 原始记录,保留导入时的原始证据和文本 | +| NormalizedRecord | 标准化记录,抽取后的结构化事实字段 | +| ClassificationResult | 分类结果,作为叠加维度不写入标准化记录 | +| RecordLink | 关联表,连接 SourceRecord 与 NormalizedRecord | +| amount_cents | 金额,单位为分(如 ¥188.00 = 18800) | +| direction | 资金方向,outflow(支出)或 inflow(收入) | + +## API + +后端提供 REST API(默认 `http://localhost:8000`): + +| 端点 | 方法 | 说明 | +|---|---|---| +| `/source-records` | GET/POST | 原始记录列表/创建 | +| `/source-records/{id}` | GET | 原始记录详情 | +| `/source-records/{id}/normalize` | POST | 执行标准化 | +| `/normalized-records` | GET/POST | 标准化记录列表/创建 | +| `/normalized-records/{id}` | GET/PATCH | 标准化记录详情/更新 | +| `/normalized-records/{id}/classifications` | GET/POST | 分类结果列表/创建 | +| `/classifications/{id}` | PATCH | 审核分类(accepted/rejected) | +| `/statistics/summary` | GET | 统计汇总 | +| `/statistics/breakdown` | GET | 分组统计 | +| `/statistics/trend` | GET | 趋势统计 | +| `/statistics/drilldown` | GET | 明细查询 | + +## Studio 集成配置 + +API base URL 通过 `QTADMIN_FINANCE_API_BASE_URL` 环境变量或 `FinanceModuleConfig` 注入,避免硬编码。 + +## 限制 + +- 金额以分为单位,字段约束 `amount_cents >= 0`,方向通过 `outflow`/`inflow` 表示 +- `raw_text` 超过 65535 字符会被拒绝 +- `description` 超过 1000 字符自动截断 +- 分类审核通过(`accepted`)后才纳入统计口径 diff --git a/examples/finance/PLAN.md b/examples/finance/PLAN.md new file mode 100644 index 0000000..2f0777e --- /dev/null +++ b/examples/finance/PLAN.md @@ -0,0 +1,180 @@ +# Demo 计划:全流程财务演示(产品版) + +## 目标 + +制作一个**单页 Web 应用**覆盖 M1–M4 全链路,让财务部同事体验真实工作流: +**录入结构化单据 → 确认标准化 → 系统自动预分类 → 批量审核确认 → 统计看效果**。 + +--- + +## 与现有 demo/index.html 的关系 + +**新建文件**,不与现有 M4 统计页共用。文件名为 `demo/index.html`(覆盖原统计页),或另起 `demo/full.html`。 + +--- + +## 支持范围(硬性约束) + +| 维度 | 约束 | 原因 | +|------|------|------| +| 可录入的 source_type | 仅 `manual` | `csv_row` 是批量导入场景,demo 演示单笔录入 | +| 分类 taxonomy | 固定 `expense_type` | 后端只有一个 | +| 分类 category | `办公用品`/`差旅`/`采购`/`工资`/`其他` | 来自 `services/classification.py` | +| 币种 | 默认 `CNY`,不做多币种 | 无业务需求 | +| 金额聚合 | 始终返回数值 | `currency=*` 不暴露 | + +**不包含**:`csv_row`/`image`/`chat`/`bank_tx`/`form`/`api`;`source_channel`。 + +--- + +## 主流程 + +``` +① 录入单据 ② 确认标准化 ③ 系统自动预分类 ④ 批量审核 ⑤ 看统计 + (编辑 → 确认) + 人工调分类 (一键提交一类) +``` + +--- + +## 功能模块 + +### 模块 1:结构化单据录入(M1 修正) + +用户填的是业务字段,不是 `raw_text`。 + +**表单**: + +| 字段 | 控件 | 说明 | +|------|------|------| +| 金额 (元) | `` | 必填,单位元 | +| 方向 | `` | 默认当天 | +| 描述 | `` | 必填 | +| 部门 | `` | 选填 | + +**提交后**:前端组装为 `{ source_type: "manual", raw_text: "..." }` 调 `POST /source-records`。同时直接写入 `NormalizedRecord`(跳过标准化步骤,见下方说明)。 + +**为什么跳过 POST /source-records → POST .../normalize 两步?** +当前 `manual` Normalizer 没有结构化解析逻辑,它只是原样把 `raw_text` 拷贝到 `description`,`record_type=other`,`normalization_status=draft`。既然 demo 录入时已经填好了结构化字段,直接在录入时写入 `NormalizedRecord` 更高效。提交逻辑变为: + +``` +POST /source-records(存原始证据) ++ POST /normalized-records(同时写入标准化记录,字段来自表单) +``` + +--- + +### 模块 2:确认标准化(M2 修正) + +实际是"预览 → 编辑 → 确认"。 + +录入后立即展示一条**可编辑的标准化记录预览**: + +| 字段 | 来源 | +|------|------| +| 金额 / 方向 / 日期 | 来自录入表单 | +| 部门 / 人员 | 来自录入表单 | +| 描述 | 可编辑 | +| record_type | 自动设为 `expense`,可改 | + +**按钮**: + +| 操作 | 说明 | +|------|------| +| 确认 | 标准化记录写入 `normalization_status=normalized` | +| 修改 | 编辑后重新提交 | +| 放弃 | 删除该条记录(不回写) | + +--- + +### 模块 3:自动分类 + 批量审核(M3 修正) + +**核心变化**:系统先自动预分类,用户只需确认。 + +#### 3a. 自动预分类(前端规则引擎) + +根据 `NormalizedRecord.description` 的关键词匹配,预填 `category`: + +| 关键词规则 | 分类 | +|-----------|------| +| 含"机票""高铁""住宿""差旅""出差""酒店""交通" | 差旅 | +| 含"A4""墨盒""文具""打印""办公桌""办公椅""纸" | 办公用品 | +| 含"采购""购买""设备""服务器""电脑""软件" | 采购 | +| 含"工资""奖金""薪" | 工资 | +| 未匹配任何规则 | 其他 | + +规则写在 JS 中(`function autoClassify(description)`),不入库。**演示目的,非生产逻辑**。 + +#### 3b. 分类展示分组 + +标准化记录按 `category` 分组展示: + +``` +┌─ 差旅(3 条待审核)─────────────────────┐ +│ [x] 张伟 - 北京出差机票 - ¥2,300 │ +│ [x] 李强 - 上海高铁票 - ¥650 │ +│ [x] 王芳 - 杭州住宿 - ¥480 │ +│ [确认以上 3 条为差旅] [批量修改为...] │ +├────────────────────────────────────────┤ +│ ...其他类别... │ +└────────────────────────────────────────┘ +``` + +**交互步骤**: + +1. 每条记录旁显示系统预判的分类标签(绿色:已匹配 / 灰色:待确认) +2. 用户扫一眼,如果整体分类正确 → 点"确认以上 N 条为 XX" → 批量调 `POST .../classifications` → `review_status=accepted` +3. 如果某条分错了 → 在该条旁下拉框手动改分类 +4. 改完后调单条确认 + +**批量确认 API 调用**:逐条 POST(后端无批量端点,demo 前端循环调 `POST /normalized-records/{id}/classifications`)。 + +#### 3c. 已审核分类状态变更 + +确认后该条移到"已审核"区,统计看板 `classified_count` 立即增加。 + +--- + +### 模块 4:统计看板(M4 — 复用) + +同之前设计,但去掉过滤栏、去掉分类过滤器,自动刷新。 + +--- + +## API 调用清单 + +``` +# 1. 录入原始证据 +POST /source-records + { "source_type": "manual", "raw_text": "研发部张伟购A4纸一箱" } + +# 2. 同时写入标准化记录(录入时已结构化) +POST /normalized-records + { "record_type": "expense", "business_date": "2026-09-01", + "amount_cents": 12000, "direction": "outflow", + "department": "研发部", "person": "张伟", + "description": "购买A4纸一箱", + "normalization_status": "normalized" } + +# 3. 批量确认分类 +for each id in [3, 7, 12]: + POST /normalized-records/{id}/classifications + { "taxonomy": "expense_type", "category": "办公用品", + "classifier_kind": "rule", "confidence": 0.85 } + +# 4. 查看统计 +GET /statistics/summary +GET /statistics/breakdown?dimension=department +GET /statistics/trend?granularity=month +GET /statistics/drilldown?limit=15 +``` + +--- + +## 注意事项 + +1. **自动分类是演示辅助,非生产逻辑**。JS 关键词匹配只是为了 demo 中"系统先分、人再确认"的交互能跑通。生产环境应走后端规则引擎或 AI。 +2. **录入时写 NormalizedRecord** 跳过了 Normalizer 流程。这是因为 demo 场景下用户输入已经是结构化数据。生产环境仍应通过 Normalizer 做抽取。 +3. **批量确认无后端批量端点**,前端循环调单条 API。如果后续需要,可以加 `POST /classifications/batch`。 +4. **中文映射表**同之前版本。 diff --git a/examples/finance/README.md b/examples/finance/README.md new file mode 100644 index 0000000..54aa625 --- /dev/null +++ b/examples/finance/README.md @@ -0,0 +1,24 @@ +# 量潮财务工具 — 全流程演示 + +覆盖 M1–M4 全链路的 5 步产品流程体验。 + +## 快速启动 + +参见 [`RUN_DEMO.md`](RUN_DEMO.md)。 + +## 演示流程 + +``` +① 录入单据 → ② 确认标准化 → ③ 系统自动分类 → ④ 批量审核 → ⑤ 统计看板 +``` + +## 目录说明 + +| 文件 | 用途 | +|------|------| +| `index.html` | 演示界面(浏览器打开) | +| `seed.py` | 初始化演示数据 | +| `run_demo.sh` | macOS/Linux 一键启动 | +| `run_demo.bat` | Windows 一键启动 | +| `RUN_DEMO.md` | 启动指南 | +| `PLAN.md` | 产品设计文档 | diff --git a/examples/finance/RUN_DEMO.md b/examples/finance/RUN_DEMO.md new file mode 100644 index 0000000..46d6340 --- /dev/null +++ b/examples/finance/RUN_DEMO.md @@ -0,0 +1,61 @@ +# 量潮财务工具 — 部署与启动指南(Windows) + +## 环境要求 + +- Python 3.12+ +- 现代浏览器(Chrome / Edge / Firefox) + +--- + +## 步骤 + +### 1. 创建虚拟环境并安装依赖 + +```powershell +cd packages\fastapi +python -m venv .venv +.venv\Scripts\Activate.ps1 +pip install -e ".[dev]" +``` + +### 2. 初始化演示数据 + +```powershell +cd demo +python seed.py --reset +``` + +预期输出:创建约 57 条 SourceRecords + NormalizedRecords + 分类数据。 + +### 3. 启动后端服务 + +```powershell +cd packages\fastapi +.venv\Scripts\Activate.ps1 +$env:DEMO_DB="..\..\demo\demo.db"; uvicorn fastapi_quanttide_finance.app:app --reload +``` + +访问 `http://localhost:8000/health` 验证,返回 `{"status":"ok"}` 即正常。 + +### 4. 打开演示页面 + +在文件管理器中双击 `demo\index.html` 用浏览器打开。 + +--- + +## 演示流程 + +``` +录入单据 → 确认标准化 → 系统自动分类 → 批量审核 → 统计看板 +``` + +--- + +## 常见问题 + +| 问题 | 解决 | +|------|------| +| 端口被占用 | `uvicorn` 加 `--port 8001`,并修改 `index.html` 中 `API` 变量 | +| 数据为空 | 重新执行 `python seed.py --reset` | +| 页面无数据 | 确认 `$env:DEMO_DB` 指向了正确的 `demo.db` 路径 | +| 虚拟环境未激活 | `.venv\Scripts\Activate.ps1`(PowerShell)或 `.venv\Scripts\activate.bat`(CMD) | diff --git a/examples/finance/demo.db b/examples/finance/demo.db new file mode 100644 index 0000000..0304a08 Binary files /dev/null and b/examples/finance/demo.db differ diff --git a/examples/finance/index.html b/examples/finance/index.html new file mode 100644 index 0000000..b272f39 --- /dev/null +++ b/examples/finance/index.html @@ -0,0 +1,459 @@ + + + + + +量潮财务工具 — 全流程演示 + + + + +
+

量潮财务全流程演示

+

① 录入单据 → ② 确认标准化 → ③ 系统自动分类 → ④ 批量审核 → ⑤ 统计看板

+ + + + +
+

① 录入单据 M1

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+ + +
+

② 确认标准化 M2

+
录入单据后,此处显示标准化预览。
+
+ + +
+

③ 系统预分类 + 批量审核 M3

+
标准化确认后,系统自动分类并在此展示。
+
+ + +
+

④ 统计看板 M4

+
+
+

部门分布

+

月度趋势

+
+
+
+ + + + diff --git a/examples/finance/seed.py b/examples/finance/seed.py new file mode 100644 index 0000000..b7c8794 --- /dev/null +++ b/examples/finance/seed.py @@ -0,0 +1,225 @@ +"""M4 Demo — 填充演示数据到独立 demo 数据库。 + +不会触碰主开发库的 quanttide_finance.db。 +通过 --reset 确认后清空 demo 数据重新生成。 +""" + +import argparse +import sys +from datetime import date +from pathlib import Path +from random import choice, randint, seed as random_seed + +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "packages/fastapi/src")) +from fastapi_quanttide_finance.models.source_record import SourceRecord +from fastapi_quanttide_finance.models.normalized_record import NormalizedRecord +from fastapi_quanttide_finance.models.record_link import RecordLink +from fastapi_quanttide_finance.models.classification_result import ClassificationResult +from fastapi_quanttide_finance.database import Base + + +@event.listens_for(Engine, "connect") +def _set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +DEMO_DIR = Path(__file__).resolve().parent +DEMO_DB_PATH = DEMO_DIR / "demo.db" +DB_URL = f"sqlite:///{DEMO_DB_PATH}" + +# ------- 演示数据 ------- + +DEPARTMENTS = ["研发部", "市场部", "行政部", "财务部", "销售部", "采购部"] +PEOPLE = { + "研发部": ["张伟", "李强", "王芳", "刘洋"], + "市场部": ["陈静", "赵敏", "周杰"], + "行政部": ["吴婷", "郑浩"], + "财务部": ["孙丽", "黄伟", "林涛"], + "销售部": ["马超", "朱红", "何亮", "徐飞"], + "采购部": ["胡明", "郭雪"], +} +COUNTERPARTIES = [ + "京东企业购", "携程商旅", "滴滴企业版", "中国石油", "中国移动", + "顺丰速运", "联想集团", "用友网络", "华为技术", "阿里云", +] +RECORD_DESCRIPTIONS = [ + "办公用品采购", "差旅报销", "项目外包服务费", "设备维修费", + "培训费用", "交通补贴", "通讯费", "快递费", + "软件订阅费", "招待费", "房租水电", "保洁服务", +] +AMOUNT_RANGES = { # (min, max) 单位分 + "expense": (5000, 500000), + "income": (10000, 2000000), + "transfer": (50000, 1000000), + "reimbursement": (10000, 200000), + "other": (1000, 100000), +} +CLASSIFICATION_CATEGORIES = ["办公用品", "差旅", "采购", "工资", "其他"] +CATEGORY_WEIGHT = {"办公用品": .3, "差旅": .25, "采购": .2, "工资": .15, "其他": .1} + + +def weighted_choice(options, weights): + r = randint(1, 100) + cumulative = 0 + for opt, w in zip(options, weights): + cumulative += w * 100 + if r <= cumulative: + return opt + return options[-1] + + +def main(): + parser = argparse.ArgumentParser(description="填充 demo 数据库") + parser.add_argument("--reset", action="store_true", help="确认清空 demo 数据后重新生成") + args = parser.parse_args() + + random_seed(42) + + engine = create_engine(DB_URL, echo=False) + SessionLocal = sessionmaker(bind=engine) + + if args.reset and DEMO_DB_PATH.exists(): + DEMO_DB_PATH.unlink() + print(f"Removed existing demo DB: {DEMO_DB_PATH}") + + if DEMO_DB_PATH.exists(): + print(f"Demo DB already exists: {DEMO_DB_PATH}") + print("Use --reset to regenerate.") + sys.exit(0) + + # 建表 + Base.metadata.create_all(engine) + print(f"Created demo DB: {DEMO_DB_PATH}") + + session = SessionLocal() + + # ---- 生成原始记录 ---- + all_srs = [] + sr_id = 0 + for month in [6, 7, 8]: + for dept in DEPARTMENTS: + num = randint(2, 4) + for _ in range(num): + sr_id += 1 + day = randint(1, 28) + person = choice(PEOPLE[dept]) + sr = SourceRecord( + source_type="manual", + raw_text=f"{dept}{person}提交的{choice(RECORD_DESCRIPTIONS)}", + ingestion_status="normalized", + ) + session.add(sr) + session.flush() + all_srs.append(sr) + + session.commit() + print(f"Created {len(all_srs)} SourceRecords") + + # ---- 生成标准化记录 ---- + all_nrs = [] + for i, sr in enumerate(all_srs): + dept = DEPARTMENTS[i % len(DEPARTMENTS)] + person = choice(PEOPLE[dept]) + month = 6 + (i // (len(DEPARTMENTS) * 3)) # 大致分配到 6-8 月 + day = 1 + (i % 28) + record_type = choice(["expense", "expense", "expense", "reimbursement", "other"]) + direction = "outflow" if record_type != "income" else choice(["outflow", "inflow"]) + amt_range = AMOUNT_RANGES.get(record_type, (10000, 100000)) + amount_cents = randint(*amt_range) + + nr = NormalizedRecord( + primary_source_id=sr.id, + record_type=record_type, + business_date=date(2026, month, day), + amount_cents=amount_cents, + currency="CNY", + direction=direction, + department=dept, + person=person, + counterparty=choice(COUNTERPARTIES), + description=choice(RECORD_DESCRIPTIONS), + normalization_status="normalized", + ) + session.add(nr) + session.flush() + all_nrs.append(nr) + + # 建立 RecordLink + rl = RecordLink( + source_record_id=sr.id, + normalized_record_id=nr.id, + relation_type="primary", + ) + session.add(rl) + + session.commit() + print(f"Created {len(all_nrs)} NormalizedRecords + {len(all_nrs)} RecordLinks") + + # ---- 生成分类 ---- + total_classifications = 0 + accepted_total = 0 + for nr in all_nrs: + # 约 85% 的记录有分类(剩余的作为"未分类"展示在统计中) + if randint(1, 100) > 85: + continue + cat = weighted_choice(CLASSIFICATION_CATEGORIES, [ + CATEGORY_WEIGHT[c] for c in CLASSIFICATION_CATEGORIES + ]) + is_accepted = randint(1, 100) <= 75 # 75% 已审核 + + cr = ClassificationResult( + normalized_record_id=nr.id, + taxonomy="expense_type", + category=cat, + classifier_kind="manual", + confidence=0.95 if is_accepted else 0.70, + review_status="accepted" if is_accepted else "candidate", + is_active=True, + ) + session.add(cr) + total_classifications += 1 + if is_accepted: + accepted_total += 1 + + session.commit() + print(f"Created {total_classifications} classifications ({accepted_total} accepted, {total_classifications - accepted_total} candidates)") + + # ---- 验证 ---- + total = session.query(NormalizedRecord).count() + sum_amount = session.query(__import__("sqlalchemy").func.sum(NormalizedRecord.amount_cents)).scalar() or 0 + classified = ( + session.query(NormalizedRecord) + .filter( + NormalizedRecord.id.in_( + session.query(ClassificationResult.normalized_record_id).filter( + ClassificationResult.review_status == "accepted", + ClassificationResult.is_active == True, + ) + ) + ) + .count() + ) + + print(f"\n=== Demo Data Summary ===") + print(f"Total records: {total}") + print(f"Sum amount_cents: {sum_amount:,} (¥{sum_amount/100:,.2f})") + print(f"Records with accepted classification: {classified}") + print(f"Classification rate: {classified/total*100:.0f}%") + print(f"\nDemo DB: {DEMO_DB_PATH}") + print("Ready! Start uvicorn and open the demo.") + print() + print("启动后端时需指定 demo 数据库:") + print(" DEMO_DB=1 uvicorn fastapi_quanttide_finance.app:app --reload") + print("或手动修改 database.py 中的 DATABASE_URL 指向 demo/demo.db") + + session.close() + + +if __name__ == "__main__": + main() diff --git a/packages/finance/LICENSE b/packages/finance/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/packages/finance/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/finance/README.md b/packages/finance/README.md new file mode 100644 index 0000000..e293a18 --- /dev/null +++ b/packages/finance/README.md @@ -0,0 +1,219 @@ +# quanttide-finance-toolkit + +**量潮财务记录标准化工具箱** —— 把各种原始记录收进系统,标准化、分类、统计。 + +## 为什么做这个 + +企业财务里的原始记录来源非常杂:报销单、付款单、银行流水、微信转账截图、聊天消息、口头说明、CSV 导出。 + +问题不在于“先记账”,而在于**这些记录先天就不统一**: + +- 有的只有截图,没有结构化字段 +- 有的有金额,没有清晰分类 +- 有的能看出是支出,但看不出该归到什么费用类型 +- 同一件事可能分散在多条记录里 + +所以本项目的核心目标不是做一个传统记账 App,而是做两件事: + +1. 把各种形态的原始记录标准化进系统 +2. 基于标准化结果和分类结果做统计分析 + +会计凭证只是可选下游,不是主干。 + +## 核心流程 + +```text +原始输入(图片 / 聊天 / 表单 / API / CSV / 人工录入) + ↓ +SourceRecord(保留原始证据和原始语义) + │ + │ ┌─ RecordLink ──┐ (关联表,两端记录存在后创建) + │ │ │ + ↓ ↓ ↓ +NormalizedRecord(抽取并统一成可统计的标准字段) + │ + ├──→ Statistics(基于标准字段聚合、筛选、钻取) + │ ↑ + └──→ ClassificationResult(AI/规则/人工分类,作为叠加维度,可选) + +可选下游: +NormalizedRecord + ↓ +Journal(日记账)→ JournalEntry(凭证)→ JournalEntryLine(分录行) +``` + +## 典型使用流程(产品视角) + +以上数据流在财务部日常使用中表现为 5 步操作: + +``` +① 录入单据 ─→ ② 确认标准化 ─→ ③ 系统自动分类 ─→ ④ 批量审核 ─→ ⑤ 统计看板 + 填金额/描述 预览/编辑/确认 关键词匹配预填 扫一眼批量确认 汇总/趋势/明细 + 选部门/人员 分为 5 大类 单条可调类别 +``` + +**每个步骤的职责**: + +| 步骤 | 用户做什么 | 系统做什么 | 对应技术层 | +|------|-----------|-----------|-----------| +| ① 录入 | 填写金额、描述、日期、部门、人员等结构化字段 | 将原始证据存入 `SourceRecord`,同时写入 `NormalizedRecord` | M1–M2 | +| ② 确认 | 预览标准化结果,修正字段后点确认 | 确认后 `normalization_status` 标记为 `normalized` | M2 | +| ③ 自动分类 | 无需操作,观察系统预分类结果 | 根据描述关键词(机票→差旅、A4纸→办公用品等)预填 `category` | M3 | +| ④ 批量审核 | 逐类浏览,点"确认"批量接受一类;错分的单条拖到正确类别 | 写入 `ClassificationResult`,`review_status=accepted` 后才纳入统计 | M3 | +| ⑤ 统计 | 查看汇总卡片、部门分布、趋势图、明细表 | 聚合 `NormalizedRecord` 事实字段 + 已接受的分类结果 | M4 | + +**重要区分**:数据流(SourceRecord → NormalizedRecord → ClassificationResult)描述的是**数据如何存储**;使用流程描述的是**用户如何操作**。两者对应但并不相同——例如用户录入结构化的金额/描述时,系统同时写入 SourceRecord(存原始证据)和 NormalizedRecord(存结构化事实),不是"用户先填 raw_text、再调标准化 API"。 + +## 产品定位 + +- **核心是数据标准化**:先把杂乱记录变成统一结构。 +- **核心也是数据统计**:统计基于标准字段和分类结果,而不是只围绕凭证。 +- **分类是独立步骤**:分类不是原始事实本身,可以由 AI、规则或人工给出,并允许修订。 +- **凭证是可选能力**:只有在需要对接会计系统时,才把标准化记录映射成凭证。 + +## 核心实体 + +| 实体 | 作用 | 是否主干 | +|---|---|---| +| `SourceRecord` | 保存原始记录、来源、原始文本、附件引用、导入状态 | 是 | +| `RecordLink` | 关联 SourceRecord 与 NormalizedRecord(支持拆/并关系) | 是 | +| `NormalizedRecord` | 保存标准化后的事实字段,作为统计基线 | 是 | +| `ClassificationResult` | 保存分类、标签、置信度、分类来源、审核状态 | 是 | +| `Journal` / `JournalEntry` / `JournalEntryLine` | 会计凭证层,用于下游记账或系统集成 | 否,可选 | + +`SourceDocument` 这个名字过窄,更适合替换为 `SourceRecord`,因为输入不一定是“单据”,也可能是聊天、截图、流水行或导入记录。 + +## 设计原则 + +- **原始优先**:原始记录必须保留,方便回溯、复核和重新抽取。 +- **标准化与分类分离**:金额、日期、人员、部门等是标准化事实;费用类型、业务标签等是分类结果。 +- **统计先于凭证**:产品主线是“能统计、能筛选、能钻取”,不是“先做借贷分录”。 +- **允许多轮整理**:同一条记录可以先粗标准化,再补分类,再人工修正。 +- **统一类型约定**:所有 `id` 目标使用 `int`,金额使用 `int` 且单位为“分”。 + +## 当前版本 + +**v1.0** — M0–M4 全链路完成,Demo 可运行。 + +- `packages/fastapi` **M0–M4 完成** — 四个 ORM 模型 + Pydantic Schema + Alembic 迁移 + Normalizer 接口 + CsvRowNormalizer + ManualNormalizer + 分类服务 + 4 统计端点(summary/breakdown/trend/drilldown),三包总计 132 tests。 +- `demo/` **全流程演示已上线** — 覆盖完整 5 步产品流程:结构化录入 → 预览确认 → 自动预分类 → 批量审核 → 统计看板。一键启动脚本 `run_demo.sh` / `run_demo.bat`。 +- `packages/dart` 已发布 [`quanttide_finance`](https://pub.dev/packages/quanttide_finance) `^0.1.1`,提供 `Journal`、`JournalEntry`、`JournalEntryLine` 三个 freezed 不可变模型(可选下游凭证层,非主干)。 + +## 产品功能矩阵 + +以下将 5 步产品流程映射到当前的实现状态: + +| 步骤 | 用户操作 | 实现状态 | 后端支撑 | Demo 覆盖 | +|------|---------|---------|---------|-----------| +| ① 录入单据 | 填写金额、方向、日期、部门、人员、描述 | **已实现** | `POST /source-records` + `POST /normalized-records` | 结构化表单,录入即写两条记录 | +| ② 确认标准化 | 预览标准化结果,修正字段后点确认 | **已实现** | `PATCH /normalized-records/{id}` 可改任意字段,`normalization_status` 标记已确认 | 可编辑预览面板,确认/放弃按钮 | +| ③ 自动分类 | 系统根据关键词规则预分类,无需操作 | **已实现** | 前端 `autoClassify()`(非生产逻辑,仅 demo) | 关键词匹配 → 预填 `category` | +| ④ 批量审核 | 分组浏览,一键确认一类;错分可单条下调改 | **已实现** | `POST /normalized-records/{id}/classifications` 写入,`PATCH /classifications/{id}` 审核通过 | 5 类分组 + 复选框批量确认 + 下拉改分类 | +| ⑤ 统计看板 | 汇总卡片、部门分布、趋势图、明细表 | **已实现** | `GET /statistics/summary` / `breakdown` / `trend` / `drilldown` | 无过滤栏,自动刷新的 Chart.js 看板 | + +**说明**: +- 分类基于前端关键词规则,是演示辅助,非生产逻辑。生产环境应走后端规则引擎或 AI 分类。 +- 统计 `classified_count` 仅计入 `review_status=accepted` 的分类结果。 +- 所有步骤均可在 `demo/index.html` 中体验完整流程。 + +## 当前 Dart 版本的定位 + +以下示例对应当前已发布的 Dart 包 `^0.1.1`。它展示的是凭证层模型,不代表本项目未来的完整主干架构。 + +```yaml +dependencies: + quanttide_finance: ^0.1.1 +``` + +```dart +import 'package:quanttide_finance/quanttide_finance.dart'; + +void main() { + final journal = Journal( + id: '1', + name: '备用金', + createdAt: DateTime.now(), + ); + + final entry = JournalEntry( + id: 'je1', + journalId: journal.id, + createdAt: DateTime.now(), + description: '采购办公用品', + lines: [ + JournalEntryLine( + id: 'l1', + type: LineType.debit, + amount: 1200, + createdAt: DateTime.now(), + ), + JournalEntryLine( + id: 'l2', + type: LineType.credit, + amount: 1200, + createdAt: DateTime.now(), + ), + ], + ); + + print('${entry.description}: 共 ${entry.lines.length} 行'); +} +``` + +## 目标架构 + +| 路径 | 状态 | 说明 | +|---|---|---| +| `packages/dart` | 已有 | 当前是凭证模型;后续可继续承担共享 DTO 或下游模型 | +| `packages/fastapi` | M4 完成 | 核心模型 + 标准化 + 分类 + 统计 API 就绪;下一阶段 M5(数据安全) | + +## 近期计划 + +1. **M5(数据安全)**:脱敏器 + 外部 API 审计日志 + Taxonomy 输出校验。 +2. **M6(Dart 模型同步)**:`id` → `int`,`amount` → `int`(分),`normalizedRecordId` 对齐。 + +## 安全与数据治理(规划) + +### 脱敏原则 + +- **脱敏在数据离站前一刻执行**,不污染存储层。原始 `raw_text` 和 `description` 完整入库,仅在发往外部 AI 前做替换。 +- 本地模型(Ollama)调用不脱敏,数据不出域。 +- 脱敏使用类型标记替换(`[AMOUNT]`、`[ID_CARD]`、`[BANK_CARD]`),保留语义信息供 AI 参考,不暴露精确值。 + +### 分类结果审核 + +- AI 分类结果写入 `ClassificationResult`,`review_status = candidate`,不参与统计。 +- 人工审核确认后改为 `accepted`,此时才纳入统计口径。 +- 置信度阈值可配置(默认 0.7),低于阈值自动标记待审核。 + +### Taxonomy 受控词表 + +- 初期硬编码基础分类列表(`办公用品` / `差旅` / `采购` / `工资` / `其他`)。 +- V2 升级为数据库表 + 管理后台,支持增删改并记录版本。 +- AI 返回的分类标签必须在 taxonomy 范围内,非法值丢弃或标记异常。 + +### 输入校验 + +- `raw_text` 上限 65535 字符,`description` 上限 1000 字符。 +- 分类模型上线前使用历史数据做沙箱测试,对比新旧分类差异。 + +## 待处理事项 + +| 优先级 | 事项 | 说明 | +|---|---|---| +| 🟡 高 | 脱敏粒度细化 | 金额保留相对大小标记(`[AMOUNT:SMALL]` / `[AMOUNT:LARGE]`),保留量级信息辅助 AI 分类 | +| 🟡 中 | 置信度阈值校准 | 上线后基于实际分布调整默认值 | +| 🟢 低 | 速率限制 | 外部 API 每分钟调用上限、超时、降级策略 | +| 🟢 低 | 附件存储 | `evidence_refs` 指向的对象存储方案 | +| 🟢 低 | 导入格式规范 | CSV / 银行流水导入模板 | + +## 许可证 + +本项目基于 [LICENSE](LICENSE) 发布。 + +## 链接 + +- [设计文档](doc/architecture.md) — 架构总览、实体、API、服务、安全、计划 +- [Dart 包文档](packages/dart/README.md) +- [更新日志](packages/dart/CHANGELOG.md) +- [报告 Issue](https://github.com/quanttide/quanttide-finance-toolkit/issues) diff --git a/packages/finance/dart/.dart_tool/package_config.json b/packages/finance/dart/.dart_tool/package_config.json new file mode 100644 index 0000000..881022c --- /dev/null +++ b/packages/finance/dart/.dart_tool/package_config.json @@ -0,0 +1,412 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "_fe_analyzer_shared", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/_fe_analyzer_shared-96.0.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "analyzer", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/analyzer-10.2.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "args", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/args-2.7.0", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "async", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/async-2.13.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "boolean_selector", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "build", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build-4.0.6", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "build_config", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build_config-1.3.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "build_daemon", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build_daemon-4.1.1", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "build_runner", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build_runner-2.15.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "built_collection", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/built_collection-5.1.1", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "built_value", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/built_value-8.12.6", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "checked_yaml", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/checked_yaml-2.0.4", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "cli_config", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/cli_config-0.2.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "collection", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "convert", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/convert-3.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "coverage", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/coverage-1.15.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "crypto", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/crypto-3.0.7", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "dart_style", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/dart_style-3.1.7", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "file", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/file-7.0.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "fixnum", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/fixnum-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "freezed", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/freezed-3.2.5", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "freezed_annotation", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/freezed_annotation-3.1.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "frontend_server_client", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/frontend_server_client-4.0.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "glob", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/glob-2.1.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "graphs", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/graphs-2.3.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "http_multi_server", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/http_multi_server-3.2.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "http_parser", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/http_parser-4.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "io", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/io-1.0.5", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "json_annotation", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/json_annotation-4.9.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "json_serializable", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/json_serializable-6.11.4", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "lints", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/lints-6.1.0", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "logging", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/logging-1.3.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "matcher", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/matcher-0.12.17", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "meta", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/meta-1.18.2", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "mime", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/mime-2.0.0", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "node_preamble", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/node_preamble-2.0.2", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "package_config", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/package_config-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "path", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pool", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/pool-1.5.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pub_semver", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/pub_semver-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pubspec_parse", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/pubspec_parse-1.5.0", + "packageUri": "lib/", + "languageVersion": "3.6" + }, + { + "name": "shelf", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/shelf-1.4.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "shelf_packages_handler", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/shelf_packages_handler-3.0.2", + "packageUri": "lib/", + "languageVersion": "2.17" + }, + { + "name": "shelf_static", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/shelf_static-1.1.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "shelf_web_socket", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/shelf_web_socket-3.0.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "source_gen", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_gen-4.2.3", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "source_helper", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_helper-1.3.12", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "source_map_stack_trace", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_map_stack_trace-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "source_maps", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_maps-0.10.13", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "source_span", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_span-1.10.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "stack_trace", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "stream_transform", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stream_transform-2.1.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "string_scanner", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/test-1.31.1", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "test_api", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/test_api-0.7.12", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "test_core", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/test_core-0.6.18", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "typed_data", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/typed_data-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "vm_service", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/vm_service-15.2.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "watcher", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/watcher-1.2.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/web-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web_socket", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/web_socket-1.0.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web_socket_channel", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/web_socket_channel-3.0.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "webkit_inspection_protocol", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/webkit_inspection_protocol-1.2.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "yaml", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/yaml-3.1.3", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "quanttide_finance", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.11" + } + ], + "generator": "pub", + "generatorVersion": "3.12.0", + "flutterRoot": "file:///home/linli/development/flutter", + "flutterVersion": "3.44.0", + "pubCache": "file:///home/linli/.pub-cache" +} diff --git a/packages/finance/dart/.dart_tool/package_graph.json b/packages/finance/dart/.dart_tool/package_graph.json new file mode 100644 index 0000000..d805604 --- /dev/null +++ b/packages/finance/dart/.dart_tool/package_graph.json @@ -0,0 +1,669 @@ +{ + "roots": [ + "quanttide_finance" + ], + "packages": [ + { + "name": "quanttide_finance", + "version": "0.2.0", + "dependencies": [ + "freezed_annotation", + "json_annotation" + ], + "devDependencies": [ + "build_runner", + "freezed", + "json_serializable", + "lints", + "test" + ] + }, + { + "name": "lints", + "version": "6.1.0", + "dependencies": [] + }, + { + "name": "json_annotation", + "version": "4.9.0", + "dependencies": [ + "meta" + ] + }, + { + "name": "freezed_annotation", + "version": "3.1.0", + "dependencies": [ + "collection", + "json_annotation", + "meta" + ] + }, + { + "name": "meta", + "version": "1.18.2", + "dependencies": [] + }, + { + "name": "pubspec_parse", + "version": "1.5.0", + "dependencies": [ + "checked_yaml", + "collection", + "json_annotation", + "pub_semver", + "yaml" + ] + }, + { + "name": "pub_semver", + "version": "2.2.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "async", + "version": "2.13.1", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "yaml", + "version": "3.1.3", + "dependencies": [ + "collection", + "source_span", + "string_scanner" + ] + }, + { + "name": "checked_yaml", + "version": "2.0.4", + "dependencies": [ + "json_annotation", + "source_span", + "yaml" + ] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.2", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "freezed", + "version": "3.2.5", + "dependencies": [ + "analyzer", + "build", + "build_config", + "collection", + "dart_style", + "freezed_annotation", + "json_annotation", + "meta", + "pub_semver", + "source_gen" + ] + }, + { + "name": "watcher", + "version": "1.2.1", + "dependencies": [ + "async", + "path" + ] + }, + { + "name": "package_config", + "version": "2.2.0", + "dependencies": [ + "path" + ] + }, + { + "name": "glob", + "version": "2.1.3", + "dependencies": [ + "async", + "collection", + "file", + "path", + "string_scanner" + ] + }, + { + "name": "crypto", + "version": "3.0.7", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "convert", + "version": "3.1.2", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "file", + "version": "7.0.1", + "dependencies": [ + "meta", + "path" + ] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "build_config", + "version": "1.3.0", + "dependencies": [ + "checked_yaml", + "json_annotation", + "path", + "pubspec_parse" + ] + }, + { + "name": "args", + "version": "2.7.0", + "dependencies": [] + }, + { + "name": "logging", + "version": "1.3.0", + "dependencies": [] + }, + { + "name": "json_serializable", + "version": "6.11.4", + "dependencies": [ + "analyzer", + "async", + "build", + "build_config", + "dart_style", + "json_annotation", + "meta", + "path", + "pub_semver", + "pubspec_parse", + "source_gen", + "source_helper" + ] + }, + { + "name": "build", + "version": "4.0.6", + "dependencies": [ + "analyzer", + "crypto", + "glob", + "logging", + "package_config", + "path" + ] + }, + { + "name": "source_helper", + "version": "1.3.12", + "dependencies": [ + "analyzer", + "source_gen" + ] + }, + { + "name": "dart_style", + "version": "3.1.7", + "dependencies": [ + "analyzer", + "args", + "collection", + "package_config", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "analyzer", + "version": "10.2.0", + "dependencies": [ + "_fe_analyzer_shared", + "collection", + "convert", + "crypto", + "glob", + "meta", + "package_config", + "path", + "pub_semver", + "source_span", + "watcher", + "yaml" + ] + }, + { + "name": "_fe_analyzer_shared", + "version": "96.0.0", + "dependencies": [ + "meta", + "source_span" + ] + }, + { + "name": "source_gen", + "version": "4.2.3", + "dependencies": [ + "analyzer", + "async", + "build", + "dart_style", + "glob", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "test", + "version": "1.31.1", + "dependencies": [ + "analyzer", + "async", + "boolean_selector", + "collection", + "coverage", + "http_multi_server", + "io", + "matcher", + "node_preamble", + "package_config", + "path", + "pool", + "shelf", + "shelf_packages_handler", + "shelf_static", + "shelf_web_socket", + "source_span", + "stack_trace", + "stream_channel", + "test_api", + "test_core", + "typed_data", + "web_socket_channel", + "webkit_inspection_protocol", + "yaml" + ] + }, + { + "name": "webkit_inspection_protocol", + "version": "1.2.1", + "dependencies": [ + "logging" + ] + }, + { + "name": "web_socket_channel", + "version": "3.0.3", + "dependencies": [ + "async", + "crypto", + "stream_channel", + "web", + "web_socket" + ] + }, + { + "name": "test_core", + "version": "0.6.18", + "dependencies": [ + "analyzer", + "args", + "async", + "boolean_selector", + "collection", + "coverage", + "frontend_server_client", + "glob", + "io", + "meta", + "package_config", + "path", + "pool", + "source_map_stack_trace", + "source_maps", + "source_span", + "stack_trace", + "stream_channel", + "test_api", + "vm_service", + "yaml" + ] + }, + { + "name": "test_api", + "version": "0.7.12", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "shelf_web_socket", + "version": "3.0.0", + "dependencies": [ + "shelf", + "stream_channel", + "web_socket_channel" + ] + }, + { + "name": "shelf_static", + "version": "1.1.3", + "dependencies": [ + "convert", + "http_parser", + "mime", + "path", + "shelf" + ] + }, + { + "name": "shelf_packages_handler", + "version": "3.0.2", + "dependencies": [ + "path", + "shelf", + "shelf_static" + ] + }, + { + "name": "shelf", + "version": "1.4.2", + "dependencies": [ + "async", + "collection", + "http_parser", + "path", + "stack_trace", + "stream_channel" + ] + }, + { + "name": "pool", + "version": "1.5.2", + "dependencies": [ + "async", + "stack_trace" + ] + }, + { + "name": "node_preamble", + "version": "2.0.2", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.17", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "io", + "version": "1.0.5", + "dependencies": [ + "meta", + "path", + "string_scanner" + ] + }, + { + "name": "http_multi_server", + "version": "3.2.2", + "dependencies": [ + "async" + ] + }, + { + "name": "coverage", + "version": "1.15.0", + "dependencies": [ + "args", + "cli_config", + "glob", + "logging", + "meta", + "package_config", + "path", + "source_maps", + "stack_trace", + "vm_service", + "yaml" + ] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "web_socket", + "version": "1.0.1", + "dependencies": [ + "web" + ] + }, + { + "name": "web", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "vm_service", + "version": "15.2.0", + "dependencies": [] + }, + { + "name": "source_maps", + "version": "0.10.13", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_map_stack_trace", + "version": "2.1.2", + "dependencies": [ + "path", + "source_maps", + "stack_trace" + ] + }, + { + "name": "frontend_server_client", + "version": "4.0.0", + "dependencies": [ + "async", + "path" + ] + }, + { + "name": "mime", + "version": "2.0.0", + "dependencies": [] + }, + { + "name": "http_parser", + "version": "4.1.2", + "dependencies": [ + "collection", + "source_span", + "string_scanner", + "typed_data" + ] + }, + { + "name": "cli_config", + "version": "0.2.0", + "dependencies": [ + "args", + "yaml" + ] + }, + { + "name": "build_runner", + "version": "2.15.0", + "dependencies": [ + "analyzer", + "args", + "async", + "build", + "build_config", + "build_daemon", + "built_collection", + "built_value", + "collection", + "convert", + "crypto", + "dart_style", + "glob", + "graphs", + "http_multi_server", + "io", + "json_annotation", + "logging", + "meta", + "mime", + "package_config", + "path", + "pool", + "pub_semver", + "shelf", + "shelf_web_socket", + "stream_transform", + "watcher", + "web_socket_channel", + "yaml" + ] + }, + { + "name": "stream_transform", + "version": "2.1.1", + "dependencies": [] + }, + { + "name": "graphs", + "version": "2.3.2", + "dependencies": [ + "collection" + ] + }, + { + "name": "built_value", + "version": "8.12.6", + "dependencies": [ + "built_collection", + "collection", + "fixnum", + "meta" + ] + }, + { + "name": "built_collection", + "version": "5.1.1", + "dependencies": [] + }, + { + "name": "build_daemon", + "version": "4.1.1", + "dependencies": [ + "built_collection", + "built_value", + "crypto", + "http_multi_server", + "logging", + "path", + "pool", + "shelf", + "shelf_web_socket", + "stream_transform", + "watcher", + "web_socket_channel" + ] + }, + { + "name": "fixnum", + "version": "1.1.1", + "dependencies": [] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/packages/finance/dart/.dart_tool/pub/bin/test/test.dart-3.12.0.snapshot b/packages/finance/dart/.dart_tool/pub/bin/test/test.dart-3.12.0.snapshot new file mode 100644 index 0000000..fdcd1b5 Binary files /dev/null and b/packages/finance/dart/.dart_tool/pub/bin/test/test.dart-3.12.0.snapshot differ diff --git a/packages/finance/dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjEx b/packages/finance/dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjEx new file mode 100644 index 0000000..d63d437 Binary files /dev/null and b/packages/finance/dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjEx differ diff --git a/packages/finance/dart/.gitignore b/packages/finance/dart/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/finance/dart/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/finance/dart/CHANGELOG.md b/packages/finance/dart/CHANGELOG.md new file mode 100644 index 0000000..b3f0226 --- /dev/null +++ b/packages/finance/dart/CHANGELOG.md @@ -0,0 +1,26 @@ +# CHANGELOG + +## [0.2.0] - 2026-06-01 + +### Added + +- SourceRecordDto — 原始记录 DTO(@JsonSerializable + @JsonKey snake_case) +- NormalizedRecordDto — 标准化记录 DTO(同上) +- 5 枚举 — SourceType, IngestionStatus, RecordType, Direction, NormalizationStatus(@JsonEnum + @JsonValue 显式映射 + unknown 兜底) +- 枚举 wire-value 对齐测试,与 doc/entities.md 值表一致 + +## [0.1.1] - 2026-05-29 + +### Fixed + +- 添加 LICENSE 文件,满足 pub.dev 发布要求 + +## [0.1.0] - 2026-05-29 + +### Added + +- Journal — 日记账实体(id, name, createdAt) +- JournalEntry — 凭证实体(id, journalId, createdAt, description, lines) +- JournalEntryLine — 分录行(id, type, amount, description, createdAt),支持多行 +- LineType 枚举(debit / credit) +- 基于 freezed 的不可变模型,支持 copyWith 与 JSON 序列化 diff --git a/packages/finance/dart/LICENSE b/packages/finance/dart/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/packages/finance/dart/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/finance/dart/README.md b/packages/finance/dart/README.md new file mode 100644 index 0000000..e27029c --- /dev/null +++ b/packages/finance/dart/README.md @@ -0,0 +1,45 @@ +# quanttide_finance + +量潮财务领域模型包 —— 日记账与凭证的核心实体。 + +## 说明 + +本包是本项目早期发布的 Dart 模型库,提供基于 `freezed` 的不可变财务实体。当前覆盖的是**下游凭证层**(Journal / JournalEntry / JournalEntryLine),并非主干标准化模型。 + +项目主干(SourceRecord → NormalizedRecord → ClassificationResult → Statistics)正在 FastAPI 上构建中,参见根目录 README。 + +## 模型 + +| 实体 | 说明 | +|---|---| +| `Journal` | 日记账(id, name, createdAt) | +| `JournalEntry` | 凭证(id, journalId, createdAt, description, lines) | +| `JournalEntryLine` | 分录行(id, type, amount, description, createdAt) | +| `LineType` | 枚举:`debit` / `credit` | + +所有模型支持 `copyWith`、`toJson` / `fromJson`。 + +## 使用 + +```dart +import 'package:quanttide_finance/quanttide_finance.dart'; + +void main() { + final journal = Journal(id: '1', name: '备用金', createdAt: DateTime.now()); + + final entry = JournalEntry( + id: 'je1', + journalId: journal.id, + createdAt: DateTime.now(), + description: '采购办公用品', + lines: [ + JournalEntryLine(id: 'l1', type: LineType.debit, amount: 1200, createdAt: DateTime.now()), + JournalEntryLine(id: 'l2', type: LineType.credit, amount: 1200, createdAt: DateTime.now()), + ], + ); +} +``` + +## 发布 + +版本 `0.1.1` 已发布到 pub.dev。 diff --git a/packages/finance/dart/analysis_options.yaml b/packages/finance/dart/analysis_options.yaml new file mode 100644 index 0000000..82c7ca7 --- /dev/null +++ b/packages/finance/dart/analysis_options.yaml @@ -0,0 +1,18 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +analyzer: + errors: + invalid_annotation_target: ignore diff --git a/packages/finance/dart/lib/quanttide_finance.dart b/packages/finance/dart/lib/quanttide_finance.dart new file mode 100644 index 0000000..383f49c --- /dev/null +++ b/packages/finance/dart/lib/quanttide_finance.dart @@ -0,0 +1,7 @@ +export 'src/models/journal.dart'; +export 'src/models/journal_entry.dart'; + +export 'src/dto/enums.dart'; +export 'src/dto/source_record.dart'; +export 'src/dto/normalized_record.dart'; +export 'src/dto/classification_result.dart'; diff --git a/packages/finance/dart/lib/src/dto/classification_result.dart b/packages/finance/dart/lib/src/dto/classification_result.dart new file mode 100644 index 0000000..8f96059 --- /dev/null +++ b/packages/finance/dart/lib/src/dto/classification_result.dart @@ -0,0 +1,36 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'enums.dart'; + +part 'classification_result.freezed.dart'; +part 'classification_result.g.dart'; + +@freezed +class ClassificationResultDto with _$ClassificationResultDto { + const factory ClassificationResultDto({ + required int id, + @JsonKey(name: 'normalized_record_id') required int normalizedRecordId, + @Default('expense_type') String taxonomy, + required String category, + @JsonKey(name: 'tags') Map? tags, + @JsonKey( + name: 'classifier_kind', + unknownEnumValue: ClassifierKind.unknown, + ) + required ClassifierKind classifierKind, + @JsonKey(name: 'confidence') double? confidence, + @JsonKey(name: 'model_version') String? modelVersion, + @JsonKey( + name: 'review_status', + unknownEnumValue: ReviewStatus.unknown, + ) + @Default(ReviewStatus.candidate) + ReviewStatus reviewStatus, + @JsonKey(name: 'is_active') @Default(true) bool isActive, + @JsonKey(name: 'created_at') required DateTime createdAt, + @JsonKey(name: 'updated_at') required DateTime updatedAt, + }) = _ClassificationResultDto; + + factory ClassificationResultDto.fromJson(Map json) => + _$ClassificationResultDtoFromJson(json); +} diff --git a/packages/finance/dart/lib/src/dto/classification_result.freezed.dart b/packages/finance/dart/lib/src/dto/classification_result.freezed.dart new file mode 100644 index 0000000..fbeb777 --- /dev/null +++ b/packages/finance/dart/lib/src/dto/classification_result.freezed.dart @@ -0,0 +1,480 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'classification_result.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +ClassificationResultDto _$ClassificationResultDtoFromJson( + Map json, +) { + return _ClassificationResultDto.fromJson(json); +} + +/// @nodoc +mixin _$ClassificationResultDto { + int get id => throw _privateConstructorUsedError; + @JsonKey(name: 'normalized_record_id') + int get normalizedRecordId => throw _privateConstructorUsedError; + String get taxonomy => throw _privateConstructorUsedError; + String get category => throw _privateConstructorUsedError; + @JsonKey(name: 'tags') + Map? get tags => throw _privateConstructorUsedError; + @JsonKey(name: 'classifier_kind', unknownEnumValue: ClassifierKind.unknown) + ClassifierKind get classifierKind => throw _privateConstructorUsedError; + @JsonKey(name: 'confidence') + double? get confidence => throw _privateConstructorUsedError; + @JsonKey(name: 'model_version') + String? get modelVersion => throw _privateConstructorUsedError; + @JsonKey(name: 'review_status', unknownEnumValue: ReviewStatus.unknown) + ReviewStatus get reviewStatus => throw _privateConstructorUsedError; + @JsonKey(name: 'is_active') + bool get isActive => throw _privateConstructorUsedError; + @JsonKey(name: 'created_at') + DateTime get createdAt => throw _privateConstructorUsedError; + @JsonKey(name: 'updated_at') + DateTime get updatedAt => throw _privateConstructorUsedError; + + /// Serializes this ClassificationResultDto to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ClassificationResultDto + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ClassificationResultDtoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ClassificationResultDtoCopyWith<$Res> { + factory $ClassificationResultDtoCopyWith( + ClassificationResultDto value, + $Res Function(ClassificationResultDto) then, + ) = _$ClassificationResultDtoCopyWithImpl<$Res, ClassificationResultDto>; + @useResult + $Res call({ + int id, + @JsonKey(name: 'normalized_record_id') int normalizedRecordId, + String taxonomy, + String category, + @JsonKey(name: 'tags') Map? tags, + @JsonKey(name: 'classifier_kind', unknownEnumValue: ClassifierKind.unknown) + ClassifierKind classifierKind, + @JsonKey(name: 'confidence') double? confidence, + @JsonKey(name: 'model_version') String? modelVersion, + @JsonKey(name: 'review_status', unknownEnumValue: ReviewStatus.unknown) + ReviewStatus reviewStatus, + @JsonKey(name: 'is_active') bool isActive, + @JsonKey(name: 'created_at') DateTime createdAt, + @JsonKey(name: 'updated_at') DateTime updatedAt, + }); +} + +/// @nodoc +class _$ClassificationResultDtoCopyWithImpl< + $Res, + $Val extends ClassificationResultDto +> + implements $ClassificationResultDtoCopyWith<$Res> { + _$ClassificationResultDtoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ClassificationResultDto + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? normalizedRecordId = null, + Object? taxonomy = null, + Object? category = null, + Object? tags = freezed, + Object? classifierKind = null, + Object? confidence = freezed, + Object? modelVersion = freezed, + Object? reviewStatus = null, + Object? isActive = null, + Object? createdAt = null, + Object? updatedAt = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + normalizedRecordId: null == normalizedRecordId + ? _value.normalizedRecordId + : normalizedRecordId // ignore: cast_nullable_to_non_nullable + as int, + taxonomy: null == taxonomy + ? _value.taxonomy + : taxonomy // ignore: cast_nullable_to_non_nullable + as String, + category: null == category + ? _value.category + : category // ignore: cast_nullable_to_non_nullable + as String, + tags: freezed == tags + ? _value.tags + : tags // ignore: cast_nullable_to_non_nullable + as Map?, + classifierKind: null == classifierKind + ? _value.classifierKind + : classifierKind // ignore: cast_nullable_to_non_nullable + as ClassifierKind, + confidence: freezed == confidence + ? _value.confidence + : confidence // ignore: cast_nullable_to_non_nullable + as double?, + modelVersion: freezed == modelVersion + ? _value.modelVersion + : modelVersion // ignore: cast_nullable_to_non_nullable + as String?, + reviewStatus: null == reviewStatus + ? _value.reviewStatus + : reviewStatus // ignore: cast_nullable_to_non_nullable + as ReviewStatus, + isActive: null == isActive + ? _value.isActive + : isActive // ignore: cast_nullable_to_non_nullable + as bool, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ClassificationResultDtoImplCopyWith<$Res> + implements $ClassificationResultDtoCopyWith<$Res> { + factory _$$ClassificationResultDtoImplCopyWith( + _$ClassificationResultDtoImpl value, + $Res Function(_$ClassificationResultDtoImpl) then, + ) = __$$ClassificationResultDtoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int id, + @JsonKey(name: 'normalized_record_id') int normalizedRecordId, + String taxonomy, + String category, + @JsonKey(name: 'tags') Map? tags, + @JsonKey(name: 'classifier_kind', unknownEnumValue: ClassifierKind.unknown) + ClassifierKind classifierKind, + @JsonKey(name: 'confidence') double? confidence, + @JsonKey(name: 'model_version') String? modelVersion, + @JsonKey(name: 'review_status', unknownEnumValue: ReviewStatus.unknown) + ReviewStatus reviewStatus, + @JsonKey(name: 'is_active') bool isActive, + @JsonKey(name: 'created_at') DateTime createdAt, + @JsonKey(name: 'updated_at') DateTime updatedAt, + }); +} + +/// @nodoc +class __$$ClassificationResultDtoImplCopyWithImpl<$Res> + extends + _$ClassificationResultDtoCopyWithImpl< + $Res, + _$ClassificationResultDtoImpl + > + implements _$$ClassificationResultDtoImplCopyWith<$Res> { + __$$ClassificationResultDtoImplCopyWithImpl( + _$ClassificationResultDtoImpl _value, + $Res Function(_$ClassificationResultDtoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ClassificationResultDto + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? normalizedRecordId = null, + Object? taxonomy = null, + Object? category = null, + Object? tags = freezed, + Object? classifierKind = null, + Object? confidence = freezed, + Object? modelVersion = freezed, + Object? reviewStatus = null, + Object? isActive = null, + Object? createdAt = null, + Object? updatedAt = null, + }) { + return _then( + _$ClassificationResultDtoImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + normalizedRecordId: null == normalizedRecordId + ? _value.normalizedRecordId + : normalizedRecordId // ignore: cast_nullable_to_non_nullable + as int, + taxonomy: null == taxonomy + ? _value.taxonomy + : taxonomy // ignore: cast_nullable_to_non_nullable + as String, + category: null == category + ? _value.category + : category // ignore: cast_nullable_to_non_nullable + as String, + tags: freezed == tags + ? _value._tags + : tags // ignore: cast_nullable_to_non_nullable + as Map?, + classifierKind: null == classifierKind + ? _value.classifierKind + : classifierKind // ignore: cast_nullable_to_non_nullable + as ClassifierKind, + confidence: freezed == confidence + ? _value.confidence + : confidence // ignore: cast_nullable_to_non_nullable + as double?, + modelVersion: freezed == modelVersion + ? _value.modelVersion + : modelVersion // ignore: cast_nullable_to_non_nullable + as String?, + reviewStatus: null == reviewStatus + ? _value.reviewStatus + : reviewStatus // ignore: cast_nullable_to_non_nullable + as ReviewStatus, + isActive: null == isActive + ? _value.isActive + : isActive // ignore: cast_nullable_to_non_nullable + as bool, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ClassificationResultDtoImpl implements _ClassificationResultDto { + const _$ClassificationResultDtoImpl({ + required this.id, + @JsonKey(name: 'normalized_record_id') required this.normalizedRecordId, + this.taxonomy = 'expense_type', + required this.category, + @JsonKey(name: 'tags') final Map? tags, + @JsonKey(name: 'classifier_kind', unknownEnumValue: ClassifierKind.unknown) + required this.classifierKind, + @JsonKey(name: 'confidence') this.confidence, + @JsonKey(name: 'model_version') this.modelVersion, + @JsonKey(name: 'review_status', unknownEnumValue: ReviewStatus.unknown) + this.reviewStatus = ReviewStatus.candidate, + @JsonKey(name: 'is_active') this.isActive = true, + @JsonKey(name: 'created_at') required this.createdAt, + @JsonKey(name: 'updated_at') required this.updatedAt, + }) : _tags = tags; + + factory _$ClassificationResultDtoImpl.fromJson(Map json) => + _$$ClassificationResultDtoImplFromJson(json); + + @override + final int id; + @override + @JsonKey(name: 'normalized_record_id') + final int normalizedRecordId; + @override + @JsonKey() + final String taxonomy; + @override + final String category; + final Map? _tags; + @override + @JsonKey(name: 'tags') + Map? get tags { + final value = _tags; + if (value == null) return null; + if (_tags is EqualUnmodifiableMapView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + @JsonKey(name: 'classifier_kind', unknownEnumValue: ClassifierKind.unknown) + final ClassifierKind classifierKind; + @override + @JsonKey(name: 'confidence') + final double? confidence; + @override + @JsonKey(name: 'model_version') + final String? modelVersion; + @override + @JsonKey(name: 'review_status', unknownEnumValue: ReviewStatus.unknown) + final ReviewStatus reviewStatus; + @override + @JsonKey(name: 'is_active') + final bool isActive; + @override + @JsonKey(name: 'created_at') + final DateTime createdAt; + @override + @JsonKey(name: 'updated_at') + final DateTime updatedAt; + + @override + String toString() { + return 'ClassificationResultDto(id: $id, normalizedRecordId: $normalizedRecordId, taxonomy: $taxonomy, category: $category, tags: $tags, classifierKind: $classifierKind, confidence: $confidence, modelVersion: $modelVersion, reviewStatus: $reviewStatus, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ClassificationResultDtoImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.normalizedRecordId, normalizedRecordId) || + other.normalizedRecordId == normalizedRecordId) && + (identical(other.taxonomy, taxonomy) || + other.taxonomy == taxonomy) && + (identical(other.category, category) || + other.category == category) && + const DeepCollectionEquality().equals(other._tags, _tags) && + (identical(other.classifierKind, classifierKind) || + other.classifierKind == classifierKind) && + (identical(other.confidence, confidence) || + other.confidence == confidence) && + (identical(other.modelVersion, modelVersion) || + other.modelVersion == modelVersion) && + (identical(other.reviewStatus, reviewStatus) || + other.reviewStatus == reviewStatus) && + (identical(other.isActive, isActive) || + other.isActive == isActive) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + normalizedRecordId, + taxonomy, + category, + const DeepCollectionEquality().hash(_tags), + classifierKind, + confidence, + modelVersion, + reviewStatus, + isActive, + createdAt, + updatedAt, + ); + + /// Create a copy of ClassificationResultDto + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ClassificationResultDtoImplCopyWith<_$ClassificationResultDtoImpl> + get copyWith => + __$$ClassificationResultDtoImplCopyWithImpl< + _$ClassificationResultDtoImpl + >(this, _$identity); + + @override + Map toJson() { + return _$$ClassificationResultDtoImplToJson(this); + } +} + +abstract class _ClassificationResultDto implements ClassificationResultDto { + const factory _ClassificationResultDto({ + required final int id, + @JsonKey(name: 'normalized_record_id') + required final int normalizedRecordId, + final String taxonomy, + required final String category, + @JsonKey(name: 'tags') final Map? tags, + @JsonKey(name: 'classifier_kind', unknownEnumValue: ClassifierKind.unknown) + required final ClassifierKind classifierKind, + @JsonKey(name: 'confidence') final double? confidence, + @JsonKey(name: 'model_version') final String? modelVersion, + @JsonKey(name: 'review_status', unknownEnumValue: ReviewStatus.unknown) + final ReviewStatus reviewStatus, + @JsonKey(name: 'is_active') final bool isActive, + @JsonKey(name: 'created_at') required final DateTime createdAt, + @JsonKey(name: 'updated_at') required final DateTime updatedAt, + }) = _$ClassificationResultDtoImpl; + + factory _ClassificationResultDto.fromJson(Map json) = + _$ClassificationResultDtoImpl.fromJson; + + @override + int get id; + @override + @JsonKey(name: 'normalized_record_id') + int get normalizedRecordId; + @override + String get taxonomy; + @override + String get category; + @override + @JsonKey(name: 'tags') + Map? get tags; + @override + @JsonKey(name: 'classifier_kind', unknownEnumValue: ClassifierKind.unknown) + ClassifierKind get classifierKind; + @override + @JsonKey(name: 'confidence') + double? get confidence; + @override + @JsonKey(name: 'model_version') + String? get modelVersion; + @override + @JsonKey(name: 'review_status', unknownEnumValue: ReviewStatus.unknown) + ReviewStatus get reviewStatus; + @override + @JsonKey(name: 'is_active') + bool get isActive; + @override + @JsonKey(name: 'created_at') + DateTime get createdAt; + @override + @JsonKey(name: 'updated_at') + DateTime get updatedAt; + + /// Create a copy of ClassificationResultDto + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ClassificationResultDtoImplCopyWith<_$ClassificationResultDtoImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/packages/finance/dart/lib/src/dto/classification_result.g.dart b/packages/finance/dart/lib/src/dto/classification_result.g.dart new file mode 100644 index 0000000..b6fca8e --- /dev/null +++ b/packages/finance/dart/lib/src/dto/classification_result.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'classification_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ClassificationResultDtoImpl _$$ClassificationResultDtoImplFromJson( + Map json, +) => _$ClassificationResultDtoImpl( + id: (json['id'] as num).toInt(), + normalizedRecordId: (json['normalized_record_id'] as num).toInt(), + taxonomy: json['taxonomy'] as String? ?? 'expense_type', + category: json['category'] as String, + tags: json['tags'] as Map?, + classifierKind: $enumDecode( + _$ClassifierKindEnumMap, + json['classifier_kind'], + unknownValue: ClassifierKind.unknown, + ), + confidence: (json['confidence'] as num?)?.toDouble(), + modelVersion: json['model_version'] as String?, + reviewStatus: + $enumDecodeNullable( + _$ReviewStatusEnumMap, + json['review_status'], + unknownValue: ReviewStatus.unknown, + ) ?? + ReviewStatus.candidate, + isActive: json['is_active'] as bool? ?? true, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), +); + +Map _$$ClassificationResultDtoImplToJson( + _$ClassificationResultDtoImpl instance, +) => { + 'id': instance.id, + 'normalized_record_id': instance.normalizedRecordId, + 'taxonomy': instance.taxonomy, + 'category': instance.category, + 'tags': instance.tags, + 'classifier_kind': _$ClassifierKindEnumMap[instance.classifierKind]!, + 'confidence': instance.confidence, + 'model_version': instance.modelVersion, + 'review_status': _$ReviewStatusEnumMap[instance.reviewStatus]!, + 'is_active': instance.isActive, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), +}; + +const _$ClassifierKindEnumMap = { + ClassifierKind.ai: 'ai', + ClassifierKind.rule: 'rule', + ClassifierKind.manual: 'manual', + ClassifierKind.unknown: '__unknown__', +}; + +const _$ReviewStatusEnumMap = { + ReviewStatus.candidate: 'candidate', + ReviewStatus.accepted: 'accepted', + ReviewStatus.rejected: 'rejected', + ReviewStatus.unknown: '__unknown__', +}; diff --git a/packages/finance/dart/lib/src/dto/enums.dart b/packages/finance/dart/lib/src/dto/enums.dart new file mode 100644 index 0000000..d119435 --- /dev/null +++ b/packages/finance/dart/lib/src/dto/enums.dart @@ -0,0 +1,104 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +@JsonEnum() +enum SourceType { + @JsonValue('image') + image, + @JsonValue('chat') + chat, + @JsonValue('form') + form, + @JsonValue('csv_row') + csvRow, + @JsonValue('bank_tx') + bankTx, + @JsonValue('api') + api, + @JsonValue('manual') + manual, + @JsonValue('other') + other, + + /// Fallback for unrecognized wire values — triggered by @JsonKey(unknownEnumValue:) + /// on DTO fields, not directly from this enum. + @JsonValue('__unknown__') + unknown, +} + +@JsonEnum() +enum IngestionStatus { + @JsonValue('pending') + pending, + @JsonValue('parsed') + parsed, + @JsonValue('reviewed') + reviewed, + @JsonValue('failed') + failed, + @JsonValue('__unknown__') + unknown, +} + +@JsonEnum() +enum RecordType { + @JsonValue('expense') + expense, + @JsonValue('income') + income, + @JsonValue('transfer') + transfer, + @JsonValue('reimbursement') + reimbursement, + @JsonValue('other') + other, + @JsonValue('__unknown__') + unknown, +} + +@JsonEnum() +enum Direction { + @JsonValue('outflow') + outflow, + @JsonValue('inflow') + inflow, + @JsonValue('__unknown__') + unknown, +} + +@JsonEnum() +enum NormalizationStatus { + @JsonValue('draft') + draft, + @JsonValue('normalized') + normalized, + @JsonValue('reviewed') + reviewed, + @JsonValue('merged') + merged, + @JsonValue('__unknown__') + unknown, +} + +@JsonEnum() +enum ClassifierKind { + @JsonValue('ai') + ai, + @JsonValue('rule') + rule, + @JsonValue('manual') + manual, + @JsonValue('__unknown__') + unknown, +} + +@JsonEnum() +enum ReviewStatus { + @JsonValue('candidate') + candidate, + @JsonValue('accepted') + accepted, + @JsonValue('rejected') + rejected, + @JsonValue('__unknown__') + unknown, +} diff --git a/packages/finance/dart/lib/src/dto/normalized_record.dart b/packages/finance/dart/lib/src/dto/normalized_record.dart new file mode 100644 index 0000000..d677f30 --- /dev/null +++ b/packages/finance/dart/lib/src/dto/normalized_record.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'enums.dart'; + +part 'normalized_record.freezed.dart'; +part 'normalized_record.g.dart'; + +@freezed +class NormalizedRecordDto with _$NormalizedRecordDto { + const factory NormalizedRecordDto({ + required int id, + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + required RecordType recordType, + @JsonKey(name: 'business_date') @Default('') String businessDate, + @JsonKey(name: 'amount_cents') @Default(0) int amountCents, + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + required Direction direction, + @JsonKey(name: 'department') String? department, + @JsonKey(name: 'person') String? person, + @JsonKey(name: 'description') @Default('') String description, + @JsonKey(name: 'created_at') required DateTime createdAt, + }) = _NormalizedRecordDto; + + factory NormalizedRecordDto.fromJson(Map json) => + _$NormalizedRecordDtoFromJson(json); +} diff --git a/packages/finance/dart/lib/src/dto/normalized_record.freezed.dart b/packages/finance/dart/lib/src/dto/normalized_record.freezed.dart new file mode 100644 index 0000000..db9972e --- /dev/null +++ b/packages/finance/dart/lib/src/dto/normalized_record.freezed.dart @@ -0,0 +1,392 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'normalized_record.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +NormalizedRecordDto _$NormalizedRecordDtoFromJson(Map json) { + return _NormalizedRecordDto.fromJson(json); +} + +/// @nodoc +mixin _$NormalizedRecordDto { + int get id => throw _privateConstructorUsedError; + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + RecordType get recordType => throw _privateConstructorUsedError; + @JsonKey(name: 'business_date') + String get businessDate => throw _privateConstructorUsedError; + @JsonKey(name: 'amount_cents') + int get amountCents => throw _privateConstructorUsedError; + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + Direction get direction => throw _privateConstructorUsedError; + @JsonKey(name: 'department') + String? get department => throw _privateConstructorUsedError; + @JsonKey(name: 'person') + String? get person => throw _privateConstructorUsedError; + @JsonKey(name: 'description') + String get description => throw _privateConstructorUsedError; + @JsonKey(name: 'created_at') + DateTime get createdAt => throw _privateConstructorUsedError; + + /// Serializes this NormalizedRecordDto to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of NormalizedRecordDto + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $NormalizedRecordDtoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $NormalizedRecordDtoCopyWith<$Res> { + factory $NormalizedRecordDtoCopyWith( + NormalizedRecordDto value, + $Res Function(NormalizedRecordDto) then, + ) = _$NormalizedRecordDtoCopyWithImpl<$Res, NormalizedRecordDto>; + @useResult + $Res call({ + int id, + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + RecordType recordType, + @JsonKey(name: 'business_date') String businessDate, + @JsonKey(name: 'amount_cents') int amountCents, + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + Direction direction, + @JsonKey(name: 'department') String? department, + @JsonKey(name: 'person') String? person, + @JsonKey(name: 'description') String description, + @JsonKey(name: 'created_at') DateTime createdAt, + }); +} + +/// @nodoc +class _$NormalizedRecordDtoCopyWithImpl<$Res, $Val extends NormalizedRecordDto> + implements $NormalizedRecordDtoCopyWith<$Res> { + _$NormalizedRecordDtoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of NormalizedRecordDto + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? recordType = null, + Object? businessDate = null, + Object? amountCents = null, + Object? direction = null, + Object? department = freezed, + Object? person = freezed, + Object? description = null, + Object? createdAt = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + recordType: null == recordType + ? _value.recordType + : recordType // ignore: cast_nullable_to_non_nullable + as RecordType, + businessDate: null == businessDate + ? _value.businessDate + : businessDate // ignore: cast_nullable_to_non_nullable + as String, + amountCents: null == amountCents + ? _value.amountCents + : amountCents // ignore: cast_nullable_to_non_nullable + as int, + direction: null == direction + ? _value.direction + : direction // ignore: cast_nullable_to_non_nullable + as Direction, + department: freezed == department + ? _value.department + : department // ignore: cast_nullable_to_non_nullable + as String?, + person: freezed == person + ? _value.person + : person // ignore: cast_nullable_to_non_nullable + as String?, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$NormalizedRecordDtoImplCopyWith<$Res> + implements $NormalizedRecordDtoCopyWith<$Res> { + factory _$$NormalizedRecordDtoImplCopyWith( + _$NormalizedRecordDtoImpl value, + $Res Function(_$NormalizedRecordDtoImpl) then, + ) = __$$NormalizedRecordDtoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int id, + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + RecordType recordType, + @JsonKey(name: 'business_date') String businessDate, + @JsonKey(name: 'amount_cents') int amountCents, + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + Direction direction, + @JsonKey(name: 'department') String? department, + @JsonKey(name: 'person') String? person, + @JsonKey(name: 'description') String description, + @JsonKey(name: 'created_at') DateTime createdAt, + }); +} + +/// @nodoc +class __$$NormalizedRecordDtoImplCopyWithImpl<$Res> + extends _$NormalizedRecordDtoCopyWithImpl<$Res, _$NormalizedRecordDtoImpl> + implements _$$NormalizedRecordDtoImplCopyWith<$Res> { + __$$NormalizedRecordDtoImplCopyWithImpl( + _$NormalizedRecordDtoImpl _value, + $Res Function(_$NormalizedRecordDtoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of NormalizedRecordDto + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? recordType = null, + Object? businessDate = null, + Object? amountCents = null, + Object? direction = null, + Object? department = freezed, + Object? person = freezed, + Object? description = null, + Object? createdAt = null, + }) { + return _then( + _$NormalizedRecordDtoImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + recordType: null == recordType + ? _value.recordType + : recordType // ignore: cast_nullable_to_non_nullable + as RecordType, + businessDate: null == businessDate + ? _value.businessDate + : businessDate // ignore: cast_nullable_to_non_nullable + as String, + amountCents: null == amountCents + ? _value.amountCents + : amountCents // ignore: cast_nullable_to_non_nullable + as int, + direction: null == direction + ? _value.direction + : direction // ignore: cast_nullable_to_non_nullable + as Direction, + department: freezed == department + ? _value.department + : department // ignore: cast_nullable_to_non_nullable + as String?, + person: freezed == person + ? _value.person + : person // ignore: cast_nullable_to_non_nullable + as String?, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$NormalizedRecordDtoImpl implements _NormalizedRecordDto { + const _$NormalizedRecordDtoImpl({ + required this.id, + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + required this.recordType, + @JsonKey(name: 'business_date') this.businessDate = '', + @JsonKey(name: 'amount_cents') this.amountCents = 0, + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + required this.direction, + @JsonKey(name: 'department') this.department, + @JsonKey(name: 'person') this.person, + @JsonKey(name: 'description') this.description = '', + @JsonKey(name: 'created_at') required this.createdAt, + }); + + factory _$NormalizedRecordDtoImpl.fromJson(Map json) => + _$$NormalizedRecordDtoImplFromJson(json); + + @override + final int id; + @override + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + final RecordType recordType; + @override + @JsonKey(name: 'business_date') + final String businessDate; + @override + @JsonKey(name: 'amount_cents') + final int amountCents; + @override + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + final Direction direction; + @override + @JsonKey(name: 'department') + final String? department; + @override + @JsonKey(name: 'person') + final String? person; + @override + @JsonKey(name: 'description') + final String description; + @override + @JsonKey(name: 'created_at') + final DateTime createdAt; + + @override + String toString() { + return 'NormalizedRecordDto(id: $id, recordType: $recordType, businessDate: $businessDate, amountCents: $amountCents, direction: $direction, department: $department, person: $person, description: $description, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NormalizedRecordDtoImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.recordType, recordType) || + other.recordType == recordType) && + (identical(other.businessDate, businessDate) || + other.businessDate == businessDate) && + (identical(other.amountCents, amountCents) || + other.amountCents == amountCents) && + (identical(other.direction, direction) || + other.direction == direction) && + (identical(other.department, department) || + other.department == department) && + (identical(other.person, person) || other.person == person) && + (identical(other.description, description) || + other.description == description) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + recordType, + businessDate, + amountCents, + direction, + department, + person, + description, + createdAt, + ); + + /// Create a copy of NormalizedRecordDto + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NormalizedRecordDtoImplCopyWith<_$NormalizedRecordDtoImpl> get copyWith => + __$$NormalizedRecordDtoImplCopyWithImpl<_$NormalizedRecordDtoImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$NormalizedRecordDtoImplToJson(this); + } +} + +abstract class _NormalizedRecordDto implements NormalizedRecordDto { + const factory _NormalizedRecordDto({ + required final int id, + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + required final RecordType recordType, + @JsonKey(name: 'business_date') final String businessDate, + @JsonKey(name: 'amount_cents') final int amountCents, + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + required final Direction direction, + @JsonKey(name: 'department') final String? department, + @JsonKey(name: 'person') final String? person, + @JsonKey(name: 'description') final String description, + @JsonKey(name: 'created_at') required final DateTime createdAt, + }) = _$NormalizedRecordDtoImpl; + + factory _NormalizedRecordDto.fromJson(Map json) = + _$NormalizedRecordDtoImpl.fromJson; + + @override + int get id; + @override + @JsonKey(name: 'record_type', unknownEnumValue: RecordType.unknown) + RecordType get recordType; + @override + @JsonKey(name: 'business_date') + String get businessDate; + @override + @JsonKey(name: 'amount_cents') + int get amountCents; + @override + @JsonKey(name: 'direction', unknownEnumValue: Direction.unknown) + Direction get direction; + @override + @JsonKey(name: 'department') + String? get department; + @override + @JsonKey(name: 'person') + String? get person; + @override + @JsonKey(name: 'description') + String get description; + @override + @JsonKey(name: 'created_at') + DateTime get createdAt; + + /// Create a copy of NormalizedRecordDto + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NormalizedRecordDtoImplCopyWith<_$NormalizedRecordDtoImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/finance/dart/lib/src/dto/normalized_record.g.dart b/packages/finance/dart/lib/src/dto/normalized_record.g.dart new file mode 100644 index 0000000..74d18bc --- /dev/null +++ b/packages/finance/dart/lib/src/dto/normalized_record.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'normalized_record.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$NormalizedRecordDtoImpl _$$NormalizedRecordDtoImplFromJson( + Map json, +) => _$NormalizedRecordDtoImpl( + id: (json['id'] as num).toInt(), + recordType: $enumDecode( + _$RecordTypeEnumMap, + json['record_type'], + unknownValue: RecordType.unknown, + ), + businessDate: json['business_date'] as String? ?? '', + amountCents: (json['amount_cents'] as num?)?.toInt() ?? 0, + direction: $enumDecode( + _$DirectionEnumMap, + json['direction'], + unknownValue: Direction.unknown, + ), + department: json['department'] as String?, + person: json['person'] as String?, + description: json['description'] as String? ?? '', + createdAt: DateTime.parse(json['created_at'] as String), +); + +Map _$$NormalizedRecordDtoImplToJson( + _$NormalizedRecordDtoImpl instance, +) => { + 'id': instance.id, + 'record_type': _$RecordTypeEnumMap[instance.recordType]!, + 'business_date': instance.businessDate, + 'amount_cents': instance.amountCents, + 'direction': _$DirectionEnumMap[instance.direction]!, + 'department': instance.department, + 'person': instance.person, + 'description': instance.description, + 'created_at': instance.createdAt.toIso8601String(), +}; + +const _$RecordTypeEnumMap = { + RecordType.expense: 'expense', + RecordType.income: 'income', + RecordType.transfer: 'transfer', + RecordType.reimbursement: 'reimbursement', + RecordType.other: 'other', + RecordType.unknown: '__unknown__', +}; + +const _$DirectionEnumMap = { + Direction.outflow: 'outflow', + Direction.inflow: 'inflow', + Direction.unknown: '__unknown__', +}; diff --git a/packages/finance/dart/lib/src/dto/source_record.dart b/packages/finance/dart/lib/src/dto/source_record.dart new file mode 100644 index 0000000..e0f4e72 --- /dev/null +++ b/packages/finance/dart/lib/src/dto/source_record.dart @@ -0,0 +1,27 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'enums.dart'; + +part 'source_record.freezed.dart'; +part 'source_record.g.dart'; + +@freezed +class SourceRecordDto with _$SourceRecordDto { + const factory SourceRecordDto({ + required int id, + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + required SourceType sourceType, + @JsonKey(name: 'raw_text') @Default('') String rawText, + @JsonKey(name: 'occurred_at') DateTime? occurredAt, + @JsonKey( + name: 'ingestion_status', + unknownEnumValue: IngestionStatus.unknown, + ) + @Default(IngestionStatus.pending) + IngestionStatus ingestionStatus, + @JsonKey(name: 'created_at') required DateTime createdAt, + }) = _SourceRecordDto; + + factory SourceRecordDto.fromJson(Map json) => + _$SourceRecordDtoFromJson(json); +} diff --git a/packages/finance/dart/lib/src/dto/source_record.freezed.dart b/packages/finance/dart/lib/src/dto/source_record.freezed.dart new file mode 100644 index 0000000..eb88191 --- /dev/null +++ b/packages/finance/dart/lib/src/dto/source_record.freezed.dart @@ -0,0 +1,329 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'source_record.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +SourceRecordDto _$SourceRecordDtoFromJson(Map json) { + return _SourceRecordDto.fromJson(json); +} + +/// @nodoc +mixin _$SourceRecordDto { + int get id => throw _privateConstructorUsedError; + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + SourceType get sourceType => throw _privateConstructorUsedError; + @JsonKey(name: 'raw_text') + String get rawText => throw _privateConstructorUsedError; + @JsonKey(name: 'occurred_at') + DateTime? get occurredAt => throw _privateConstructorUsedError; + @JsonKey(name: 'ingestion_status', unknownEnumValue: IngestionStatus.unknown) + IngestionStatus get ingestionStatus => throw _privateConstructorUsedError; + @JsonKey(name: 'created_at') + DateTime get createdAt => throw _privateConstructorUsedError; + + /// Serializes this SourceRecordDto to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SourceRecordDto + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SourceRecordDtoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SourceRecordDtoCopyWith<$Res> { + factory $SourceRecordDtoCopyWith( + SourceRecordDto value, + $Res Function(SourceRecordDto) then, + ) = _$SourceRecordDtoCopyWithImpl<$Res, SourceRecordDto>; + @useResult + $Res call({ + int id, + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + SourceType sourceType, + @JsonKey(name: 'raw_text') String rawText, + @JsonKey(name: 'occurred_at') DateTime? occurredAt, + @JsonKey( + name: 'ingestion_status', + unknownEnumValue: IngestionStatus.unknown, + ) + IngestionStatus ingestionStatus, + @JsonKey(name: 'created_at') DateTime createdAt, + }); +} + +/// @nodoc +class _$SourceRecordDtoCopyWithImpl<$Res, $Val extends SourceRecordDto> + implements $SourceRecordDtoCopyWith<$Res> { + _$SourceRecordDtoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SourceRecordDto + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sourceType = null, + Object? rawText = null, + Object? occurredAt = freezed, + Object? ingestionStatus = null, + Object? createdAt = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + sourceType: null == sourceType + ? _value.sourceType + : sourceType // ignore: cast_nullable_to_non_nullable + as SourceType, + rawText: null == rawText + ? _value.rawText + : rawText // ignore: cast_nullable_to_non_nullable + as String, + occurredAt: freezed == occurredAt + ? _value.occurredAt + : occurredAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ingestionStatus: null == ingestionStatus + ? _value.ingestionStatus + : ingestionStatus // ignore: cast_nullable_to_non_nullable + as IngestionStatus, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$SourceRecordDtoImplCopyWith<$Res> + implements $SourceRecordDtoCopyWith<$Res> { + factory _$$SourceRecordDtoImplCopyWith( + _$SourceRecordDtoImpl value, + $Res Function(_$SourceRecordDtoImpl) then, + ) = __$$SourceRecordDtoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int id, + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + SourceType sourceType, + @JsonKey(name: 'raw_text') String rawText, + @JsonKey(name: 'occurred_at') DateTime? occurredAt, + @JsonKey( + name: 'ingestion_status', + unknownEnumValue: IngestionStatus.unknown, + ) + IngestionStatus ingestionStatus, + @JsonKey(name: 'created_at') DateTime createdAt, + }); +} + +/// @nodoc +class __$$SourceRecordDtoImplCopyWithImpl<$Res> + extends _$SourceRecordDtoCopyWithImpl<$Res, _$SourceRecordDtoImpl> + implements _$$SourceRecordDtoImplCopyWith<$Res> { + __$$SourceRecordDtoImplCopyWithImpl( + _$SourceRecordDtoImpl _value, + $Res Function(_$SourceRecordDtoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of SourceRecordDto + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sourceType = null, + Object? rawText = null, + Object? occurredAt = freezed, + Object? ingestionStatus = null, + Object? createdAt = null, + }) { + return _then( + _$SourceRecordDtoImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + sourceType: null == sourceType + ? _value.sourceType + : sourceType // ignore: cast_nullable_to_non_nullable + as SourceType, + rawText: null == rawText + ? _value.rawText + : rawText // ignore: cast_nullable_to_non_nullable + as String, + occurredAt: freezed == occurredAt + ? _value.occurredAt + : occurredAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ingestionStatus: null == ingestionStatus + ? _value.ingestionStatus + : ingestionStatus // ignore: cast_nullable_to_non_nullable + as IngestionStatus, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$SourceRecordDtoImpl implements _SourceRecordDto { + const _$SourceRecordDtoImpl({ + required this.id, + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + required this.sourceType, + @JsonKey(name: 'raw_text') this.rawText = '', + @JsonKey(name: 'occurred_at') this.occurredAt, + @JsonKey( + name: 'ingestion_status', + unknownEnumValue: IngestionStatus.unknown, + ) + this.ingestionStatus = IngestionStatus.pending, + @JsonKey(name: 'created_at') required this.createdAt, + }); + + factory _$SourceRecordDtoImpl.fromJson(Map json) => + _$$SourceRecordDtoImplFromJson(json); + + @override + final int id; + @override + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + final SourceType sourceType; + @override + @JsonKey(name: 'raw_text') + final String rawText; + @override + @JsonKey(name: 'occurred_at') + final DateTime? occurredAt; + @override + @JsonKey(name: 'ingestion_status', unknownEnumValue: IngestionStatus.unknown) + final IngestionStatus ingestionStatus; + @override + @JsonKey(name: 'created_at') + final DateTime createdAt; + + @override + String toString() { + return 'SourceRecordDto(id: $id, sourceType: $sourceType, rawText: $rawText, occurredAt: $occurredAt, ingestionStatus: $ingestionStatus, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SourceRecordDtoImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.sourceType, sourceType) || + other.sourceType == sourceType) && + (identical(other.rawText, rawText) || other.rawText == rawText) && + (identical(other.occurredAt, occurredAt) || + other.occurredAt == occurredAt) && + (identical(other.ingestionStatus, ingestionStatus) || + other.ingestionStatus == ingestionStatus) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + sourceType, + rawText, + occurredAt, + ingestionStatus, + createdAt, + ); + + /// Create a copy of SourceRecordDto + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SourceRecordDtoImplCopyWith<_$SourceRecordDtoImpl> get copyWith => + __$$SourceRecordDtoImplCopyWithImpl<_$SourceRecordDtoImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$SourceRecordDtoImplToJson(this); + } +} + +abstract class _SourceRecordDto implements SourceRecordDto { + const factory _SourceRecordDto({ + required final int id, + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + required final SourceType sourceType, + @JsonKey(name: 'raw_text') final String rawText, + @JsonKey(name: 'occurred_at') final DateTime? occurredAt, + @JsonKey( + name: 'ingestion_status', + unknownEnumValue: IngestionStatus.unknown, + ) + final IngestionStatus ingestionStatus, + @JsonKey(name: 'created_at') required final DateTime createdAt, + }) = _$SourceRecordDtoImpl; + + factory _SourceRecordDto.fromJson(Map json) = + _$SourceRecordDtoImpl.fromJson; + + @override + int get id; + @override + @JsonKey(name: 'source_type', unknownEnumValue: SourceType.unknown) + SourceType get sourceType; + @override + @JsonKey(name: 'raw_text') + String get rawText; + @override + @JsonKey(name: 'occurred_at') + DateTime? get occurredAt; + @override + @JsonKey(name: 'ingestion_status', unknownEnumValue: IngestionStatus.unknown) + IngestionStatus get ingestionStatus; + @override + @JsonKey(name: 'created_at') + DateTime get createdAt; + + /// Create a copy of SourceRecordDto + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SourceRecordDtoImplCopyWith<_$SourceRecordDtoImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/finance/dart/lib/src/dto/source_record.g.dart b/packages/finance/dart/lib/src/dto/source_record.g.dart new file mode 100644 index 0000000..5ab93af --- /dev/null +++ b/packages/finance/dart/lib/src/dto/source_record.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'source_record.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SourceRecordDtoImpl _$$SourceRecordDtoImplFromJson( + Map json, +) => _$SourceRecordDtoImpl( + id: (json['id'] as num).toInt(), + sourceType: $enumDecode( + _$SourceTypeEnumMap, + json['source_type'], + unknownValue: SourceType.unknown, + ), + rawText: json['raw_text'] as String? ?? '', + occurredAt: json['occurred_at'] == null + ? null + : DateTime.parse(json['occurred_at'] as String), + ingestionStatus: + $enumDecodeNullable( + _$IngestionStatusEnumMap, + json['ingestion_status'], + unknownValue: IngestionStatus.unknown, + ) ?? + IngestionStatus.pending, + createdAt: DateTime.parse(json['created_at'] as String), +); + +Map _$$SourceRecordDtoImplToJson( + _$SourceRecordDtoImpl instance, +) => { + 'id': instance.id, + 'source_type': _$SourceTypeEnumMap[instance.sourceType]!, + 'raw_text': instance.rawText, + 'occurred_at': instance.occurredAt?.toIso8601String(), + 'ingestion_status': _$IngestionStatusEnumMap[instance.ingestionStatus]!, + 'created_at': instance.createdAt.toIso8601String(), +}; + +const _$SourceTypeEnumMap = { + SourceType.image: 'image', + SourceType.chat: 'chat', + SourceType.form: 'form', + SourceType.csvRow: 'csv_row', + SourceType.bankTx: 'bank_tx', + SourceType.api: 'api', + SourceType.manual: 'manual', + SourceType.other: 'other', + SourceType.unknown: '__unknown__', +}; + +const _$IngestionStatusEnumMap = { + IngestionStatus.pending: 'pending', + IngestionStatus.parsed: 'parsed', + IngestionStatus.reviewed: 'reviewed', + IngestionStatus.failed: 'failed', + IngestionStatus.unknown: '__unknown__', +}; diff --git a/packages/finance/dart/lib/src/models/journal.dart b/packages/finance/dart/lib/src/models/journal.dart new file mode 100644 index 0000000..a16c28a --- /dev/null +++ b/packages/finance/dart/lib/src/models/journal.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'journal.freezed.dart'; +part 'journal.g.dart'; + +@freezed +class Journal with _$Journal { + const factory Journal({ + required String id, + required String name, + required DateTime createdAt, + }) = _Journal; + + factory Journal.fromJson(Map json) => _$JournalFromJson(json); +} diff --git a/packages/finance/dart/lib/src/models/journal.freezed.dart b/packages/finance/dart/lib/src/models/journal.freezed.dart new file mode 100644 index 0000000..8b412d0 --- /dev/null +++ b/packages/finance/dart/lib/src/models/journal.freezed.dart @@ -0,0 +1,207 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'journal.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Journal _$JournalFromJson(Map json) { + return _Journal.fromJson(json); +} + +/// @nodoc +mixin _$Journal { + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + + /// Serializes this Journal to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Journal + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $JournalCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $JournalCopyWith<$Res> { + factory $JournalCopyWith(Journal value, $Res Function(Journal) then) = + _$JournalCopyWithImpl<$Res, Journal>; + @useResult + $Res call({String id, String name, DateTime createdAt}); +} + +/// @nodoc +class _$JournalCopyWithImpl<$Res, $Val extends Journal> + implements $JournalCopyWith<$Res> { + _$JournalCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Journal + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? createdAt = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$JournalImplCopyWith<$Res> implements $JournalCopyWith<$Res> { + factory _$$JournalImplCopyWith( + _$JournalImpl value, + $Res Function(_$JournalImpl) then, + ) = __$$JournalImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String id, String name, DateTime createdAt}); +} + +/// @nodoc +class __$$JournalImplCopyWithImpl<$Res> + extends _$JournalCopyWithImpl<$Res, _$JournalImpl> + implements _$$JournalImplCopyWith<$Res> { + __$$JournalImplCopyWithImpl( + _$JournalImpl _value, + $Res Function(_$JournalImpl) _then, + ) : super(_value, _then); + + /// Create a copy of Journal + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? createdAt = null, + }) { + return _then( + _$JournalImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$JournalImpl implements _Journal { + const _$JournalImpl({ + required this.id, + required this.name, + required this.createdAt, + }); + + factory _$JournalImpl.fromJson(Map json) => + _$$JournalImplFromJson(json); + + @override + final String id; + @override + final String name; + @override + final DateTime createdAt; + + @override + String toString() { + return 'Journal(id: $id, name: $name, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$JournalImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, id, name, createdAt); + + /// Create a copy of Journal + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$JournalImplCopyWith<_$JournalImpl> get copyWith => + __$$JournalImplCopyWithImpl<_$JournalImpl>(this, _$identity); + + @override + Map toJson() { + return _$$JournalImplToJson(this); + } +} + +abstract class _Journal implements Journal { + const factory _Journal({ + required final String id, + required final String name, + required final DateTime createdAt, + }) = _$JournalImpl; + + factory _Journal.fromJson(Map json) = _$JournalImpl.fromJson; + + @override + String get id; + @override + String get name; + @override + DateTime get createdAt; + + /// Create a copy of Journal + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$JournalImplCopyWith<_$JournalImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/finance/dart/lib/src/models/journal.g.dart b/packages/finance/dart/lib/src/models/journal.g.dart new file mode 100644 index 0000000..02d7c80 --- /dev/null +++ b/packages/finance/dart/lib/src/models/journal.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'journal.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$JournalImpl _$$JournalImplFromJson(Map json) => + _$JournalImpl( + id: json['id'] as String, + name: json['name'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$$JournalImplToJson(_$JournalImpl instance) => + { + 'id': instance.id, + 'name': instance.name, + 'createdAt': instance.createdAt.toIso8601String(), + }; diff --git a/packages/finance/dart/lib/src/models/journal_entry.dart b/packages/finance/dart/lib/src/models/journal_entry.dart new file mode 100644 index 0000000..b7609c6 --- /dev/null +++ b/packages/finance/dart/lib/src/models/journal_entry.dart @@ -0,0 +1,39 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'journal_entry.freezed.dart'; +part 'journal_entry.g.dart'; + +@JsonEnum() +enum LineType { debit, credit } + +@freezed +class JournalEntryLine with _$JournalEntryLine { + const JournalEntryLine._(); + + @Assert('amount >= 0', 'amount must be non-negative') + const factory JournalEntryLine({ + required String id, + required LineType type, + @Default(0) double amount, + @Default('') String description, + required DateTime createdAt, + }) = _JournalEntryLine; + + factory JournalEntryLine.fromJson(Map json) => _$JournalEntryLineFromJson(json); +} + +@freezed +class JournalEntry with _$JournalEntry { + const factory JournalEntry({ + required String id, + required String journalId, + required DateTime createdAt, + @Default('') String description, + @JsonKey(toJson: _linesToJson) @Default([]) List lines, + }) = _JournalEntry; + + factory JournalEntry.fromJson(Map json) => _$JournalEntryFromJson(json); +} + +List> _linesToJson(List lines) => + lines.map((l) => l.toJson()).toList(); diff --git a/packages/finance/dart/lib/src/models/journal_entry.freezed.dart b/packages/finance/dart/lib/src/models/journal_entry.freezed.dart new file mode 100644 index 0000000..20d268e --- /dev/null +++ b/packages/finance/dart/lib/src/models/journal_entry.freezed.dart @@ -0,0 +1,532 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'journal_entry.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +JournalEntryLine _$JournalEntryLineFromJson(Map json) { + return _JournalEntryLine.fromJson(json); +} + +/// @nodoc +mixin _$JournalEntryLine { + String get id => throw _privateConstructorUsedError; + LineType get type => throw _privateConstructorUsedError; + double get amount => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + + /// Serializes this JournalEntryLine to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of JournalEntryLine + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $JournalEntryLineCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $JournalEntryLineCopyWith<$Res> { + factory $JournalEntryLineCopyWith( + JournalEntryLine value, + $Res Function(JournalEntryLine) then, + ) = _$JournalEntryLineCopyWithImpl<$Res, JournalEntryLine>; + @useResult + $Res call({ + String id, + LineType type, + double amount, + String description, + DateTime createdAt, + }); +} + +/// @nodoc +class _$JournalEntryLineCopyWithImpl<$Res, $Val extends JournalEntryLine> + implements $JournalEntryLineCopyWith<$Res> { + _$JournalEntryLineCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of JournalEntryLine + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? type = null, + Object? amount = null, + Object? description = null, + Object? createdAt = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as LineType, + amount: null == amount + ? _value.amount + : amount // ignore: cast_nullable_to_non_nullable + as double, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$JournalEntryLineImplCopyWith<$Res> + implements $JournalEntryLineCopyWith<$Res> { + factory _$$JournalEntryLineImplCopyWith( + _$JournalEntryLineImpl value, + $Res Function(_$JournalEntryLineImpl) then, + ) = __$$JournalEntryLineImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String id, + LineType type, + double amount, + String description, + DateTime createdAt, + }); +} + +/// @nodoc +class __$$JournalEntryLineImplCopyWithImpl<$Res> + extends _$JournalEntryLineCopyWithImpl<$Res, _$JournalEntryLineImpl> + implements _$$JournalEntryLineImplCopyWith<$Res> { + __$$JournalEntryLineImplCopyWithImpl( + _$JournalEntryLineImpl _value, + $Res Function(_$JournalEntryLineImpl) _then, + ) : super(_value, _then); + + /// Create a copy of JournalEntryLine + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? type = null, + Object? amount = null, + Object? description = null, + Object? createdAt = null, + }) { + return _then( + _$JournalEntryLineImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as LineType, + amount: null == amount + ? _value.amount + : amount // ignore: cast_nullable_to_non_nullable + as double, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$JournalEntryLineImpl extends _JournalEntryLine { + const _$JournalEntryLineImpl({ + required this.id, + required this.type, + this.amount = 0, + this.description = '', + required this.createdAt, + }) : assert(amount >= 0, 'amount must be non-negative'), + super._(); + + factory _$JournalEntryLineImpl.fromJson(Map json) => + _$$JournalEntryLineImplFromJson(json); + + @override + final String id; + @override + final LineType type; + @override + @JsonKey() + final double amount; + @override + @JsonKey() + final String description; + @override + final DateTime createdAt; + + @override + String toString() { + return 'JournalEntryLine(id: $id, type: $type, amount: $amount, description: $description, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$JournalEntryLineImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.type, type) || other.type == type) && + (identical(other.amount, amount) || other.amount == amount) && + (identical(other.description, description) || + other.description == description) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, id, type, amount, description, createdAt); + + /// Create a copy of JournalEntryLine + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$JournalEntryLineImplCopyWith<_$JournalEntryLineImpl> get copyWith => + __$$JournalEntryLineImplCopyWithImpl<_$JournalEntryLineImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$JournalEntryLineImplToJson(this); + } +} + +abstract class _JournalEntryLine extends JournalEntryLine { + const factory _JournalEntryLine({ + required final String id, + required final LineType type, + final double amount, + final String description, + required final DateTime createdAt, + }) = _$JournalEntryLineImpl; + const _JournalEntryLine._() : super._(); + + factory _JournalEntryLine.fromJson(Map json) = + _$JournalEntryLineImpl.fromJson; + + @override + String get id; + @override + LineType get type; + @override + double get amount; + @override + String get description; + @override + DateTime get createdAt; + + /// Create a copy of JournalEntryLine + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$JournalEntryLineImplCopyWith<_$JournalEntryLineImpl> get copyWith => + throw _privateConstructorUsedError; +} + +JournalEntry _$JournalEntryFromJson(Map json) { + return _JournalEntry.fromJson(json); +} + +/// @nodoc +mixin _$JournalEntry { + String get id => throw _privateConstructorUsedError; + String get journalId => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + @JsonKey(toJson: _linesToJson) + List get lines => throw _privateConstructorUsedError; + + /// Serializes this JournalEntry to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of JournalEntry + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $JournalEntryCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $JournalEntryCopyWith<$Res> { + factory $JournalEntryCopyWith( + JournalEntry value, + $Res Function(JournalEntry) then, + ) = _$JournalEntryCopyWithImpl<$Res, JournalEntry>; + @useResult + $Res call({ + String id, + String journalId, + DateTime createdAt, + String description, + @JsonKey(toJson: _linesToJson) List lines, + }); +} + +/// @nodoc +class _$JournalEntryCopyWithImpl<$Res, $Val extends JournalEntry> + implements $JournalEntryCopyWith<$Res> { + _$JournalEntryCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of JournalEntry + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? journalId = null, + Object? createdAt = null, + Object? description = null, + Object? lines = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + journalId: null == journalId + ? _value.journalId + : journalId // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + lines: null == lines + ? _value.lines + : lines // ignore: cast_nullable_to_non_nullable + as List, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$JournalEntryImplCopyWith<$Res> + implements $JournalEntryCopyWith<$Res> { + factory _$$JournalEntryImplCopyWith( + _$JournalEntryImpl value, + $Res Function(_$JournalEntryImpl) then, + ) = __$$JournalEntryImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String id, + String journalId, + DateTime createdAt, + String description, + @JsonKey(toJson: _linesToJson) List lines, + }); +} + +/// @nodoc +class __$$JournalEntryImplCopyWithImpl<$Res> + extends _$JournalEntryCopyWithImpl<$Res, _$JournalEntryImpl> + implements _$$JournalEntryImplCopyWith<$Res> { + __$$JournalEntryImplCopyWithImpl( + _$JournalEntryImpl _value, + $Res Function(_$JournalEntryImpl) _then, + ) : super(_value, _then); + + /// Create a copy of JournalEntry + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? journalId = null, + Object? createdAt = null, + Object? description = null, + Object? lines = null, + }) { + return _then( + _$JournalEntryImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + journalId: null == journalId + ? _value.journalId + : journalId // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + lines: null == lines + ? _value._lines + : lines // ignore: cast_nullable_to_non_nullable + as List, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$JournalEntryImpl implements _JournalEntry { + const _$JournalEntryImpl({ + required this.id, + required this.journalId, + required this.createdAt, + this.description = '', + @JsonKey(toJson: _linesToJson) + final List lines = const [], + }) : _lines = lines; + + factory _$JournalEntryImpl.fromJson(Map json) => + _$$JournalEntryImplFromJson(json); + + @override + final String id; + @override + final String journalId; + @override + final DateTime createdAt; + @override + @JsonKey() + final String description; + final List _lines; + @override + @JsonKey(toJson: _linesToJson) + List get lines { + if (_lines is EqualUnmodifiableListView) return _lines; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_lines); + } + + @override + String toString() { + return 'JournalEntry(id: $id, journalId: $journalId, createdAt: $createdAt, description: $description, lines: $lines)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$JournalEntryImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.journalId, journalId) || + other.journalId == journalId) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.description, description) || + other.description == description) && + const DeepCollectionEquality().equals(other._lines, _lines)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + journalId, + createdAt, + description, + const DeepCollectionEquality().hash(_lines), + ); + + /// Create a copy of JournalEntry + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$JournalEntryImplCopyWith<_$JournalEntryImpl> get copyWith => + __$$JournalEntryImplCopyWithImpl<_$JournalEntryImpl>(this, _$identity); + + @override + Map toJson() { + return _$$JournalEntryImplToJson(this); + } +} + +abstract class _JournalEntry implements JournalEntry { + const factory _JournalEntry({ + required final String id, + required final String journalId, + required final DateTime createdAt, + final String description, + @JsonKey(toJson: _linesToJson) final List lines, + }) = _$JournalEntryImpl; + + factory _JournalEntry.fromJson(Map json) = + _$JournalEntryImpl.fromJson; + + @override + String get id; + @override + String get journalId; + @override + DateTime get createdAt; + @override + String get description; + @override + @JsonKey(toJson: _linesToJson) + List get lines; + + /// Create a copy of JournalEntry + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$JournalEntryImplCopyWith<_$JournalEntryImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/finance/dart/lib/src/models/journal_entry.g.dart b/packages/finance/dart/lib/src/models/journal_entry.g.dart new file mode 100644 index 0000000..7d255f3 --- /dev/null +++ b/packages/finance/dart/lib/src/models/journal_entry.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'journal_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$JournalEntryLineImpl _$$JournalEntryLineImplFromJson( + Map json, +) => _$JournalEntryLineImpl( + id: json['id'] as String, + type: $enumDecode(_$LineTypeEnumMap, json['type']), + amount: (json['amount'] as num?)?.toDouble() ?? 0, + description: json['description'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt'] as String), +); + +Map _$$JournalEntryLineImplToJson( + _$JournalEntryLineImpl instance, +) => { + 'id': instance.id, + 'type': _$LineTypeEnumMap[instance.type]!, + 'amount': instance.amount, + 'description': instance.description, + 'createdAt': instance.createdAt.toIso8601String(), +}; + +const _$LineTypeEnumMap = {LineType.debit: 'debit', LineType.credit: 'credit'}; + +_$JournalEntryImpl _$$JournalEntryImplFromJson(Map json) => + _$JournalEntryImpl( + id: json['id'] as String, + journalId: json['journalId'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + description: json['description'] as String? ?? '', + lines: + (json['lines'] as List?) + ?.map((e) => JournalEntryLine.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$$JournalEntryImplToJson(_$JournalEntryImpl instance) => + { + 'id': instance.id, + 'journalId': instance.journalId, + 'createdAt': instance.createdAt.toIso8601String(), + 'description': instance.description, + 'lines': _linesToJson(instance.lines), + }; diff --git a/packages/finance/dart/pubspec.lock b/packages/finance/dart/pubspec.lock new file mode 100644 index 0000000..2d6952e --- /dev/null +++ b/packages/finance/dart/pubspec.lock @@ -0,0 +1,533 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "96.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.12.6" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.5" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.11.4" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.17" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.12" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.5 <4.0.0" diff --git a/packages/finance/dart/pubspec.yaml b/packages/finance/dart/pubspec.yaml new file mode 100644 index 0000000..4606860 --- /dev/null +++ b/packages/finance/dart/pubspec.yaml @@ -0,0 +1,20 @@ +name: quanttide_finance +description: 量潮金融领域模型 — 核心实体(Journal、JournalEntry) +version: 0.2.0 +homepage: https://github.com/quanttide/quanttide-finance-toolkit +repository: https://github.com/quanttide/quanttide-finance-toolkit +issue_tracker: https://github.com/quanttide/quanttide-finance-toolkit/issues + +environment: + sdk: ^3.11.5 + +dependencies: + freezed_annotation: ^3.1.0 + json_annotation: ^4.9.0 + +dev_dependencies: + freezed: ^3.2.5 + json_serializable: ^6.9.0 + build_runner: ^2.4.6 + lints: ^6.0.0 + test: ^1.25.6 diff --git a/packages/finance/dart/run_build_runner.sh b/packages/finance/dart/run_build_runner.sh new file mode 100755 index 0000000..dd0e8ad --- /dev/null +++ b/packages/finance/dart/run_build_runner.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Add Flutter to PATH if available +[ -d "$HOME/flutter/bin" ] && PATH="$HOME/flutter/bin:$PATH" +cd "$(cd "$(dirname "$0")" && pwd)" +dart pub get 2>&1 | tail -3 +dart run build_runner build --delete-conflicting-outputs 2>&1 diff --git a/packages/finance/dart/run_test.sh b/packages/finance/dart/run_test.sh new file mode 100755 index 0000000..c2b1787 --- /dev/null +++ b/packages/finance/dart/run_test.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Add Flutter to PATH if available +[ -d "$HOME/flutter/bin" ] && PATH="$HOME/flutter/bin:$PATH" +cd "$(cd "$(dirname "$0")" && pwd)" +dart test 2>&1 diff --git a/packages/finance/dart/test/dto/classification_result_test.dart b/packages/finance/dart/test/dto/classification_result_test.dart new file mode 100644 index 0000000..07f071a --- /dev/null +++ b/packages/finance/dart/test/dto/classification_result_test.dart @@ -0,0 +1,136 @@ +import 'package:test/test.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +void main() { + group('ClassificationResultDto fromJson round-trip', () { + test('parses full response', () { + final json = { + 'id': 1, + 'normalized_record_id': 42, + 'taxonomy': 'expense_type', + 'category': 'office_supplies', + 'tags': {'brand': 'Staples'}, + 'classifier_kind': 'ai', + 'confidence': 0.95, + 'model_version': 'gpt-4o-2026-05-01', + 'review_status': 'candidate', + 'is_active': true, + 'created_at': '2026-06-01T12:00:00Z', + 'updated_at': '2026-06-01T12:30:00Z', + }; + + final dto = ClassificationResultDto.fromJson(json); + expect(dto.id, 1); + expect(dto.normalizedRecordId, 42); + expect(dto.taxonomy, 'expense_type'); + expect(dto.category, 'office_supplies'); + expect(dto.tags, {'brand': 'Staples'}); + expect(dto.classifierKind, ClassifierKind.ai); + expect(dto.confidence, 0.95); + expect(dto.modelVersion, 'gpt-4o-2026-05-01'); + expect(dto.reviewStatus, ReviewStatus.candidate); + expect(dto.isActive, true); + }); + + test('toJson uses snake_case keys', () { + final dto = ClassificationResultDto( + id: 1, + normalizedRecordId: 42, + taxonomy: 'expense_type', + category: 'office_supplies', + classifierKind: ClassifierKind.manual, + reviewStatus: ReviewStatus.accepted, + isActive: true, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final json = dto.toJson(); + expect(json['normalized_record_id'], 42); + expect(json['classifier_kind'], 'manual'); + expect(json['review_status'], 'accepted'); + expect(json['is_active'], true); + expect(json['created_at'], isA()); + expect(json['updated_at'], isA()); + }); + + test('round-trip preserves all fields', () { + final original = ClassificationResultDto( + id: 1, + normalizedRecordId: 42, + taxonomy: 'expense_type', + category: 'office_supplies', + tags: {'brand': 'Staples'}, + classifierKind: ClassifierKind.ai, + confidence: 0.95, + modelVersion: 'gpt-4o-2026-05-01', + reviewStatus: ReviewStatus.candidate, + isActive: true, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1, 0, 30), + ); + + final json = original.toJson(); + final restored = ClassificationResultDto.fromJson(json); + expect(restored, original); + }); + }); + + group('ClassificationResultDto defaults', () { + test('review_status defaults to candidate', () { + const json = { + 'id': 1, + 'normalized_record_id': 1, + 'taxonomy': 'expense_type', + 'category': 'office_supplies', + 'classifier_kind': 'manual', + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', + }; + final dto = ClassificationResultDto.fromJson(json); + expect(dto.reviewStatus, ReviewStatus.candidate); + expect(dto.isActive, true); + }); + + test('unknown enum values fall back to unknown', () { + const json = { + 'id': 1, + 'normalized_record_id': 1, + 'taxonomy': 'expense_type', + 'category': 'office_supplies', + 'classifier_kind': 'unknown_classifier', + 'review_status': 'unknown_status', + 'is_active': true, + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', + }; + final dto = ClassificationResultDto.fromJson(json); + expect(dto.classifierKind, ClassifierKind.unknown); + expect(dto.reviewStatus, ReviewStatus.unknown); + }); + }); + + group('ClassificationResultDto copyWith', () { + test('copies with modified fields', () { + final dto = ClassificationResultDto( + id: 1, + normalizedRecordId: 42, + taxonomy: 'expense_type', + category: 'office_supplies', + classifierKind: ClassifierKind.ai, + reviewStatus: ReviewStatus.candidate, + isActive: true, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final updated = dto.copyWith( + reviewStatus: ReviewStatus.accepted, + isActive: false, + ); + expect(updated.reviewStatus, ReviewStatus.accepted); + expect(updated.isActive, false); + expect(updated.id, 1); // unchanged + }); + }); +} diff --git a/packages/finance/dart/test/dto/enums_test.dart b/packages/finance/dart/test/dto/enums_test.dart new file mode 100644 index 0000000..1153289 --- /dev/null +++ b/packages/finance/dart/test/dto/enums_test.dart @@ -0,0 +1,207 @@ +import 'package:test/test.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +/// Wire-value alignment tests. +/// +/// Each @JsonValue label is verified against doc/entities.md wire values +/// through DTO toJson serialization. +void main() { + group('SourceType wire values', () { + for (final entry in { + 'image': SourceType.image, + 'chat': SourceType.chat, + 'form': SourceType.form, + 'csv_row': SourceType.csvRow, + 'bank_tx': SourceType.bankTx, + 'api': SourceType.api, + 'manual': SourceType.manual, + 'other': SourceType.other, + }.entries) { + test('${entry.key}', () { + final dto = SourceRecordDto( + id: 1, + sourceType: entry.value, + rawText: '', + occurredAt: null, + ingestionStatus: IngestionStatus.pending, + createdAt: DateTime(2026, 1, 1), + ); + final json = dto.toJson(); + expect( + json['source_type'], + equals(entry.key), + reason: + 'SourceType.${entry.value.name} @JsonValue must match doc/entities.md', + ); + }); + } + }); + + group('IngestionStatus wire values', () { + for (final entry in { + 'pending': IngestionStatus.pending, + 'parsed': IngestionStatus.parsed, + 'reviewed': IngestionStatus.reviewed, + 'failed': IngestionStatus.failed, + }.entries) { + test('${entry.key}', () { + final dto = SourceRecordDto( + id: 1, + sourceType: SourceType.manual, + rawText: '', + occurredAt: null, + ingestionStatus: entry.value, + createdAt: DateTime(2026, 1, 1), + ); + final json = dto.toJson(); + expect( + json['ingestion_status'], + equals(entry.key), + reason: + 'IngestionStatus.${entry.value.name} @JsonValue must match doc/entities.md', + ); + }); + } + }); + + group('RecordType wire values', () { + for (final entry in { + 'expense': RecordType.expense, + 'income': RecordType.income, + 'transfer': RecordType.transfer, + 'reimbursement': RecordType.reimbursement, + 'other': RecordType.other, + }.entries) { + test('${entry.key}', () { + final dto = NormalizedRecordDto( + id: 1, + recordType: entry.value, + businessDate: '2026-06-01', + amountCents: 0, + direction: Direction.outflow, + department: null, + person: null, + description: '', + createdAt: DateTime(2026, 1, 1), + ); + final json = dto.toJson(); + expect( + json['record_type'], + equals(entry.key), + reason: + 'RecordType.${entry.value.name} @JsonValue must match doc/entities.md', + ); + }); + } + }); + + group('Direction wire values', () { + for (final entry in { + 'outflow': Direction.outflow, + 'inflow': Direction.inflow, + }.entries) { + test('${entry.key}', () { + final dto = NormalizedRecordDto( + id: 1, + recordType: RecordType.expense, + businessDate: '2026-06-01', + amountCents: 0, + direction: entry.value, + department: null, + person: null, + description: '', + createdAt: DateTime(2026, 1, 1), + ); + final json = dto.toJson(); + expect( + json['direction'], + equals(entry.key), + reason: + 'Direction.${entry.value.name} @JsonValue must match doc/entities.md', + ); + }); + } + }); + + group('SourceType', () { + test('unknown fallback', () { + const json = { + 'id': 1, + 'source_type': 'invalid', + 'created_at': '2026-01-01T00:00:00Z', + }; + final dto = SourceRecordDto.fromJson(json); + expect(dto.sourceType, SourceType.unknown); + }); + }); + + group('IngestionStatus', () { + test('default value', () { + const json = { + 'id': 1, + 'source_type': 'manual', + 'created_at': '2026-01-01T00:00:00Z', + }; + final dto = SourceRecordDto.fromJson(json); + expect(dto.ingestionStatus, IngestionStatus.pending); + }); + }); + + group('ClassifierKind wire values', () { + for (final entry in { + 'ai': ClassifierKind.ai, + 'rule': ClassifierKind.rule, + 'manual': ClassifierKind.manual, + }.entries) { + test(entry.key, () { + final dto = ClassificationResultDto( + id: 1, + normalizedRecordId: 1, + taxonomy: 'expense_type', + category: 'office_supplies', + classifierKind: entry.value, + reviewStatus: ReviewStatus.candidate, + isActive: true, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + final json = dto.toJson(); + expect( + json['classifier_kind'], + equals(entry.key), + reason: + 'ClassifierKind.${entry.value.name} @JsonValue must match doc/entities.md', + ); + }); + } + }); + + group('ReviewStatus wire values', () { + for (final entry in { + 'candidate': ReviewStatus.candidate, + 'accepted': ReviewStatus.accepted, + 'rejected': ReviewStatus.rejected, + }.entries) { + test(entry.key, () { + final dto = ClassificationResultDto( + id: 1, + normalizedRecordId: 1, + taxonomy: 'expense_type', + category: 'office_supplies', + classifierKind: ClassifierKind.manual, + reviewStatus: entry.value, + isActive: true, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + final json = dto.toJson(); + expect( + json['review_status'], + equals(entry.key), + reason: + 'ReviewStatus.${entry.value.name} @JsonValue must match doc/entities.md', + ); + }); + } + }); +} diff --git a/packages/finance/dart/test/dto/normalized_record_test.dart b/packages/finance/dart/test/dto/normalized_record_test.dart new file mode 100644 index 0000000..f154aaf --- /dev/null +++ b/packages/finance/dart/test/dto/normalized_record_test.dart @@ -0,0 +1,93 @@ +import 'package:test/test.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +void main() { + group('NormalizedRecordDto', () { + test('toJson uses snake_case keys', () { + final dto = NormalizedRecordDto( + id: 1, + recordType: RecordType.expense, + businessDate: '2026-06-01', + amountCents: 120000, + direction: Direction.outflow, + department: '研发部', + person: '张三', + description: '办公用品采购', + createdAt: DateTime(2026, 6, 1), + ); + final json = dto.toJson(); + expect(json.containsKey('record_type'), isTrue); + expect(json.containsKey('business_date'), isTrue); + expect(json.containsKey('amount_cents'), isTrue); + expect(json.containsKey('created_at'), isTrue); + expect(json.containsKey('recordType'), isFalse); + expect(json.containsKey('amountCents'), isFalse); + }); + + test('fromJson round-trip', () { + final dto = NormalizedRecordDto( + id: 42, + recordType: RecordType.income, + businessDate: '2026-06-15', + amountCents: 500000, + direction: Direction.inflow, + department: null, + person: null, + description: '客户回款', + createdAt: DateTime(2026, 6, 15), + ); + expect(NormalizedRecordDto.fromJson(dto.toJson()), dto); + }); + + test('copyWith', () { + final dto = NormalizedRecordDto( + id: 1, + recordType: RecordType.expense, + businessDate: '2026-06-01', + amountCents: 1000, + direction: Direction.outflow, + department: null, + person: null, + description: 'test', + createdAt: DateTime(2026, 6, 1), + ); + final updated = dto.copyWith(amountCents: 2000); + expect(updated.amountCents, 2000); + expect(updated.id, dto.id); + }); + + test('nullable fields', () { + final dto = NormalizedRecordDto( + id: 1, + recordType: RecordType.other, + businessDate: '2026-06-01', + amountCents: 0, + direction: Direction.outflow, + department: null, + person: null, + description: '', + createdAt: DateTime(2026, 6, 1), + ); + final json = dto.toJson(); + expect(json['department'], isNull); + expect(json['person'], isNull); + }); + + test('businessDate is display-only string', () { + final dto = NormalizedRecordDto( + id: 1, + recordType: RecordType.expense, + businessDate: '2026-06-01', + amountCents: 0, + direction: Direction.outflow, + department: null, + person: null, + description: '', + createdAt: DateTime(2026, 6, 1), + ); + final json = dto.toJson(); + expect(json['business_date'], isA()); + expect(json['business_date'], '2026-06-01'); + }); + }); +} diff --git a/packages/finance/dart/test/dto/source_record_test.dart b/packages/finance/dart/test/dto/source_record_test.dart new file mode 100644 index 0000000..528b1ca --- /dev/null +++ b/packages/finance/dart/test/dto/source_record_test.dart @@ -0,0 +1,64 @@ +import 'package:test/test.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +void main() { + group('SourceRecordDto', () { + test('toJson uses snake_case keys', () { + final dto = SourceRecordDto( + id: 1, + sourceType: SourceType.csvRow, + rawText: '2026-06-01,办公用品,120000,outflow', + occurredAt: null, + ingestionStatus: IngestionStatus.parsed, + createdAt: DateTime(2026, 6, 1), + ); + final json = dto.toJson(); + expect(json.containsKey('source_type'), isTrue); + expect(json.containsKey('raw_text'), isTrue); + expect(json.containsKey('ingestion_status'), isTrue); + expect(json.containsKey('created_at'), isTrue); + // Ensure no camelCase keys leak through + expect(json.containsKey('sourceType'), isFalse); + expect(json.containsKey('rawText'), isFalse); + }); + + test('fromJson round-trip', () { + final dto = SourceRecordDto( + id: 42, + sourceType: SourceType.manual, + rawText: '购买办公用品', + occurredAt: DateTime(2026, 5, 30), + ingestionStatus: IngestionStatus.pending, + createdAt: DateTime(2026, 5, 31), + ); + expect(SourceRecordDto.fromJson(dto.toJson()), dto); + }); + + test('copyWith', () { + final dto = SourceRecordDto( + id: 1, + sourceType: SourceType.other, + rawText: '', + occurredAt: null, + ingestionStatus: IngestionStatus.pending, + createdAt: DateTime(2026, 6, 1), + ); + final updated = dto.copyWith(ingestionStatus: IngestionStatus.reviewed); + expect(updated.ingestionStatus, IngestionStatus.reviewed); + expect(updated.id, dto.id); // unchanged + }); + + test('nullable fields', () { + final dto = SourceRecordDto( + id: 1, + sourceType: SourceType.form, + rawText: 'test', + occurredAt: null, + ingestionStatus: IngestionStatus.pending, + createdAt: DateTime(2026, 6, 1), + ); + final json = dto.toJson(); + expect(json['occurred_at'], isNull); + }); + }); +} diff --git a/packages/finance/dart/test/models/journal_test.dart b/packages/finance/dart/test/models/journal_test.dart new file mode 100644 index 0000000..43c22ff --- /dev/null +++ b/packages/finance/dart/test/models/journal_test.dart @@ -0,0 +1,34 @@ +import 'package:test/test.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +void main() { + test('Journal toJson / fromJson', () { + final j = Journal(id: '1', name: '备用金', createdAt: DateTime(2026, 5, 29)); + expect(Journal.fromJson(j.toJson()), j); + }); + + test('JournalEntryLine toJson / fromJson', () { + final l = JournalEntryLine( + id: 'l1', type: LineType.debit, amount: 1000, + description: '买纸', createdAt: DateTime(2026, 5, 29), + ); + expect(JournalEntryLine.fromJson(l.toJson()), l); + }); + + test('JournalEntry toJson / fromJson', () { + final e = JournalEntry( + id: 'je1', journalId: 'j1', createdAt: DateTime(2026, 5, 29), + description: '采购', + lines: [ + JournalEntryLine(id: 'l1', type: LineType.debit, amount: 1200, createdAt: DateTime(2026, 5, 29)), + JournalEntryLine(id: 'l2', type: LineType.credit, amount: 1200, createdAt: DateTime(2026, 5, 29)), + ], + ); + expect(JournalEntry.fromJson(e.toJson()), e); + }); + + test('copyWith', () { + final j = Journal(id: '1', name: '备用金', createdAt: DateTime(2026, 5, 29)); + expect(j.copyWith(name: '改名的备用金').name, '改名的备用金'); + }); +} diff --git a/packages/finance/data/quanttide_finance.db b/packages/finance/data/quanttide_finance.db new file mode 100644 index 0000000..e69de29 diff --git a/packages/finance/fastapi/.pytest_cache/.gitignore b/packages/finance/fastapi/.pytest_cache/.gitignore new file mode 100644 index 0000000..bc1a1f6 --- /dev/null +++ b/packages/finance/fastapi/.pytest_cache/.gitignore @@ -0,0 +1,2 @@ +# Created by pytest automatically. +* diff --git a/packages/finance/fastapi/.pytest_cache/CACHEDIR.TAG b/packages/finance/fastapi/.pytest_cache/CACHEDIR.TAG new file mode 100644 index 0000000..fce15ad --- /dev/null +++ b/packages/finance/fastapi/.pytest_cache/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by pytest. +# For information about cache directory tags, see: +# https://bford.info/cachedir/spec.html diff --git a/packages/finance/fastapi/.pytest_cache/README.md b/packages/finance/fastapi/.pytest_cache/README.md new file mode 100644 index 0000000..b89018c --- /dev/null +++ b/packages/finance/fastapi/.pytest_cache/README.md @@ -0,0 +1,8 @@ +# pytest cache directory # + +This directory contains data from the pytest's cache plugin, +which provides the `--lf` and `--ff` options, as well as the `cache` fixture. + +**Do not** commit this to version control. + +See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information. diff --git a/packages/finance/fastapi/.pytest_cache/v/cache/nodeids b/packages/finance/fastapi/.pytest_cache/v/cache/nodeids new file mode 100644 index 0000000..1260f36 --- /dev/null +++ b/packages/finance/fastapi/.pytest_cache/v/cache/nodeids @@ -0,0 +1,123 @@ +[ + "tests/test_database.py::test_db_connectivity", + "tests/test_database.py::test_db_has_tables", + "tests/test_health.py::test_health", + "tests/test_models.py::TestClassificationResultModel::test_create_and_read", + "tests/test_models.py::TestClassificationResultModel::test_default_review_status_and_is_active", + "tests/test_models.py::TestNormalizedRecordModel::test_create_and_read", + "tests/test_models.py::TestNormalizedRecordModel::test_default_currency_and_status", + "tests/test_models.py::TestRecordLinkModel::test_create_and_read", + "tests/test_models.py::TestRecordLinkModel::test_fk_violation_on_invalid_source", + "tests/test_models.py::TestSourceRecordModel::test_create_and_read", + "tests/test_models.py::TestSourceRecordModel::test_default_ingestion_status", + "tests/test_models.py::TestSourceRecordModel::test_timestamps_set_on_create", + "tests/test_normalizers.py::TestCsvRowNormalizer::test_can_handle_csv_row", + "tests/test_normalizers.py::TestCsvRowNormalizer::test_generates_links_for_each_row", + "tests/test_normalizers.py::TestCsvRowNormalizer::test_normalize_full_row", + "tests/test_normalizers.py::TestCsvRowNormalizer::test_normalize_multiple_rows", + "tests/test_normalizers.py::TestCsvRowNormalizer::test_rejects_csv_without_header", + "tests/test_normalizers.py::TestCsvRowNormalizer::test_rejects_empty_csv", + "tests/test_normalizers.py::TestCsvRowNormalizer::test_uses_defaults_for_missing_fields", + "tests/test_normalizers.py::TestManualNormalizer::test_can_handle_manual", + "tests/test_normalizers.py::TestManualNormalizer::test_generates_link", + "tests/test_normalizers.py::TestManualNormalizer::test_handles_empty_text", + "tests/test_normalizers.py::TestManualNormalizer::test_normalize_sets_raw_text_as_description", + "tests/test_normalizers.py::TestManualNormalizer::test_normalize_sets_sensible_defaults", + "tests/test_routes.py::TestCreateClassification::test_create_candidate", + "tests/test_routes.py::TestCreateClassification::test_create_invalid_category", + "tests/test_routes.py::TestCreateClassification::test_create_nonexistent_normalized_record", + "tests/test_routes.py::TestCreateClassification::test_create_rejects_extra_fields_in_body", + "tests/test_routes.py::TestCreateSourceRecord::test_creates_source_record", + "tests/test_routes.py::TestCreateSourceRecord::test_rejects_invalid_source_type", + "tests/test_routes.py::TestGetNormalizedRecord::test_get_existing", + "tests/test_routes.py::TestGetNormalizedRecord::test_get_nonexistent", + "tests/test_routes.py::TestGetSourceRecord::test_get_existing", + "tests/test_routes.py::TestGetSourceRecord::test_get_nonexistent", + "tests/test_routes.py::TestListClassifications::test_list_after_create", + "tests/test_routes.py::TestListClassifications::test_list_empty", + "tests/test_routes.py::TestListClassifications::test_list_filter_by_review_status", + "tests/test_routes.py::TestListClassifications::test_list_filter_invalid_status_returns_422", + "tests/test_routes.py::TestListClassifications::test_list_nonexistent_normalized_record", + "tests/test_routes.py::TestListNormalizedRecords::test_list_after_normalize", + "tests/test_routes.py::TestListNormalizedRecords::test_list_empty", + "tests/test_routes.py::TestListNormalizedRecords::test_list_filter_by_source", + "tests/test_routes.py::TestListSourceRecords::test_list_empty", + "tests/test_routes.py::TestListSourceRecords::test_list_with_records", + "tests/test_routes.py::TestNormalizeSourceRecord::test_normalize_csv_row", + "tests/test_routes.py::TestNormalizeSourceRecord::test_normalize_manual", + "tests/test_routes.py::TestNormalizeSourceRecord::test_normalize_nonexistent_record", + "tests/test_routes.py::TestNormalizeSourceRecord::test_normalize_unsupported_type", + "tests/test_routes.py::TestReviewClassification::test_review_accept", + "tests/test_routes.py::TestReviewClassification::test_review_category_not_accepted", + "tests/test_routes.py::TestReviewClassification::test_review_invalid_status", + "tests/test_routes.py::TestReviewClassification::test_review_is_active_null_rejected", + "tests/test_routes.py::TestReviewClassification::test_review_nonexistent", + "tests/test_routes.py::TestReviewClassification::test_review_noop_empty_body", + "tests/test_routes.py::TestReviewClassification::test_review_reject", + "tests/test_routes.py::TestReviewClassification::test_review_soft_delete", + "tests/test_schemas.py::TestClassificationCreateRequestSchema::test_extra_fields_rejected", + "tests/test_schemas.py::TestClassificationCreateRequestSchema::test_invalid_classifier_kind", + "tests/test_schemas.py::TestClassificationCreateRequestSchema::test_invalid_taxonomy", + "tests/test_schemas.py::TestClassificationCreateRequestSchema::test_valid_full", + "tests/test_schemas.py::TestClassificationCreateRequestSchema::test_valid_minimal", + "tests/test_schemas.py::TestClassificationResultSchema::test_invalid_classifier_kind", + "tests/test_schemas.py::TestClassificationResultSchema::test_invalid_review_status", + "tests/test_schemas.py::TestClassificationResultSchema::test_invalid_taxonomy", + "tests/test_schemas.py::TestClassificationResultSchema::test_valid_minimal", + "tests/test_schemas.py::TestClassificationResultUpdateSchema::test_update_invalid_review_status", + "tests/test_schemas.py::TestClassificationReviewSchema::test_empty_body_allowed", + "tests/test_schemas.py::TestClassificationReviewSchema::test_extra_fields_rejected", + "tests/test_schemas.py::TestClassificationReviewSchema::test_invalid_review_status", + "tests/test_schemas.py::TestClassificationReviewSchema::test_valid_review_status_accepted", + "tests/test_schemas.py::TestClassificationReviewSchema::test_valid_review_status_rejected", + "tests/test_schemas.py::TestNormalizedRecordSchema::test_amount_cents_negative_rejected", + "tests/test_schemas.py::TestNormalizedRecordSchema::test_amount_cents_zero_allowed", + "tests/test_schemas.py::TestNormalizedRecordSchema::test_description_truncated_at_1000", + "tests/test_schemas.py::TestNormalizedRecordSchema::test_invalid_direction", + "tests/test_schemas.py::TestNormalizedRecordSchema::test_invalid_normalization_status", + "tests/test_schemas.py::TestNormalizedRecordSchema::test_invalid_record_type", + "tests/test_schemas.py::TestNormalizedRecordSchema::test_valid_minimal", + "tests/test_schemas.py::TestRecordLinkSchema::test_invalid_relation_type", + "tests/test_schemas.py::TestRecordLinkSchema::test_valid", + "tests/test_schemas.py::TestSourceRecordSchema::test_invalid_ingestion_status", + "tests/test_schemas.py::TestSourceRecordSchema::test_invalid_source_type", + "tests/test_schemas.py::TestSourceRecordSchema::test_raw_text_overflow_rejected", + "tests/test_schemas.py::TestSourceRecordSchema::test_valid_full", + "tests/test_schemas.py::TestSourceRecordSchema::test_valid_minimal", + "tests/test_statistics.py::TestBreakdown::test_by_department", + "tests/test_statistics.py::TestBreakdown::test_by_record_type", + "tests/test_statistics.py::TestBreakdown::test_currency_star_returns_null_amount", + "tests/test_statistics.py::TestBreakdown::test_invalid_dimension_returns_422_with_allowed_list", + "tests/test_statistics.py::TestBreakdown::test_missing_dimension_returns_422", + "tests/test_statistics.py::TestBreakdown::test_null_dimension", + "tests/test_statistics.py::TestDrilldown::test_basic_pagination", + "tests/test_statistics.py::TestDrilldown::test_department_filter", + "tests/test_statistics.py::TestDrilldown::test_empty_db", + "tests/test_statistics.py::TestDrilldown::test_items_use_normalized_record_response_schema", + "tests/test_statistics.py::TestDrilldown::test_limit_200_is_ok", + "tests/test_statistics.py::TestDrilldown::test_limit_exceeds_max_returns_422", + "tests/test_statistics.py::TestDrilldown::test_skip_beyond_total", + "tests/test_statistics.py::TestDrilldown::test_skip_limit", + "tests/test_statistics.py::TestDrilldown::test_total_matches_record_count", + "tests/test_statistics.py::TestSummary::test_basic_summary", + "tests/test_statistics.py::TestSummary::test_category_without_taxonomy_returns_422", + "tests/test_statistics.py::TestSummary::test_classified_count", + "tests/test_statistics.py::TestSummary::test_classified_count_deduplicates_exist", + "tests/test_statistics.py::TestSummary::test_currency_cny_returns_amount", + "tests/test_statistics.py::TestSummary::test_currency_star_returns_null_amount", + "tests/test_statistics.py::TestSummary::test_department_filter", + "tests/test_statistics.py::TestSummary::test_empty_db", + "tests/test_statistics.py::TestSummary::test_from_date_gt_to_date_returns_422", + "tests/test_statistics.py::TestSummary::test_invalid_currency_returns_422", + "tests/test_statistics.py::TestSummary::test_invalid_direction_returns_422", + "tests/test_statistics.py::TestSummary::test_invalid_record_type_returns_422", + "tests/test_statistics.py::TestSummary::test_taxonomy_category_filter_matches", + "tests/test_statistics.py::TestSummary::test_taxonomy_category_filter_no_match", + "tests/test_statistics.py::TestSummary::test_taxonomy_without_category_returns_422", + "tests/test_statistics.py::TestTrend::test_by_day", + "tests/test_statistics.py::TestTrend::test_by_month", + "tests/test_statistics.py::TestTrend::test_currency_star_returns_null_amount", + "tests/test_statistics.py::TestTrend::test_default_granularity_is_day", + "tests/test_statistics.py::TestTrend::test_invalid_granularity_returns_422", + "tests/test_statistics.py::TestTrend::test_no_empty_periods" +] \ No newline at end of file diff --git a/packages/finance/fastapi/alembic.ini b/packages/finance/fastapi/alembic.ini new file mode 100644 index 0000000..87c2271 --- /dev/null +++ b/packages/finance/fastapi/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/src/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///%(here)s/data/quanttide_finance.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/packages/finance/fastapi/data/quanttide_finance.db b/packages/finance/fastapi/data/quanttide_finance.db new file mode 100644 index 0000000..8ba44b3 Binary files /dev/null and b/packages/finance/fastapi/data/quanttide_finance.db differ diff --git a/packages/finance/fastapi/pyproject.toml b/packages/finance/fastapi/pyproject.toml new file mode 100644 index 0000000..b3e63b8 --- /dev/null +++ b/packages/finance/fastapi/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "fastapi-quanttide-finance" +version = "0.1.0" +description = "QuantTide Finance Toolkit — FastAPI backend for financial record normalization, classification, and statistics" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "sqlalchemy>=2.0.36", + "alembic>=1.14.0", + "pydantic>=2.10.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-cov>=6.0.0", + "httpx>=0.28.0", + "ruff>=0.8.0", +] + +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/packages/finance/fastapi/src/alembic/README b/packages/finance/fastapi/src/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/packages/finance/fastapi/src/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/packages/finance/fastapi/src/alembic/__pycache__/env.cpython-314.pyc b/packages/finance/fastapi/src/alembic/__pycache__/env.cpython-314.pyc new file mode 100644 index 0000000..7138fa1 Binary files /dev/null and b/packages/finance/fastapi/src/alembic/__pycache__/env.cpython-314.pyc differ diff --git a/packages/finance/fastapi/src/alembic/env.py b/packages/finance/fastapi/src/alembic/env.py new file mode 100644 index 0000000..2c2329f --- /dev/null +++ b/packages/finance/fastapi/src/alembic/env.py @@ -0,0 +1,82 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +from fastapi_quanttide_finance.database import Base +from fastapi_quanttide_finance.models import ( # noqa: F401 — ensure models are loaded + source_record, + normalized_record, + record_link, + classification_result, +) + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/packages/finance/fastapi/src/alembic/script.py.mako b/packages/finance/fastapi/src/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/packages/finance/fastapi/src/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/packages/finance/fastapi/src/alembic/versions/2bf6352a0475_fix_classification_result_tags_to_json.py b/packages/finance/fastapi/src/alembic/versions/2bf6352a0475_fix_classification_result_tags_to_json.py new file mode 100644 index 0000000..053fba2 --- /dev/null +++ b/packages/finance/fastapi/src/alembic/versions/2bf6352a0475_fix_classification_result_tags_to_json.py @@ -0,0 +1,41 @@ +"""fix: classification_result.tags to JSON + +Revision ID: 2bf6352a0475 +Revises: 571dd6946d4b +Create Date: 2026-05-31 15:21:46.704629 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision: str = "2bf6352a0475" +down_revision: Union[str, Sequence[str], None] = "571dd6946d4b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table("classification_result") as batch_op: + batch_op.alter_column( + "tags", + existing_type=sa.VARCHAR(), + type_=sqlite.JSON(), + existing_nullable=True, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table("classification_result") as batch_op: + batch_op.alter_column( + "tags", + existing_type=sqlite.JSON(), + type_=sa.VARCHAR(), + existing_nullable=True, + ) diff --git a/packages/finance/fastapi/src/alembic/versions/571dd6946d4b_m1_add_core_entities.py b/packages/finance/fastapi/src/alembic/versions/571dd6946d4b_m1_add_core_entities.py new file mode 100644 index 0000000..d64f782 --- /dev/null +++ b/packages/finance/fastapi/src/alembic/versions/571dd6946d4b_m1_add_core_entities.py @@ -0,0 +1,144 @@ +"""M1: add core entities + +Revision ID: 571dd6946d4b +Revises: +Create Date: 2026-05-30 17:08:10.567381 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision: str = "571dd6946d4b" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "source_record", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("source_type", sa.String(length=50), nullable=False), + sa.Column("source_channel", sa.String(length=50), nullable=True), + sa.Column("external_id", sa.String(length=255), nullable=True), + sa.Column("raw_payload", sqlite.JSON(), nullable=True), + sa.Column("raw_text", sa.Text(), nullable=False), + sa.Column("evidence_refs", sqlite.JSON(), nullable=True), + sa.Column("occurred_at", sa.DateTime(), nullable=True), + sa.Column("ingestion_status", sa.String(length=50), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "normalized_record", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("primary_source_id", sa.Integer(), nullable=True), + sa.Column("record_type", sa.String(length=50), nullable=False), + sa.Column("business_date", sa.Date(), nullable=False), + sa.Column("amount_cents", sa.Integer(), nullable=False), + sa.Column("currency", sa.String(length=10), nullable=False), + sa.Column("direction", sa.String(length=50), nullable=False), + sa.Column("department", sa.String(length=255), nullable=True), + sa.Column("person", sa.String(length=255), nullable=True), + sa.Column("counterparty", sa.String(length=255), nullable=True), + sa.Column("description", sa.String(length=1000), nullable=False), + sa.Column("normalization_status", sa.String(length=50), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["primary_source_id"], + ["source_record.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "classification_result", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("normalized_record_id", sa.Integer(), nullable=False), + sa.Column("taxonomy", sa.String(length=50), nullable=False), + sa.Column("category", sa.String(length=255), nullable=False), + sa.Column("tags", sa.String(), nullable=True), + sa.Column("classifier_kind", sa.String(length=50), nullable=False), + sa.Column("confidence", sa.Float(), nullable=True), + sa.Column("model_version", sa.String(length=50), nullable=True), + sa.Column("review_status", sa.String(length=50), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["normalized_record_id"], + ["normalized_record.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "record_link", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("source_record_id", sa.Integer(), nullable=False), + sa.Column("normalized_record_id", sa.Integer(), nullable=False), + sa.Column("relation_type", sa.String(length=50), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["normalized_record_id"], + ["normalized_record.id"], + ), + sa.ForeignKeyConstraint( + ["source_record_id"], + ["source_record.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("record_link") + op.drop_table("classification_result") + op.drop_table("normalized_record") + op.drop_table("source_record") + # ### end Alembic commands ### diff --git a/packages/finance/fastapi/src/alembic/versions/__pycache__/2bf6352a0475_fix_classification_result_tags_to_json.cpython-314.pyc b/packages/finance/fastapi/src/alembic/versions/__pycache__/2bf6352a0475_fix_classification_result_tags_to_json.cpython-314.pyc new file mode 100644 index 0000000..e9b92cb Binary files /dev/null and b/packages/finance/fastapi/src/alembic/versions/__pycache__/2bf6352a0475_fix_classification_result_tags_to_json.cpython-314.pyc differ diff --git a/packages/finance/fastapi/src/alembic/versions/__pycache__/571dd6946d4b_m1_add_core_entities.cpython-314.pyc b/packages/finance/fastapi/src/alembic/versions/__pycache__/571dd6946d4b_m1_add_core_entities.cpython-314.pyc new file mode 100644 index 0000000..83210d1 Binary files /dev/null and b/packages/finance/fastapi/src/alembic/versions/__pycache__/571dd6946d4b_m1_add_core_entities.cpython-314.pyc differ diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/__init__.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/app.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/app.py new file mode 100644 index 0000000..e4831b0 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/app.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from fastapi_quanttide_finance.routers import classifications, source_records, statistics + +app = FastAPI(title="QuantTide Finance Toolkit") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +app.include_router(source_records.router) +app.include_router(classifications.router) +app.include_router(statistics.router) diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/database.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/database.py new file mode 100644 index 0000000..71b1c43 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/database.py @@ -0,0 +1,30 @@ +import os +from pathlib import Path + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" +DATA_DIR.mkdir(exist_ok=True) + +# 支持 DEMO_DB 环境变量指向独立数据库(不碰生产库) +_demo_db = os.environ.get("DEMO_DB") +if _demo_db: + DATABASE_URL = f"sqlite:///{Path(_demo_db).resolve()}" +else: + DATABASE_URL = f"sqlite:///{DATA_DIR / 'quanttide_finance.db'}" + +engine = create_engine(DATABASE_URL, echo=False) +SessionLocal = sessionmaker(bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/models/__init__.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/__init__.py new file mode 100644 index 0000000..5f8bab0 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/__init__.py @@ -0,0 +1,13 @@ +from fastapi_quanttide_finance.models.source_record import SourceRecord +from fastapi_quanttide_finance.models.normalized_record import NormalizedRecord +from fastapi_quanttide_finance.models.record_link import RecordLink +from fastapi_quanttide_finance.models.classification_result import ( + ClassificationResult, +) + +__all__ = [ + "SourceRecord", + "NormalizedRecord", + "RecordLink", + "ClassificationResult", +] diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/models/classification_result.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/classification_result.py new file mode 100644 index 0000000..9b6a141 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/classification_result.py @@ -0,0 +1,39 @@ +from datetime import datetime + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Integer, + String, + func, +) +from sqlalchemy.dialects.sqlite import JSON + +from fastapi_quanttide_finance.database import Base + + +class ClassificationResult(Base): + __tablename__ = "classification_result" + + id = Column(Integer, primary_key=True, autoincrement=True) + normalized_record_id = Column( + Integer, ForeignKey("normalized_record.id"), nullable=False + ) + taxonomy = Column(String(50), nullable=False) + category = Column(String(255), nullable=False) + tags = Column(JSON, nullable=True) + classifier_kind = Column(String(50), nullable=False) + confidence = Column(Float, nullable=True) + model_version = Column(String(50), nullable=True) + review_status = Column(String(50), nullable=False, default="candidate") + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/models/normalized_record.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/normalized_record.py new file mode 100644 index 0000000..9a8e0b6 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/normalized_record.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String, Text, func + +from fastapi_quanttide_finance.database import Base + + +class NormalizedRecord(Base): + __tablename__ = "normalized_record" + + id = Column(Integer, primary_key=True, autoincrement=True) + primary_source_id = Column(Integer, ForeignKey("source_record.id"), nullable=True) + record_type = Column(String(50), nullable=False) + business_date = Column(Date, nullable=False) + amount_cents = Column(Integer, nullable=False, default=0) + currency = Column(String(10), nullable=False, default="CNY") + direction = Column(String(50), nullable=False) + department = Column(String(255), nullable=True) + person = Column(String(255), nullable=True) + counterparty = Column(String(255), nullable=True) + description = Column(String(1000), nullable=False, default="") + normalization_status = Column(String(50), nullable=False, default="draft") + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/models/record_link.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/record_link.py new file mode 100644 index 0000000..5043bcc --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/record_link.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func + +from fastapi_quanttide_finance.database import Base + + +class RecordLink(Base): + __tablename__ = "record_link" + + id = Column(Integer, primary_key=True, autoincrement=True) + source_record_id = Column(Integer, ForeignKey("source_record.id"), nullable=False) + normalized_record_id = Column( + Integer, ForeignKey("normalized_record.id"), nullable=False + ) + relation_type = Column(String(50), nullable=False) + created_at = Column(DateTime, nullable=False, server_default=func.now()) diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/models/source_record.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/source_record.py new file mode 100644 index 0000000..a81069d --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/models/source_record.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, Integer, String, Text, func +from sqlalchemy.dialects.sqlite import JSON + +from fastapi_quanttide_finance.database import Base + + +class SourceRecord(Base): + __tablename__ = "source_record" + + id = Column(Integer, primary_key=True, autoincrement=True) + source_type = Column(String(50), nullable=False) + source_channel = Column(String(50), nullable=True) + external_id = Column(String(255), nullable=True) + raw_payload = Column(JSON, nullable=True) + raw_text = Column(Text, nullable=False, default="") + evidence_refs = Column(JSON, nullable=True) + occurred_at = Column(DateTime, nullable=True) + ingestion_status = Column(String(50), nullable=False, default="pending") + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/__init__.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/classifications.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/classifications.py new file mode 100644 index 0000000..4844738 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/classifications.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from fastapi_quanttide_finance.database import get_db +from fastapi_quanttide_finance.models.classification_result import ClassificationResult +from fastapi_quanttide_finance.models.normalized_record import NormalizedRecord +from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationCreateRequest, + ClassificationResultResponse, + ClassificationReviewSchema, +) +from fastapi_quanttide_finance.services.classification import validate_category + +router = APIRouter() + + +@router.post( + "/normalized-records/{normalized_record_id}/classifications", + response_model=ClassificationResultResponse, + status_code=201, +) +def create_classification( + normalized_record_id: int, + body: ClassificationCreateRequest, + db: Session = Depends(get_db), +): + normalized = db.get(NormalizedRecord, normalized_record_id) + if normalized is None: + raise HTTPException(status_code=404, detail="NormalizedRecord not found") + + try: + validate_category(body.taxonomy, body.category) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + record = ClassificationResult( + normalized_record_id=normalized_record_id, + review_status="candidate", + is_active=True, + **body.model_dump(), + ) + db.add(record) + db.commit() + db.refresh(record) + return record + + +@router.get( + "/normalized-records/{normalized_record_id}/classifications", + response_model=list[ClassificationResultResponse], +) +def list_classifications( + normalized_record_id: int, + review_status: str | None = None, + db: Session = Depends(get_db), +): + if review_status is not None and review_status not in {"candidate", "accepted", "rejected"}: + raise HTTPException( + status_code=422, + detail=f"Invalid review_status: '{review_status}'. Allowed: candidate, accepted, rejected", + ) + + normalized = db.get(NormalizedRecord, normalized_record_id) + if normalized is None: + raise HTTPException(status_code=404, detail="NormalizedRecord not found") + + qb = db.query(ClassificationResult).filter( + ClassificationResult.normalized_record_id == normalized_record_id + ) + if review_status is not None: + qb = qb.filter(ClassificationResult.review_status == review_status) + return qb.order_by(ClassificationResult.created_at.desc()).all() + + +@router.patch( + "/classifications/{classification_id}", + response_model=ClassificationResultResponse, +) +def review_classification( + classification_id: int, + body: ClassificationReviewSchema, + db: Session = Depends(get_db), +): + record = db.get(ClassificationResult, classification_id) + if record is None: + raise HTTPException(status_code=404, detail="ClassificationResult not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(record, field, value) + + db.commit() + db.refresh(record) + return record diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/source_records.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/source_records.py new file mode 100644 index 0000000..e7c527c --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/source_records.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from fastapi_quanttide_finance.database import get_db +from fastapi_quanttide_finance.models.source_record import SourceRecord +from fastapi_quanttide_finance.models.normalized_record import NormalizedRecord +from fastapi_quanttide_finance.models.record_link import RecordLink +from fastapi_quanttide_finance.schemas.source_record import ( + SourceRecordCreate, + SourceRecordResponse, +) +from fastapi_quanttide_finance.schemas.normalized_record import ( + NormalizedRecordCreate, + NormalizedRecordResponse, + NormalizedRecordUpdate, +) +from fastapi_quanttide_finance.services.normalization import ( + NormalizeInput, + normalize, +) +from fastapi_quanttide_finance.services.normalizers import ( + CsvRowNormalizer, + ManualNormalizer, +) + +router = APIRouter() + +# Register built-in normalizers on module load +try: + from fastapi_quanttide_finance.services.normalization import register_normalizer + + register_normalizer(CsvRowNormalizer()) + register_normalizer(ManualNormalizer()) +except RuntimeError: + pass + + +@router.post( + "/source-records/{record_id}/normalize", + response_model=list[NormalizedRecordResponse], +) +def normalize_source_record(record_id: int, db: Session = Depends(get_db)): + source = db.get(SourceRecord, record_id) + if source is None: + raise HTTPException(status_code=404, detail="SourceRecord not found") + + input_data = NormalizeInput( + source_record_id=source.id, + raw_text=source.raw_text, + source_type=source.source_type, + ) + + try: + result = normalize(input_data) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + created_records = [] + for nr_data in result.normalized_records: + nr = NormalizedRecord(**nr_data, primary_source_id=source.id) + db.add(nr) + db.flush() + created_records.append(nr) + + for link_data in result.links: + nr_id = created_records[link_data["normalized_record_id"]].id + link = RecordLink( + source_record_id=link_data["source_record_id"], + normalized_record_id=nr_id, + relation_type=link_data["relation_type"], + ) + db.add(link) + + # 标准化成功后更新原始记录状态 + source.ingestion_status = "normalized" + + db.commit() + for nr in created_records: + db.refresh(nr) + return created_records + + +@router.get("/source-records", response_model=list[SourceRecordResponse]) +def list_source_records(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + return ( + db.query(SourceRecord) + .order_by(SourceRecord.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + +@router.get("/source-records/{record_id}", response_model=SourceRecordResponse) +def get_source_record(record_id: int, db: Session = Depends(get_db)): + record = db.get(SourceRecord, record_id) + if record is None: + raise HTTPException(status_code=404, detail="SourceRecord not found") + return record + + +@router.get("/normalized-records", response_model=list[NormalizedRecordResponse]) +def list_normalized_records( + source_record_id: int | None = None, + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), +): + qb = db.query(NormalizedRecord) + if source_record_id is not None: + qb = qb.filter(NormalizedRecord.primary_source_id == source_record_id) + return ( + qb.order_by(NormalizedRecord.created_at.desc()).offset(skip).limit(limit).all() + ) + + +@router.get("/normalized-records/{record_id}", response_model=NormalizedRecordResponse) +def get_normalized_record(record_id: int, db: Session = Depends(get_db)): + record = db.get(NormalizedRecord, record_id) + if record is None: + raise HTTPException(status_code=404, detail="NormalizedRecord not found") + return record + + +@router.post("/source-records", response_model=SourceRecordResponse, status_code=201) +def create_source_record(data: SourceRecordCreate, db: Session = Depends(get_db)): + record = SourceRecord(**data.model_dump()) + db.add(record) + db.commit() + db.refresh(record) + return record + + +@router.post("/normalized-records", response_model=NormalizedRecordResponse, status_code=201) +def create_normalized_record(data: NormalizedRecordCreate, db: Session = Depends(get_db)): + nr = NormalizedRecord(**data.model_dump()) + db.add(nr) + db.commit() + db.refresh(nr) + return nr + + +@router.patch("/normalized-records/{record_id}", response_model=NormalizedRecordResponse) +def update_normalized_record(record_id: int, data: NormalizedRecordUpdate, db: Session = Depends(get_db)): + nr = db.get(NormalizedRecord, record_id) + if nr is None: + raise HTTPException(status_code=404, detail="NormalizedRecord not found") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(nr, field, value) + db.commit() + db.refresh(nr) + return nr diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/statistics.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/statistics.py new file mode 100644 index 0000000..fc2cd02 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/routers/statistics.py @@ -0,0 +1,191 @@ +"""Statistics API router — summary, breakdown, trend, drilldown.""" + +import re +from datetime import date +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from fastapi_quanttide_finance.database import get_db +from fastapi_quanttide_finance.schemas.statistics import ( + StatisticsBreakdownResponse, + StatisticsDrilldownResponse, + StatisticsFilterParams, + StatisticsRow, + StatisticsSummaryResponse, + StatisticsTrendResponse, + StatisticsTrendRow, +) +from fastapi_quanttide_finance.services.statistics import ( + ALLOWED_DIMENSIONS, + GRANULARITY_FORMAT, + get_breakdown, + get_drilldown, + get_summary, + get_trend, +) + +router = APIRouter() + + +def _parse_filters( + from_date: Optional[date] = Query(default=None), + to_date: Optional[date] = Query(default=None), + department: Optional[str] = Query(default=None), + person: Optional[str] = Query(default=None), + counterparty: Optional[str] = Query(default=None), + record_type: Optional[str] = Query(default=None), + direction: Optional[str] = Query(default=None), + normalization_status: Optional[str] = Query(default=None), + currency: str = Query(default="CNY"), + taxonomy: Optional[str] = Query(default=None), + category: Optional[str] = Query(default=None), +) -> StatisticsFilterParams: + """Parse and validate query params manually, then build StatisticsFilterParams.""" + # Manual field validation + if record_type is not None: + allowed_rt = {"expense", "income", "transfer", "reimbursement", "other"} + if record_type not in allowed_rt: + raise HTTPException( + status_code=422, + detail=f"record_type must be one of: {', '.join(sorted(allowed_rt))}", + ) + if direction is not None: + allowed_dir = {"outflow", "inflow"} + if direction not in allowed_dir: + raise HTTPException( + status_code=422, + detail=f"direction must be one of: {', '.join(sorted(allowed_dir))}", + ) + if normalization_status is not None: + allowed_ns = {"draft", "normalized", "reviewed", "merged"} + if normalization_status not in allowed_ns: + raise HTTPException( + status_code=422, + detail=f"normalization_status must be one of: {', '.join(sorted(allowed_ns))}", + ) + if currency != "*" and not re.match(r"^[A-Z]{3}$", currency): + raise HTTPException( + status_code=422, + detail=f"Invalid currency '{currency}'. Use ISO 4217 code (e.g. CNY, USD) or '*' for all.", + ) + if (taxonomy is None) != (category is None): + raise HTTPException( + status_code=422, + detail="taxonomy and category must be provided together", + ) + if from_date is not None and to_date is not None and from_date > to_date: + raise HTTPException( + status_code=422, + detail="from_date must not be later than to_date", + ) + + return StatisticsFilterParams( + from_date=from_date, + to_date=to_date, + department=department, + person=person, + counterparty=counterparty, + record_type=record_type, + direction=direction, + normalization_status=normalization_status, + currency=currency, + taxonomy=taxonomy, + category=category, + ) + + +def _filters_to_dict(filters: StatisticsFilterParams) -> dict: + return filters.model_dump(exclude_none=True) + + +@router.get( + "/statistics/summary", + response_model=StatisticsSummaryResponse, +) +def list_summary( + filters: StatisticsFilterParams = Depends(_parse_filters), + db: Session = Depends(get_db), +): + result = get_summary(filters, db) + return { + "record_count": result["record_count"], + "amount_cents": result["amount_cents"], + "classified_count": result["classified_count"], + "filters": _filters_to_dict(filters), + } + + +@router.get( + "/statistics/breakdown", + response_model=StatisticsBreakdownResponse, +) +def list_breakdown( + dimension: str = Query(...), + filters: StatisticsFilterParams = Depends(_parse_filters), + db: Session = Depends(get_db), +): + if dimension not in ALLOWED_DIMENSIONS: + allowed = ", ".join(sorted(ALLOWED_DIMENSIONS)) + raise HTTPException( + status_code=422, + detail=f"Invalid dimension '{dimension}'. Allowed: {allowed}", + ) + + rows_data = get_breakdown(filters, dimension, db) + return { + "dimension": dimension, + "rows": [StatisticsRow(**r) for r in rows_data], + "filters": _filters_to_dict(filters), + } + + +@router.get( + "/statistics/trend", + response_model=StatisticsTrendResponse, +) +def list_trend( + granularity: str = "day", + filters: StatisticsFilterParams = Depends(_parse_filters), + db: Session = Depends(get_db), +): + if granularity not in GRANULARITY_FORMAT: + allowed = ", ".join(sorted(GRANULARITY_FORMAT)) + raise HTTPException( + status_code=422, + detail=f"Invalid granularity '{granularity}'. Allowed: {allowed}", + ) + + rows_data = get_trend(filters, granularity, db) + return { + "granularity": granularity, + "rows": [StatisticsTrendRow(**r) for r in rows_data], + "filters": _filters_to_dict(filters), + } + + +@router.get( + "/statistics/drilldown", + response_model=StatisticsDrilldownResponse, +) +def list_drilldown( + skip: int = 0, + limit: int = 50, + filters: StatisticsFilterParams = Depends(_parse_filters), + db: Session = Depends(get_db), +): + if limit > 200: + raise HTTPException( + status_code=422, + detail="limit must not exceed 200", + ) + + items, total = get_drilldown(filters, skip, limit, db) + return { + "items": items, + "total": total, + "skip": skip, + "limit": limit, + "filters": _filters_to_dict(filters), + } diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/__init__.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/classification_result.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/classification_result.py new file mode 100644 index 0000000..cd57af3 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/classification_result.py @@ -0,0 +1,152 @@ +from datetime import datetime +from typing import Optional + +from pydantic import field_validator +from pydantic import BaseModel as PydanticBase + + +class ClassificationResultCreate(PydanticBase): + normalized_record_id: int + taxonomy: str + category: str + tags: Optional[dict] = None + classifier_kind: str + confidence: Optional[float] = None + model_version: Optional[str] = None + review_status: str = "candidate" + is_active: bool = True + + @field_validator("taxonomy") + @classmethod + def validate_taxonomy(cls, v: str) -> str: + allowed = {"expense_type"} + if v not in allowed: + raise ValueError(f"taxonomy must be one of: {', '.join(sorted(allowed))}") + return v + + @field_validator("classifier_kind") + @classmethod + def validate_classifier_kind(cls, v: str) -> str: + allowed = {"ai", "rule", "manual"} + if v not in allowed: + raise ValueError( + f"classifier_kind must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("review_status") + @classmethod + def validate_review_status(cls, v: str) -> str: + allowed = {"candidate", "accepted", "rejected"} + if v not in allowed: + raise ValueError( + f"review_status must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("is_active") + @classmethod + def reject_null_is_active(cls, v): + """Reject explicit null for is_active — non-nullable at DB layer.""" + if v is None: + raise ValueError("is_active cannot be set to null") + return v + + +class ClassificationResultResponse(PydanticBase): + model_config = {"from_attributes": True} + + id: int + normalized_record_id: int + taxonomy: str + category: str + tags: Optional[dict] = None + classifier_kind: str + confidence: Optional[float] = None + model_version: Optional[str] = None + review_status: str = "candidate" + is_active: bool = True + created_at: datetime + updated_at: datetime + + +class ClassificationResultUpdate(PydanticBase): + model_config = {"extra": "forbid"} + + category: Optional[str] = None + tags: Optional[dict] = None + confidence: Optional[float] = None + review_status: Optional[str] = None + is_active: Optional[bool] = None + + @field_validator("review_status") + @classmethod + def validate_review_status(cls, v: str) -> str: + allowed = {"candidate", "accepted", "rejected"} + if v not in allowed: + raise ValueError( + f"review_status must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("is_active", mode="before") + @classmethod + def reject_null_is_active(cls, v): + """Reject explicit null for is_active — non-nullable at DB layer. + Uses mode='before' to catch JSON null before Pydantic skips validation for Optional types.""" + if v is None: + raise ValueError("is_active cannot be set to null") + return v + + +class ClassificationCreateRequest(PydanticBase): + model_config = {"extra": "forbid"} + + taxonomy: str = "expense_type" + category: str + tags: Optional[dict] = None + classifier_kind: str + confidence: Optional[float] = None + model_version: Optional[str] = None + + @field_validator("taxonomy") + @classmethod + def validate_taxonomy(cls, v: str) -> str: + allowed = {"expense_type"} + if v not in allowed: + raise ValueError(f"taxonomy must be one of: {', '.join(sorted(allowed))}") + return v + + @field_validator("classifier_kind") + @classmethod + def validate_classifier_kind(cls, v: str) -> str: + allowed = {"ai", "rule", "manual"} + if v not in allowed: + raise ValueError( + f"classifier_kind must be one of: {', '.join(sorted(allowed))}" + ) + return v + + +class ClassificationReviewSchema(PydanticBase): + model_config = {"extra": "forbid"} + + review_status: Optional[str] = None + is_active: Optional[bool] = None + + @field_validator("review_status") + @classmethod + def validate_review_status(cls, v: str) -> str: + allowed = {"candidate", "accepted", "rejected"} + if v not in allowed: + raise ValueError( + f"review_status must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("is_active", mode="before") + @classmethod + def reject_null_is_active(cls, v): + if v is None: + raise ValueError("is_active cannot be set to null") + return v diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/normalized_record.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/normalized_record.py new file mode 100644 index 0000000..a9172d4 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/normalized_record.py @@ -0,0 +1,86 @@ +from datetime import date, datetime +from typing import Optional + +from pydantic import Field, field_validator +from pydantic import BaseModel as PydanticBase + + +class NormalizedRecordCreate(PydanticBase): + primary_source_id: Optional[int] = None + record_type: str + business_date: date + amount_cents: int = Field(default=0, ge=0) + currency: str = "CNY" + direction: str + department: Optional[str] = None + person: Optional[str] = None + counterparty: Optional[str] = None + description: str = "" + normalization_status: str = "draft" + + @field_validator("record_type") + @classmethod + def validate_record_type(cls, v: str) -> str: + allowed = {"expense", "income", "transfer", "reimbursement", "other"} + if v not in allowed: + raise ValueError( + f"record_type must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("direction") + @classmethod + def validate_direction(cls, v: str) -> str: + allowed = {"outflow", "inflow"} + if v not in allowed: + raise ValueError(f"direction must be one of: {', '.join(sorted(allowed))}") + return v + + @field_validator("normalization_status") + @classmethod + def validate_normalization_status(cls, v: str) -> str: + allowed = {"draft", "normalized", "reviewed", "merged"} + if v not in allowed: + raise ValueError( + f"normalization_status must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("description") + @classmethod + def truncate_description(cls, v: str) -> str: + if len(v) > 1000: + return v[:1000] + return v + + +class NormalizedRecordResponse(PydanticBase): + model_config = {"from_attributes": True} + + id: int + primary_source_id: Optional[int] = None + record_type: str + business_date: date + amount_cents: int = 0 + currency: str = "CNY" + direction: str + department: Optional[str] = None + person: Optional[str] = None + counterparty: Optional[str] = None + description: str = "" + normalization_status: str = "draft" + created_at: datetime + updated_at: datetime + + +class NormalizedRecordUpdate(PydanticBase): + record_type: Optional[str] = None + business_date: Optional[date] = None + amount_cents: Optional[int] = Field(default=None, ge=0) + currency: Optional[str] = None + direction: Optional[str] = None + department: Optional[str] = None + person: Optional[str] = None + counterparty: Optional[str] = None + description: Optional[str] = None + normalization_status: Optional[str] = None diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/record_link.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/record_link.py new file mode 100644 index 0000000..454c30c --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/record_link.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from pydantic import field_validator +from pydantic import BaseModel as PydanticBase + + +class RecordLinkCreate(PydanticBase): + source_record_id: int + normalized_record_id: int + relation_type: str + + @field_validator("relation_type") + @classmethod + def validate_relation_type(cls, v: str) -> str: + allowed = {"primary", "supplementary", "split", "merged"} + if v not in allowed: + raise ValueError( + f"relation_type must be one of: {', '.join(sorted(allowed))}" + ) + return v + + +class RecordLinkResponse(PydanticBase): + model_config = {"from_attributes": True} + + id: int + source_record_id: int + normalized_record_id: int + relation_type: str + created_at: datetime diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/source_record.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/source_record.py new file mode 100644 index 0000000..41b054e --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/source_record.py @@ -0,0 +1,81 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, field_validator +from pydantic import BaseModel as PydanticBase + + +class SourceRecordCreate(PydanticBase): + source_type: str + source_channel: Optional[str] = None + external_id: Optional[str] = None + raw_payload: Optional[dict] = None + raw_text: str = "" + evidence_refs: Optional[dict] = None + occurred_at: Optional[datetime] = None + ingestion_status: str = "pending" + + @field_validator("source_type") + @classmethod + def validate_source_type(cls, v: str) -> str: + allowed = { + "image", + "chat", + "form", + "csv_row", + "bank_tx", + "api", + "manual", + "other", + } + if v not in allowed: + raise ValueError( + f"source_type must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("ingestion_status") + @classmethod + def validate_ingestion_status(cls, v: str) -> str: + allowed = {"pending", "parsed", "reviewed", "failed"} + if v not in allowed: + raise ValueError( + f"ingestion_status must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("raw_text") + @classmethod + def validate_raw_text_length(cls, v: str) -> str: + if len(v) > 65535: + raise ValueError( + f"raw_text exceeds maximum length of 65535 characters (got {len(v)})" + ) + return v + + +class SourceRecordResponse(PydanticBase): + model_config = {"from_attributes": True} + + id: int + source_type: str + source_channel: Optional[str] = None + external_id: Optional[str] = None + raw_payload: Optional[dict] = None + raw_text: str = "" + evidence_refs: Optional[dict] = None + occurred_at: Optional[datetime] = None + ingestion_status: str = "pending" + created_at: datetime + updated_at: datetime + + +class SourceRecordUpdate(PydanticBase): + source_type: Optional[str] = None + source_channel: Optional[str] = None + external_id: Optional[str] = None + raw_payload: Optional[dict] = None + raw_text: Optional[str] = None + evidence_refs: Optional[dict] = None + occurred_at: Optional[datetime] = None + ingestion_status: Optional[str] = None diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/statistics.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/statistics.py new file mode 100644 index 0000000..d8426be --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/schemas/statistics.py @@ -0,0 +1,123 @@ +import re +from datetime import date +from typing import Optional + +from pydantic import field_validator, model_validator +from pydantic import BaseModel as PydanticBase + +from fastapi_quanttide_finance.schemas.normalized_record import ( + NormalizedRecordResponse, +) + + +class StatisticsFilterParams(PydanticBase): + """Optional filters that apply to all statistics endpoints.""" + + from_date: Optional[date] = None + to_date: Optional[date] = None + department: Optional[str] = None + person: Optional[str] = None + counterparty: Optional[str] = None + record_type: Optional[str] = None + direction: Optional[str] = None + normalization_status: Optional[str] = None + currency: str = "CNY" + taxonomy: Optional[str] = None + category: Optional[str] = None + + @field_validator("record_type") + @classmethod + def validate_record_type(cls, v: str) -> str: + if v is None: + return v + allowed = {"expense", "income", "transfer", "reimbursement", "other"} + if v not in allowed: + raise ValueError( + f"record_type must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("direction") + @classmethod + def validate_direction(cls, v: str) -> str: + if v is None: + return v + allowed = {"outflow", "inflow"} + if v not in allowed: + raise ValueError(f"direction must be one of: {', '.join(sorted(allowed))}") + return v + + @field_validator("normalization_status") + @classmethod + def validate_normalization_status(cls, v: str) -> str: + if v is None: + return v + allowed = {"draft", "normalized", "reviewed", "merged"} + if v not in allowed: + raise ValueError( + f"normalization_status must be one of: {', '.join(sorted(allowed))}" + ) + return v + + @field_validator("currency") + @classmethod + def validate_currency(cls, v: str) -> str: + if v == "*": + return v + if not re.match(r"^[A-Z]{3}$", v): + raise ValueError( + f"Invalid currency '{v}'. Use ISO 4217 code (e.g. CNY, USD) or '*' for all." + ) + return v + + @model_validator(mode="after") + def check_taxonomy_category_pair(self): + if (self.taxonomy is None) != (self.category is None): + raise ValueError("taxonomy and category must be provided together") + return self + + @model_validator(mode="after") + def check_date_range(self): + if self.from_date is not None and self.to_date is not None: + if self.from_date > self.to_date: + raise ValueError("from_date must not be later than to_date") + return self + + +class StatisticsSummaryResponse(PydanticBase): + record_count: int = 0 + amount_cents: Optional[int] = 0 + classified_count: int = 0 + filters: dict + + +class StatisticsRow(PydanticBase): + key: Optional[str] = None + count: int = 0 + amount_cents: Optional[int] = 0 + + +class StatisticsBreakdownResponse(PydanticBase): + dimension: str + rows: list[StatisticsRow] + filters: dict + + +class StatisticsTrendRow(PydanticBase): + date: str + count: int = 0 + amount_cents: Optional[int] = 0 + + +class StatisticsTrendResponse(PydanticBase): + granularity: str + rows: list[StatisticsTrendRow] + filters: dict + + +class StatisticsDrilldownResponse(PydanticBase): + items: list[NormalizedRecordResponse] + total: int = 0 + skip: int = 0 + limit: int = 50 + filters: dict diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/services/__init__.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/services/classification.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/classification.py new file mode 100644 index 0000000..ad37ae3 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/classification.py @@ -0,0 +1,15 @@ +_TAXONOMY: dict[str, list[str]] = { + "expense_type": ["办公用品", "差旅", "采购", "工资", "其他"], +} + + +def validate_category(taxonomy: str, category: str) -> None: + """Raise ValueError if category is not in the taxonomy's allowed list.""" + allowed = _TAXONOMY.get(taxonomy) + if allowed is None: + raise ValueError(f"Unknown taxonomy: {taxonomy}") + if category not in allowed: + raise ValueError( + f"Invalid category '{category}' for taxonomy '{taxonomy}'. " + f"Allowed: {', '.join(sorted(allowed))}" + ) diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/services/normalization.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/normalization.py new file mode 100644 index 0000000..f58d441 --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/normalization.py @@ -0,0 +1,37 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + + +@dataclass +class NormalizeInput: + source_record_id: int + raw_text: str + source_type: str + + +@dataclass +class NormalizeResult: + normalized_records: list[dict] = field(default_factory=list) + links: list[dict] = field(default_factory=list) + + +class Normalizer(ABC): + @abstractmethod + def can_handle(self, source_type: str) -> bool: ... + + @abstractmethod + def normalize(self, input: NormalizeInput) -> NormalizeResult: ... + + +_normalizers: list[Normalizer] = [] + + +def register_normalizer(normalizer: Normalizer) -> None: + _normalizers.append(normalizer) + + +def normalize(input: NormalizeInput) -> NormalizeResult: + for normalizer in _normalizers: + if normalizer.can_handle(input.source_type): + return normalizer.normalize(input) + raise ValueError(f"No Normalizer registered for source_type={input.source_type}") diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/services/normalizers.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/normalizers.py new file mode 100644 index 0000000..b7c7fda --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/normalizers.py @@ -0,0 +1,114 @@ +import csv +import io +from datetime import date + +from fastapi_quanttide_finance.services.normalization import ( + NormalizeInput, + NormalizeResult, + Normalizer, +) + +CSV_COLUMN_MAP = { + "date": "business_date", + "description": "description", + "amount_cents": "amount_cents", + "direction": "direction", + "department": "department", + "person": "person", + "counterparty": "counterparty", + "currency": "currency", + "record_type": "record_type", +} + +OPTIONAL_COLUMNS = {"department", "person", "counterparty", "currency", "record_type"} +REQUIRED_COLUMNS = {"date", "description", "amount_cents", "direction"} + + +def _parse_value(key: str, value: str) -> date | int | str | None: + if value == "": + return None + if key == "amount_cents": + return int(value) + if key == "date": + parts = value.split("-") + return date(int(parts[0]), int(parts[1]), int(parts[2])) + return value + + +class CsvRowNormalizer(Normalizer): + def can_handle(self, source_type: str) -> bool: + return source_type == "csv_row" + + def normalize(self, input: NormalizeInput) -> NormalizeResult: + if not input.raw_text.strip(): + raise ValueError("CSV content is empty") + + reader = csv.DictReader(io.StringIO(input.raw_text)) + if reader.fieldnames is None or not reader.fieldnames: + raise ValueError("CSV must have a header row") + + has_expected_header = any(col in reader.fieldnames for col in CSV_COLUMN_MAP) + if not has_expected_header: + raise ValueError("CSV header does not contain expected columns") + + result = NormalizeResult() + for row in reader: + norms = { + "record_type": "expense", + "business_date": date.today(), + "amount_cents": 0, + "currency": "CNY", + "direction": "outflow", + "department": None, + "person": None, + "counterparty": None, + "description": "", + "normalization_status": "draft", + } + + for csv_col, model_field in CSV_COLUMN_MAP.items(): + if csv_col in row: + vals = _parse_value(csv_col, row[csv_col]) + if vals is not None: + norms[model_field] = vals + + result.normalized_records.append(norms) + result.links.append( + { + "source_record_id": input.source_record_id, + "normalized_record_id": len(result.normalized_records) - 1, + "relation_type": "primary", + } + ) + + return result + + +class ManualNormalizer(Normalizer): + def can_handle(self, source_type: str) -> bool: + return source_type == "manual" + + def normalize(self, input: NormalizeInput) -> NormalizeResult: + record = { + "record_type": "other", + "business_date": date.today(), + "amount_cents": 0, + "currency": "CNY", + "direction": "outflow", + "department": None, + "person": None, + "counterparty": None, + "description": input.raw_text, + "normalization_status": "draft", + } + result = NormalizeResult( + normalized_records=[record], + links=[ + { + "source_record_id": input.source_record_id, + "normalized_record_id": 0, + "relation_type": "primary", + } + ], + ) + return result diff --git a/packages/finance/fastapi/src/fastapi_quanttide_finance/services/statistics.py b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/statistics.py new file mode 100644 index 0000000..374716e --- /dev/null +++ b/packages/finance/fastapi/src/fastapi_quanttide_finance/services/statistics.py @@ -0,0 +1,219 @@ +"""Statistics query service — summary, breakdown, trend, drilldown.""" + +from sqlalchemy import exists, text +from sqlalchemy.orm import Session + +from fastapi_quanttide_finance.models.classification_result import ClassificationResult +from fastapi_quanttide_finance.models.normalized_record import NormalizedRecord +from fastapi_quanttide_finance.schemas.statistics import StatisticsFilterParams + +# Allowed dimensions for breakdown +ALLOWED_DIMENSIONS = { + "department", + "person", + "counterparty", + "record_type", + "direction", + "currency", +} + +# Granularity -> strftime format +GRANULARITY_FORMAT = { + "day": "%Y-%m-%d", + "week": "%Y-%W", + "month": "%Y-%m", +} + + +def build_where(filters: StatisticsFilterParams): + """Build WHERE clauses and params dict from filter params.""" + where = [] + params = {} + + if filters.from_date is not None: + where.append("nr.business_date >= :from_date") + params["from_date"] = filters.from_date + if filters.to_date is not None: + where.append("nr.business_date <= :to_date") + params["to_date"] = filters.to_date + if filters.department is not None: + where.append("nr.department = :department") + params["department"] = filters.department + if filters.person is not None: + where.append("nr.person = :person") + params["person"] = filters.person + if filters.counterparty is not None: + where.append("nr.counterparty = :counterparty") + params["counterparty"] = filters.counterparty + if filters.record_type is not None: + where.append("nr.record_type = :record_type") + params["record_type"] = filters.record_type + if filters.direction is not None: + where.append("nr.direction = :direction") + params["direction"] = filters.direction + if filters.normalization_status is not None: + where.append("nr.normalization_status = :normalization_status") + params["normalization_status"] = filters.normalization_status + if filters.currency is not None and filters.currency != "*": + where.append("nr.currency = :currency") + params["currency"] = filters.currency + if filters.taxonomy is not None and filters.category is not None: + where.append( + "EXISTS (" + "SELECT 1 FROM classification_result cr " + "WHERE cr.normalized_record_id = nr.id " + "AND cr.is_active = 1 " + "AND cr.review_status = 'accepted' " + "AND cr.taxonomy = :taxonomy " + "AND cr.category = :category" + ")" + ) + params["taxonomy"] = filters.taxonomy + params["category"] = filters.category + + return where, params + + +def _where_sql(where: list[str]) -> str: + """Join WHERE clauses with AND, or return empty string.""" + if not where: + return "" + return " WHERE " + " AND ".join(where) + + +def get_summary(filters: StatisticsFilterParams, db: Session) -> dict: + """Return record_count, amount_cents, classified_count.""" + where, params = build_where(filters) + ws = _where_sql(where) + + # Query A: record_count + amount_cents + row = db.execute( + text( + "SELECT COUNT(*), COALESCE(SUM(amount_cents), 0) " + "FROM normalized_record nr" + ws + ), + params, + ).one() + record_count = row[0] + amount_cents = row[1] + + # When currency='*', amount aggregation is meaningless + if filters.currency == "*": + amount_cents = None + + # Query B: classified_count (EXISTS subquery) + classified_sql = ( + "SELECT COUNT(*) FROM normalized_record nr " + "WHERE EXISTS (" + "SELECT 1 FROM classification_result cr " + "WHERE cr.normalized_record_id = nr.id " + "AND cr.is_active = 1 " + "AND cr.review_status = 'accepted'" + ")" + ) + if where: + classified_sql += " AND " + " AND ".join(where) + classified_row = db.execute(text(classified_sql), params).one() + classified_count = classified_row[0] + + return { + "record_count": record_count, + "amount_cents": amount_cents, + "classified_count": classified_count, + } + + +def get_breakdown( + filters: StatisticsFilterParams, dimension: str, db: Session +) -> list[dict]: + """Return grouped rows for a given dimension.""" + where, params = build_where(filters) + ws = _where_sql(where) + + sql = ( + f"SELECT nr.{dimension} AS key, " + f"COUNT(*) AS count, " + f"COALESCE(SUM(amount_cents), 0) AS amount_cents " + f"FROM normalized_record nr" + ws + + f" GROUP BY nr.{dimension} " + f"ORDER BY count DESC" + ) + + rows = [] + for row in db.execute(text(sql), params).all(): + amount = None if filters.currency == "*" else row[2] + rows.append({"key": row[0], "count": row[1], "amount_cents": amount}) + return rows + + +def get_trend( + filters: StatisticsFilterParams, granularity: str, db: Session +) -> list[dict]: + """Return time-series rows grouped by granularity.""" + fmt = GRANULARITY_FORMAT[granularity] + where, params = build_where(filters) + ws = _where_sql(where) + + sql = ( + f"SELECT strftime('{fmt}', nr.business_date) AS date, " + f"COUNT(*) AS count, " + f"COALESCE(SUM(amount_cents), 0) AS amount_cents " + f"FROM normalized_record nr" + ws + + f" GROUP BY strftime('{fmt}', nr.business_date) " + f"ORDER BY MIN(nr.business_date)" + ) + + rows = [] + for row in db.execute(text(sql), params).all(): + amount = None if filters.currency == "*" else row[2] + rows.append({"date": row[0], "count": row[1], "amount_cents": amount}) + return rows + + +def get_drilldown( + filters: StatisticsFilterParams, skip: int, limit: int, db: Session +) -> tuple[list[NormalizedRecord], int]: + """Return (items, total) for drilldown query.""" + qb = db.query(NormalizedRecord) + + if filters.from_date is not None: + qb = qb.filter(NormalizedRecord.business_date >= filters.from_date) + if filters.to_date is not None: + qb = qb.filter(NormalizedRecord.business_date <= filters.to_date) + if filters.department is not None: + qb = qb.filter(NormalizedRecord.department == filters.department) + if filters.person is not None: + qb = qb.filter(NormalizedRecord.person == filters.person) + if filters.counterparty is not None: + qb = qb.filter(NormalizedRecord.counterparty == filters.counterparty) + if filters.record_type is not None: + qb = qb.filter(NormalizedRecord.record_type == filters.record_type) + if filters.direction is not None: + qb = qb.filter(NormalizedRecord.direction == filters.direction) + if filters.normalization_status is not None: + qb = qb.filter( + NormalizedRecord.normalization_status == filters.normalization_status + ) + if filters.currency is not None and filters.currency != "*": + qb = qb.filter(NormalizedRecord.currency == filters.currency) + if filters.taxonomy is not None and filters.category is not None: + exists_clause = ( + exists() + .where( + ClassificationResult.normalized_record_id == NormalizedRecord.id, + ClassificationResult.is_active == True, + ClassificationResult.review_status == "accepted", + ClassificationResult.taxonomy == filters.taxonomy, + ClassificationResult.category == filters.category, + ) + ) + qb = qb.filter(exists_clause) + + total = qb.count() + items = ( + qb.order_by(NormalizedRecord.business_date.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return items, total diff --git a/packages/finance/fastapi/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..b17dfd5 Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/__pycache__/test_database.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/test_database.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..194e8de Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/test_database.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/__pycache__/test_health.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/test_health.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..a905005 Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/test_health.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/__pycache__/test_models.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/test_models.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..2607412 Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/test_models.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/__pycache__/test_normalizers.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/test_normalizers.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..a27c02f Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/test_normalizers.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/__pycache__/test_routes.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/test_routes.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..6dda33d Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/test_routes.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/__pycache__/test_schemas.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/test_schemas.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..885f598 Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/test_schemas.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/__pycache__/test_statistics.cpython-314-pytest-9.0.3.pyc b/packages/finance/fastapi/tests/__pycache__/test_statistics.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..a271793 Binary files /dev/null and b/packages/finance/fastapi/tests/__pycache__/test_statistics.cpython-314-pytest-9.0.3.pyc differ diff --git a/packages/finance/fastapi/tests/conftest.py b/packages/finance/fastapi/tests/conftest.py new file mode 100644 index 0000000..089a395 --- /dev/null +++ b/packages/finance/fastapi/tests/conftest.py @@ -0,0 +1,97 @@ +import tempfile +from collections.abc import Generator +from pathlib import Path + +import pytest +from alembic.command import upgrade +from alembic.config import Config +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from sqlalchemy import event +from sqlalchemy.engine import Engine + +from fastapi_quanttide_finance.app import app +from fastapi_quanttide_finance.database import get_db + + +@event.listens_for(Engine, "connect") +def _set_sqlite_pragma(dbapi_connection, connection_record): + """Enable foreign key enforcement for SQLite.""" + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +TEST_DATA_DIR = Path(__file__).resolve().parent.parent / "data" +TEST_DB_PATH = TEST_DATA_DIR / "test.db" +ALEMBIC_CFG = Path(__file__).resolve().parent.parent / "alembic.ini" + + +@pytest.fixture(scope="session") +def test_db_path() -> Generator[Path, None, None]: + TEST_DATA_DIR.mkdir(exist_ok=True) + if TEST_DB_PATH.exists(): + TEST_DB_PATH.unlink() + yield TEST_DB_PATH + if TEST_DB_PATH.exists(): + TEST_DB_PATH.unlink() + + +@pytest.fixture(scope="session") +def alembic_config(test_db_path: Path) -> Config: + config = Config(str(ALEMBIC_CFG)) + config.set_main_option("sqlalchemy.url", f"sqlite:///{test_db_path}") + return config + + +@pytest.fixture(scope="session") +def db_engine(test_db_path: Path, alembic_config: Config) -> Generator: + upgrade(alembic_config, "head") + engine = create_engine(f"sqlite:///{test_db_path}", echo=False) + yield engine + engine.dispose() + + +@pytest.fixture +def db_session(db_engine) -> Generator[Session, None, None]: + TestSessionLocal = sessionmaker(bind=db_engine) + session = TestSessionLocal() + try: + yield session + finally: + session.rollback() + session.close() + + +@pytest.fixture +def client(alembic_config) -> Generator[TestClient, None, None]: + """Test client with per-test isolated SQLite DB.""" + tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = tmp.name + tmp.close() + + # Run migrations on isolated database + alembic_config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + upgrade(alembic_config, "head") + + engine = create_engine( + f"sqlite:///{db_path}", connect_args={"check_same_thread": False} + ) + TestSessionLocal = sessionmaker(bind=engine) + + def override_get_db(): + db = TestSessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as c: + yield c + + app.dependency_overrides.clear() + engine.dispose() + Path(db_path).unlink(missing_ok=True) diff --git a/packages/finance/fastapi/tests/test_database.py b/packages/finance/fastapi/tests/test_database.py new file mode 100644 index 0000000..de3c695 --- /dev/null +++ b/packages/finance/fastapi/tests/test_database.py @@ -0,0 +1,14 @@ +from sqlalchemy import text + + +def test_db_connectivity(db_session): + result = db_session.execute(text("SELECT 1")) + assert result.scalar() == 1 + + +def test_db_has_tables(db_session): + result = db_session.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") + ) + tables = {row[0] for row in result} + assert "alembic_version" in tables diff --git a/packages/finance/fastapi/tests/test_health.py b/packages/finance/fastapi/tests/test_health.py new file mode 100644 index 0000000..c9c4731 --- /dev/null +++ b/packages/finance/fastapi/tests/test_health.py @@ -0,0 +1,4 @@ +def test_health(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/packages/finance/fastapi/tests/test_models.py b/packages/finance/fastapi/tests/test_models.py new file mode 100644 index 0000000..b251896 --- /dev/null +++ b/packages/finance/fastapi/tests/test_models.py @@ -0,0 +1,162 @@ +from datetime import date, datetime + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from fastapi_quanttide_finance.database import Base +from fastapi_quanttide_finance.models.source_record import SourceRecord +from fastapi_quanttide_finance.models.normalized_record import ( + NormalizedRecord, +) +from fastapi_quanttide_finance.models.record_link import RecordLink +from fastapi_quanttide_finance.models.classification_result import ( + ClassificationResult, +) + + +class TestSourceRecordModel: + def test_create_and_read(self, db_session): + record = SourceRecord( + source_type="csv_row", + raw_text="test,data,123", + ) + db_session.add(record) + db_session.commit() + + fetched = db_session.get(SourceRecord, record.id) + assert fetched is not None + assert fetched.source_type == "csv_row" + assert fetched.ingestion_status == "pending" + + def test_default_ingestion_status(self, db_session): + record = SourceRecord(source_type="manual") + db_session.add(record) + db_session.commit() + + assert record.ingestion_status == "pending" + + def test_timestamps_set_on_create(self, db_session): + record = SourceRecord(source_type="csv_row") + db_session.add(record) + db_session.commit() + + assert record.created_at is not None + assert record.updated_at is not None + + +class TestNormalizedRecordModel: + def test_create_and_read(self, db_session): + record = NormalizedRecord( + record_type="expense", + business_date=date(2026, 6, 1), + amount_cents=120000, + direction="outflow", + ) + db_session.add(record) + db_session.commit() + + fetched = db_session.get(NormalizedRecord, record.id) + assert fetched is not None + assert fetched.amount_cents == 120000 + assert fetched.currency == "CNY" + assert fetched.normalization_status == "draft" + + def test_default_currency_and_status(self, db_session): + record = NormalizedRecord( + record_type="income", + business_date=date(2026, 6, 1), + amount_cents=50000, + direction="inflow", + ) + db_session.add(record) + db_session.commit() + + assert record.currency == "CNY" + assert record.normalization_status == "draft" + + +class TestRecordLinkModel: + def test_create_and_read(self, db_session): + sr = SourceRecord(source_type="csv_row") + nr = NormalizedRecord( + record_type="expense", + business_date=date(2026, 6, 1), + amount_cents=120000, + direction="outflow", + ) + db_session.add_all([sr, nr]) + db_session.commit() + + link = RecordLink( + source_record_id=sr.id, + normalized_record_id=nr.id, + relation_type="primary", + ) + db_session.add(link) + db_session.commit() + + fetched = db_session.get(RecordLink, link.id) + assert fetched is not None + assert fetched.relation_type == "primary" + + def test_fk_violation_on_invalid_source(self, db_session): + link = RecordLink( + source_record_id=99999, + normalized_record_id=99999, + relation_type="primary", + ) + db_session.add(link) + with pytest.raises(IntegrityError): + db_session.commit() + db_session.rollback() + + +class TestClassificationResultModel: + def test_create_and_read(self, db_session): + sr = SourceRecord(source_type="csv_row") + nr = NormalizedRecord( + record_type="expense", + business_date=date(2026, 6, 1), + amount_cents=120000, + direction="outflow", + ) + db_session.add_all([sr, nr]) + db_session.commit() + + cr = ClassificationResult( + normalized_record_id=nr.id, + taxonomy="expense_type", + category="办公用品", + classifier_kind="manual", + ) + db_session.add(cr) + db_session.commit() + + fetched = db_session.get(ClassificationResult, cr.id) + assert fetched is not None + assert fetched.review_status == "candidate" + assert fetched.is_active is True + + def test_default_review_status_and_is_active(self, db_session): + sr = SourceRecord(source_type="csv_row") + nr = NormalizedRecord( + record_type="expense", + business_date=date(2026, 6, 1), + amount_cents=120000, + direction="outflow", + ) + db_session.add_all([sr, nr]) + db_session.commit() + + cr = ClassificationResult( + normalized_record_id=nr.id, + taxonomy="expense_type", + category="差旅", + classifier_kind="rule", + ) + db_session.add(cr) + db_session.commit() + + assert cr.review_status == "candidate" + assert cr.is_active is True diff --git a/packages/finance/fastapi/tests/test_normalizers.py b/packages/finance/fastapi/tests/test_normalizers.py new file mode 100644 index 0000000..fd3e12f --- /dev/null +++ b/packages/finance/fastapi/tests/test_normalizers.py @@ -0,0 +1,155 @@ +"""Tests for CsvRowNormalizer + ManualNormalizer (M2).""" + +import pytest + +from fastapi_quanttide_finance.services.normalization import ( + NormalizeInput, + NormalizeResult, + Normalizer, +) +from fastapi_quanttide_finance.services.normalizers import ( + CsvRowNormalizer, + ManualNormalizer, +) + + +class TestCsvRowNormalizer: + def setup_method(self): + self.normalizer = CsvRowNormalizer() + + def test_can_handle_csv_row(self): + assert self.normalizer.can_handle("csv_row") is True + assert self.normalizer.can_handle("manual") is False + + def test_normalize_full_row(self): + csv_text = ( + "date,description,amount_cents,direction,department,person," + "counterparty,currency,record_type\n" + "2026-06-01,办公用品采购,120000,outflow,研发部,张三,京东,CNY,expense" + ) + result = self.normalizer.normalize( + NormalizeInput(source_record_id=1, raw_text=csv_text, source_type="csv_row") + ) + assert len(result.normalized_records) == 1 + record = result.normalized_records[0] + from datetime import date + + assert record["business_date"] == date(2026, 6, 1) + assert record["description"] == "办公用品采购" + assert record["amount_cents"] == 120000 + assert record["direction"] == "outflow" + assert record["department"] == "研发部" + assert record["person"] == "张三" + assert record["counterparty"] == "京东" + assert record["currency"] == "CNY" + assert record["record_type"] == "expense" + + def test_normalize_multiple_rows(self): + csv_text = ( + "date,description,amount_cents,direction\n" + "2026-06-01,item1,1000,outflow\n" + "2026-06-02,item2,2000,inflow" + ) + result = self.normalizer.normalize( + NormalizeInput(source_record_id=1, raw_text=csv_text, source_type="csv_row") + ) + assert len(result.normalized_records) == 2 + assert result.normalized_records[0]["description"] == "item1" + assert result.normalized_records[1]["description"] == "item2" + + def test_generates_links_for_each_row(self): + csv_text = ( + "date,description,amount_cents,direction\n" + "2026-06-01,item1,1000,outflow\n" + "2026-06-02,item2,2000,inflow" + ) + result = self.normalizer.normalize( + NormalizeInput(source_record_id=1, raw_text=csv_text, source_type="csv_row") + ) + assert len(result.links) == 2 + for link in result.links: + assert link["source_record_id"] == 1 + assert link["relation_type"] == "primary" + + def test_uses_defaults_for_missing_fields(self): + csv_text = ( + "date,description,amount_cents,direction\n2026-06-01,test,500,outflow" + ) + result = self.normalizer.normalize( + NormalizeInput(source_record_id=1, raw_text=csv_text, source_type="csv_row") + ) + record = result.normalized_records[0] + assert record["currency"] == "CNY" + assert record["record_type"] == "expense" + assert record["department"] is None + assert record["person"] is None + assert record["counterparty"] is None + assert record["normalization_status"] == "draft" + + def test_rejects_empty_csv(self): + with pytest.raises(ValueError, match="empty"): + self.normalizer.normalize( + NormalizeInput(source_record_id=1, raw_text="", source_type="csv_row") + ) + + def test_rejects_csv_without_header(self): + with pytest.raises(ValueError, match="header"): + self.normalizer.normalize( + NormalizeInput( + source_record_id=1, + raw_text="data_only,without,header", + source_type="csv_row", + ) + ) + + +class TestManualNormalizer: + def setup_method(self): + self.normalizer = ManualNormalizer() + + def test_can_handle_manual(self): + assert self.normalizer.can_handle("manual") is True + assert self.normalizer.can_handle("csv_row") is False + + def test_normalize_sets_raw_text_as_description(self): + result = self.normalizer.normalize( + NormalizeInput( + source_record_id=1, + raw_text="购买办公用品A4纸5包", + source_type="manual", + ) + ) + assert len(result.normalized_records) == 1 + record = result.normalized_records[0] + assert record["description"] == "购买办公用品A4纸5包" + + def test_normalize_sets_sensible_defaults(self): + result = self.normalizer.normalize( + NormalizeInput( + source_record_id=1, + raw_text="test manual entry", + source_type="manual", + ) + ) + record = result.normalized_records[0] + assert record["record_type"] == "other" + assert record["direction"] == "outflow" + assert record["amount_cents"] == 0 + assert record["normalization_status"] == "draft" + + def test_generates_link(self): + result = self.normalizer.normalize( + NormalizeInput(source_record_id=42, raw_text="test", source_type="manual") + ) + assert len(result.links) == 1 + link = result.links[0] + assert link["source_record_id"] == 42 + assert link["normalized_record_id"] == 0 + assert link["relation_type"] == "primary" + + def test_handles_empty_text(self): + result = self.normalizer.normalize( + NormalizeInput(source_record_id=1, raw_text="", source_type="manual") + ) + assert len(result.normalized_records) == 1 + assert result.normalized_records[0]["description"] == "" diff --git a/packages/finance/fastapi/tests/test_routes.py b/packages/finance/fastapi/tests/test_routes.py new file mode 100644 index 0000000..cecba68 --- /dev/null +++ b/packages/finance/fastapi/tests/test_routes.py @@ -0,0 +1,332 @@ +"""Integration tests for M2 routes.""" + +import pytest + + +class TestListSourceRecords: + def test_list_empty(self, client): + response = client.get("/source-records") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_with_records(self, client): + client.post("/source-records", json={"source_type": "csv_row", "raw_text": "a"}) + client.post("/source-records", json={"source_type": "manual", "raw_text": "b"}) + response = client.get("/source-records") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + + +class TestGetSourceRecord: + def test_get_existing(self, client): + create_resp = client.post( + "/source-records", json={"source_type": "csv_row", "raw_text": "test"} + ) + record_id = create_resp.json()["id"] + response = client.get(f"/source-records/{record_id}") + assert response.status_code == 200 + assert response.json()["id"] == record_id + + def test_get_nonexistent(self, client): + response = client.get("/source-records/99999") + assert response.status_code == 404 + + +class TestListNormalizedRecords: + def test_list_empty(self, client): + response = client.get("/normalized-records") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_after_normalize(self, client): + create_resp = client.post( + "/source-records", + json={ + "source_type": "csv_row", + "raw_text": "date,description,amount_cents,direction\n2026-06-01,测试,1000,outflow", + }, + ) + record_id = create_resp.json()["id"] + client.post(f"/source-records/{record_id}/normalize") + response = client.get("/normalized-records") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["description"] == "测试" + + def test_list_filter_by_source(self, client): + create_resp = client.post( + "/source-records", + json={ + "source_type": "csv_row", + "raw_text": "date,description,amount_cents,direction\n2026-06-01,filter test,1000,outflow", + }, + ) + record_id = create_resp.json()["id"] + client.post(f"/source-records/{record_id}/normalize") + response = client.get(f"/normalized-records?source_record_id={record_id}") + assert response.status_code == 200 + assert len(response.json()) >= 1 + + +class TestGetNormalizedRecord: + def test_get_existing(self, client): + create_resp = client.post( + "/source-records", + json={ + "source_type": "csv_row", + "raw_text": "date,description,amount_cents,direction\n2026-06-01,get测试,1000,outflow", + }, + ) + record_id = create_resp.json()["id"] + norm_resp = client.post(f"/source-records/{record_id}/normalize") + norm_id = norm_resp.json()[0]["id"] + response = client.get(f"/normalized-records/{norm_id}") + assert response.status_code == 200 + assert response.json()["description"] == "get测试" + + def test_get_nonexistent(self, client): + response = client.get("/normalized-records/99999") + assert response.status_code == 404 + + +class TestCreateSourceRecord: + def test_creates_source_record(self, client): + response = client.post( + "/source-records", + json={"source_type": "csv_row", "raw_text": "a,b\n1,2"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["source_type"] == "csv_row" + assert data["id"] is not None + + def test_rejects_invalid_source_type(self, client): + response = client.post( + "/source-records", + json={"source_type": "invalid_type"}, + ) + assert response.status_code == 422 + + +class TestNormalizeSourceRecord: + def test_normalize_csv_row(self, client): + create_resp = client.post( + "/source-records", + json={ + "source_type": "csv_row", + "raw_text": ( + "date,description,amount_cents,direction\n" + "2026-06-01,办公用品,120000,outflow" + ), + }, + ) + record_id = create_resp.json()["id"] + + response = client.post(f"/source-records/{record_id}/normalize") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["description"] == "办公用品" + assert data[0]["amount_cents"] == 120000 + assert data[0]["direction"] == "outflow" + + def test_normalize_manual(self, client): + create_resp = client.post( + "/source-records", + json={ + "source_type": "manual", + "raw_text": "购买办公用品A4纸", + }, + ) + record_id = create_resp.json()["id"] + + response = client.post(f"/source-records/{record_id}/normalize") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["description"] == "购买办公用品A4纸" + assert data[0]["record_type"] == "other" + assert data[0]["normalization_status"] == "draft" + + def test_normalize_nonexistent_record(self, client): + response = client.post("/source-records/99999/normalize") + assert response.status_code == 404 + + def test_normalize_unsupported_type(self, client): + create_resp = client.post( + "/source-records", + json={"source_type": "image", "raw_text": "some image text"}, + ) + record_id = create_resp.json()["id"] + + response = client.post(f"/source-records/{record_id}/normalize") + assert response.status_code == 400 + assert "No Normalizer" in response.json()["detail"] + + +def _create_normalized_record(client): + """Helper to create a normalized record via the normalize flow.""" + create_resp = client.post( + "/source-records", + json={"source_type": "manual", "raw_text": "办公用品采购"}, + ) + record_id = create_resp.json()["id"] + norm_resp = client.post(f"/source-records/{record_id}/normalize") + return norm_resp.json()[0]["id"] + + +def _create_classification(client, normalized_record_id, category="办公用品", **extra): + """Helper to create a classification.""" + body = {"category": category, "classifier_kind": "manual", **extra} + return client.post( + f"/normalized-records/{normalized_record_id}/classifications", + json=body, + ) + + +class TestCreateClassification: + def test_create_candidate(self, client): + nr_id = _create_normalized_record(client) + response = _create_classification(client, nr_id) + assert response.status_code == 201 + data = response.json() + assert data["review_status"] == "candidate" + assert data["is_active"] is True + assert data["category"] == "办公用品" + + def test_create_invalid_category(self, client): + nr_id = _create_normalized_record(client) + response = _create_classification(client, nr_id, category="无效类别") + assert response.status_code == 400 + + def test_create_nonexistent_normalized_record(self, client): + response = _create_classification(client, normalized_record_id=99999) + assert response.status_code == 404 + + def test_create_rejects_extra_fields_in_body(self, client): + nr_id = _create_normalized_record(client) + response = client.post( + f"/normalized-records/{nr_id}/classifications", + json={ + "category": "办公用品", + "classifier_kind": "manual", + "normalized_record_id": 999, + }, + ) + assert response.status_code == 422 + + +class TestListClassifications: + def test_list_after_create(self, client): + nr_id = _create_normalized_record(client) + _create_classification(client, nr_id) + response = client.get(f"/normalized-records/{nr_id}/classifications") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["category"] == "办公用品" + + def test_list_empty(self, client): + nr_id = _create_normalized_record(client) + response = client.get(f"/normalized-records/{nr_id}/classifications") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_filter_by_review_status(self, client): + nr_id = _create_normalized_record(client) + resp_a = _create_classification(client, nr_id, category="办公用品") + cls_a_id = resp_a.json()["id"] + _create_classification(client, nr_id, category="办公用品") + # PATCH one to "accepted" + client.patch(f"/classifications/{cls_a_id}", json={"review_status": "accepted"}) + # Filter: should return 1 accepted + response = client.get( + f"/normalized-records/{nr_id}/classifications?review_status=accepted" + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["review_status"] == "accepted" + + def test_list_filter_invalid_status_returns_422(self, client): + nr_id = _create_normalized_record(client) + response = client.get( + f"/normalized-records/{nr_id}/classifications?review_status=invalid_typo" + ) + assert response.status_code == 422 + + def test_list_nonexistent_normalized_record(self, client): + response = client.get("/normalized-records/99999/classifications") + assert response.status_code == 404 + + +class TestReviewClassification: + def test_review_accept(self, client): + nr_id = _create_normalized_record(client) + create_resp = _create_classification(client, nr_id) + cls_id = create_resp.json()["id"] + response = client.patch( + f"/classifications/{cls_id}", json={"review_status": "accepted"} + ) + assert response.status_code == 200 + assert response.json()["review_status"] == "accepted" + + def test_review_reject(self, client): + nr_id = _create_normalized_record(client) + create_resp = _create_classification(client, nr_id) + cls_id = create_resp.json()["id"] + response = client.patch( + f"/classifications/{cls_id}", json={"review_status": "rejected"} + ) + assert response.status_code == 200 + assert response.json()["review_status"] == "rejected" + + def test_review_soft_delete(self, client): + nr_id = _create_normalized_record(client) + create_resp = _create_classification(client, nr_id) + cls_id = create_resp.json()["id"] + response = client.patch(f"/classifications/{cls_id}", json={"is_active": False}) + assert response.status_code == 200 + assert response.json()["is_active"] is False + + def test_review_invalid_status(self, client): + nr_id = _create_normalized_record(client) + create_resp = _create_classification(client, nr_id) + cls_id = create_resp.json()["id"] + response = client.patch( + f"/classifications/{cls_id}", json={"review_status": "invalid"} + ) + assert response.status_code == 422 + + def test_review_category_not_accepted(self, client): + nr_id = _create_normalized_record(client) + create_resp = _create_classification(client, nr_id) + cls_id = create_resp.json()["id"] + response = client.patch(f"/classifications/{cls_id}", json={"category": "采购"}) + assert response.status_code == 422 + + def test_review_nonexistent(self, client): + response = client.patch( + "/classifications/99999", json={"review_status": "accepted"} + ) + assert response.status_code == 404 + + def test_review_is_active_null_rejected(self, client): + nr_id = _create_normalized_record(client) + create_resp = _create_classification(client, nr_id) + cls_id = create_resp.json()["id"] + response = client.patch( + f"/classifications/{cls_id}", json={"is_active": None} + ) + assert response.status_code == 422 + + def test_review_noop_empty_body(self, client): + nr_id = _create_normalized_record(client) + create_resp = _create_classification(client, nr_id) + cls_id = create_resp.json()["id"] + response = client.patch(f"/classifications/{cls_id}", json={}) + assert response.status_code == 200 + data = response.json() + assert data["review_status"] == "candidate" + assert data["is_active"] is True diff --git a/packages/finance/fastapi/tests/test_schemas.py b/packages/finance/fastapi/tests/test_schemas.py new file mode 100644 index 0000000..7a1690a --- /dev/null +++ b/packages/finance/fastapi/tests/test_schemas.py @@ -0,0 +1,291 @@ +import pytest +from pydantic import ValidationError + +from fastapi_quanttide_finance.schemas.source_record import ( + SourceRecordCreate, +) +from fastapi_quanttide_finance.schemas.normalized_record import ( + NormalizedRecordCreate, +) +from fastapi_quanttide_finance.schemas.record_link import RecordLinkCreate +from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationResultCreate, + ClassificationResultUpdate, +) + + +class TestSourceRecordSchema: + def test_valid_minimal(self): + data = SourceRecordCreate(source_type="csv_row") + assert data.source_type == "csv_row" + assert data.ingestion_status == "pending" + + def test_valid_full(self): + data = SourceRecordCreate( + source_type="image", + source_channel="upload", + external_id="ext_001", + raw_text="报销单图片文字", + ingestion_status="parsed", + ) + assert data.source_type == "image" + + def test_invalid_source_type(self): + with pytest.raises(ValidationError): + SourceRecordCreate(source_type="invalid_type") + + def test_invalid_ingestion_status(self): + with pytest.raises(ValidationError): + SourceRecordCreate(source_type="csv_row", ingestion_status="invalid_status") + + def test_raw_text_overflow_rejected(self): + with pytest.raises(ValidationError) as excinfo: + SourceRecordCreate( + source_type="csv_row", + raw_text="x" * 65536, + ) + errors = excinfo.value.errors() + assert any("raw_text" in str(e["loc"]) for e in errors) + + +class TestNormalizedRecordSchema: + def test_valid_minimal(self): + data = NormalizedRecordCreate( + record_type="expense", + business_date="2026-06-01", + amount_cents=120000, + direction="outflow", + ) + assert data.amount_cents == 120000 + assert data.currency == "CNY" + assert data.normalization_status == "draft" + + def test_amount_cents_negative_rejected(self): + with pytest.raises(ValidationError): + NormalizedRecordCreate( + record_type="expense", + business_date="2026-06-01", + amount_cents=-1, + direction="outflow", + ) + + def test_amount_cents_zero_allowed(self): + data = NormalizedRecordCreate( + record_type="expense", + business_date="2026-06-01", + amount_cents=0, + direction="outflow", + ) + assert data.amount_cents == 0 + + def test_invalid_record_type(self): + with pytest.raises(ValidationError): + NormalizedRecordCreate( + record_type="invalid", + business_date="2026-06-01", + amount_cents=100, + direction="outflow", + ) + + def test_invalid_direction(self): + with pytest.raises(ValidationError): + NormalizedRecordCreate( + record_type="expense", + business_date="2026-06-01", + amount_cents=100, + direction="invalid", + ) + + def test_description_truncated_at_1000(self): + data = NormalizedRecordCreate( + record_type="expense", + business_date="2026-06-01", + amount_cents=100, + direction="outflow", + description="x" * 1001, + ) + assert len(data.description) == 1000 + + def test_invalid_normalization_status(self): + with pytest.raises(ValidationError): + NormalizedRecordCreate( + record_type="expense", + business_date="2026-06-01", + amount_cents=100, + direction="outflow", + normalization_status="invalid", + ) + + +class TestRecordLinkSchema: + def test_valid(self): + data = RecordLinkCreate( + source_record_id=1, + normalized_record_id=2, + relation_type="primary", + ) + assert data.relation_type == "primary" + + def test_invalid_relation_type(self): + with pytest.raises(ValidationError): + RecordLinkCreate( + source_record_id=1, + normalized_record_id=2, + relation_type="invalid", + ) + + +class TestClassificationResultSchema: + def test_valid_minimal(self): + data = ClassificationResultCreate( + normalized_record_id=1, + taxonomy="expense_type", + category="办公用品", + classifier_kind="manual", + ) + assert data.review_status == "candidate" + assert data.is_active is True + + def test_invalid_taxonomy(self): + with pytest.raises(ValidationError): + ClassificationResultCreate( + normalized_record_id=1, + taxonomy="invalid_taxonomy", + category="办公用品", + classifier_kind="manual", + ) + + def test_invalid_classifier_kind(self): + with pytest.raises(ValidationError): + ClassificationResultCreate( + normalized_record_id=1, + taxonomy="expense_type", + category="办公用品", + classifier_kind="invalid", + ) + + def test_invalid_review_status(self): + with pytest.raises(ValidationError): + ClassificationResultCreate( + normalized_record_id=1, + taxonomy="expense_type", + category="办公用品", + classifier_kind="manual", + review_status="invalid", + ) + + +class TestClassificationCreateRequestSchema: + def test_valid_minimal(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationCreateRequest, + ) + + data = ClassificationCreateRequest( + category="办公用品", + classifier_kind="manual", + ) + assert data.taxonomy == "expense_type" + assert data.category == "办公用品" + + def test_valid_full(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationCreateRequest, + ) + + data = ClassificationCreateRequest( + taxonomy="expense_type", + category="采购", + tags={"project": "A001"}, + classifier_kind="ai", + confidence=0.95, + model_version="v1.0", + ) + assert data.taxonomy == "expense_type" + assert data.confidence == 0.95 + + def test_invalid_taxonomy(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationCreateRequest, + ) + + with pytest.raises(ValidationError): + ClassificationCreateRequest( + taxonomy="business_tag", + category="采购", + classifier_kind="manual", + ) + + def test_invalid_classifier_kind(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationCreateRequest, + ) + + with pytest.raises(ValidationError): + ClassificationCreateRequest( + category="办公用品", + classifier_kind="invalid", + ) + + def test_extra_fields_rejected(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationCreateRequest, + ) + + with pytest.raises(ValidationError): + ClassificationCreateRequest( + category="办公用品", + classifier_kind="manual", + normalized_record_id=1, + ) + + +class TestClassificationReviewSchema: + def test_valid_review_status_accepted(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationReviewSchema, + ) + + data = ClassificationReviewSchema(review_status="accepted") + assert data.review_status == "accepted" + + def test_valid_review_status_rejected(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationReviewSchema, + ) + + data = ClassificationReviewSchema(review_status="rejected") + assert data.review_status == "rejected" + + def test_invalid_review_status(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationReviewSchema, + ) + + with pytest.raises(ValidationError): + ClassificationReviewSchema(review_status="invalid") + + def test_empty_body_allowed(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationReviewSchema, + ) + + data = ClassificationReviewSchema() + assert data.review_status is None + assert data.is_active is None + + def test_extra_fields_rejected(self): + from fastapi_quanttide_finance.schemas.classification_result import ( + ClassificationReviewSchema, + ) + + with pytest.raises(ValidationError): + ClassificationReviewSchema(category="办公用品") + + +class TestClassificationResultUpdateSchema: + def test_update_invalid_review_status(self): + with pytest.raises(ValidationError): + ClassificationResultUpdate( + review_status="invalid", + ) diff --git a/packages/finance/fastapi/tests/test_statistics.py b/packages/finance/fastapi/tests/test_statistics.py new file mode 100644 index 0000000..112a35c --- /dev/null +++ b/packages/finance/fastapi/tests/test_statistics.py @@ -0,0 +1,330 @@ +"""Tests for M4 Statistics API — summary, breakdown, trend, drilldown.""" + + +def _create_nr(client, raw_text=None): + """Helper: create source record + normalize, return normalized_record_id.""" + if raw_text is None: + raw_text = "date,description,amount_cents,direction\n2026-06-01,测试,1000,outflow" + resp = client.post("/source-records", json={"source_type": "csv_row", "raw_text": raw_text}) + record_id = resp.json()["id"] + norm_resp = client.post(f"/source-records/{record_id}/normalize") + return norm_resp.json()[0]["id"] + + +def _create_classification(client, nr_id, category="办公用品"): + """Helper: create classification (returns response).""" + return client.post( + f"/normalized-records/{nr_id}/classifications", + json={"category": category, "classifier_kind": "manual"}, + ) + + +def _create_accepted_classification(client, nr_id, category="办公用品"): + """Helper: create + accept a classification.""" + resp = _create_classification(client, nr_id, category) + cls_id = resp.json()["id"] + client.patch(f"/classifications/{cls_id}", json={"review_status": "accepted"}) + + +# ─── GET /statistics/summary ────────────────────────────────────────────────── + + +class TestSummary: + def test_empty_db(self, client): + response = client.get("/statistics/summary") + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 0 + assert data["amount_cents"] == 0 + assert data["classified_count"] == 0 + assert "filters" in data + + def test_basic_summary(self, client): + _create_nr(client) + _create_nr(client, raw_text="date,description,amount_cents,direction\n2026-06-02,测试2,2000,outflow") + response = client.get("/statistics/summary") + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 2 + assert data["amount_cents"] == 3000 + assert data["classified_count"] == 0 + + def test_classified_count(self, client): + nr_id = _create_nr(client) + _create_accepted_classification(client, nr_id) + response = client.get("/statistics/summary") + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 1 + assert data["classified_count"] == 1 + + def test_classified_count_deduplicates_exist(self, client): + """Multiple accepted classifications on same record -> count=1 (EXISTS dedup).""" + nr_id = _create_nr(client) + _create_accepted_classification(client, nr_id, "办公用品") + _create_accepted_classification(client, nr_id, "差旅") + response = client.get("/statistics/summary") + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 1 + assert data["classified_count"] == 1 + + def test_currency_star_returns_null_amount(self, client): + _create_nr(client) + response = client.get("/statistics/summary?currency=*") + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 1 + assert data["amount_cents"] is None + assert data["classified_count"] == 0 + + def test_currency_cny_returns_amount(self, client): + _create_nr(client) + response = client.get("/statistics/summary?currency=CNY") + assert response.status_code == 200 + data = response.json() + assert data["amount_cents"] == 1000 + + def test_taxonomy_category_filter_matches(self, client): + nr_id = _create_nr(client) + _create_accepted_classification(client, nr_id, "办公用品") + response = client.get( + "/statistics/summary?taxonomy=expense_type&category=%E5%8A%9E%E5%85%AC%E7%94%A8%E5%93%81" + ) + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 1 + + def test_taxonomy_category_filter_no_match(self, client): + nr_id = _create_nr(client) + _create_accepted_classification(client, nr_id, "办公用品") + response = client.get( + "/statistics/summary?taxonomy=expense_type&category=%E5%B7%AE%E6%97%85" + ) + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 0 + + def test_taxonomy_without_category_returns_422(self, client): + response = client.get("/statistics/summary?taxonomy=expense_type") + assert response.status_code == 422 + + def test_category_without_taxonomy_returns_422(self, client): + response = client.get("/statistics/summary?category=%E5%8A%9E%E5%85%AC%E7%94%A8%E5%93%81") + assert response.status_code == 422 + + def test_from_date_gt_to_date_returns_422(self, client): + response = client.get( + "/statistics/summary?from_date=2026-06-10&to_date=2026-06-01" + ) + assert response.status_code == 422 + + def test_invalid_record_type_returns_422(self, client): + response = client.get("/statistics/summary?record_type=invalid") + assert response.status_code == 422 + + def test_invalid_currency_returns_422(self, client): + response = client.get("/statistics/summary?currency=INVALID") + assert response.status_code == 422 + + def test_invalid_direction_returns_422(self, client): + response = client.get("/statistics/summary?direction=nowhere") + assert response.status_code == 422 + + def test_department_filter(self, client): + csv = "date,description,amount_cents,direction,department\n2026-06-01,研发支出,5000,outflow,研发部" + _create_nr(client, csv) + csv2 = "date,description,amount_cents,direction,department\n2026-06-01,市场费用,3000,outflow,市场部" + _create_nr(client, csv2) + response = client.get("/statistics/summary?department=%E7%A0%94%E5%8F%91%E9%83%A8") + assert response.status_code == 200 + data = response.json() + assert data["record_count"] == 1 + assert data["amount_cents"] == 5000 + + +# GET /statistics/breakdown + + +class TestBreakdown: + def test_by_department(self, client): + csv_rnd = "date,description,amount_cents,direction,department\n2026-06-01,研发,5000,outflow,研发部" + csv_mkt = "date,description,amount_cents,direction,department\n2026-06-01,市场,3000,outflow,市场部" + _create_nr(client, csv_rnd) + _create_nr(client, csv_mkt) + response = client.get("/statistics/breakdown?dimension=department") + assert response.status_code == 200 + data = response.json() + assert data["dimension"] == "department" + assert len(data["rows"]) == 2 + keys = {r["key"] for r in data["rows"]} + assert keys == {"研发部", "市场部"} + + def test_by_record_type(self, client): + _create_nr(client) + response = client.get("/statistics/breakdown?dimension=record_type") + assert response.status_code == 200 + data = response.json() + assert data["dimension"] == "record_type" + assert len(data["rows"]) >= 1 + + def test_null_dimension(self, client): + """Department is NULL -> grouped as null key.""" + csv = "date,description,amount_cents,direction\n2026-06-01,无部门,1000,outflow" + _create_nr(client, csv) + response = client.get("/statistics/breakdown?dimension=department") + assert response.status_code == 200 + data = response.json() + null_rows = [r for r in data["rows"] if r["key"] is None] + assert len(null_rows) >= 1 + + def test_invalid_dimension_returns_422_with_allowed_list(self, client): + response = client.get("/statistics/breakdown?dimension=invalid") + assert response.status_code == 422 + body = response.json() + assert "department" in body["detail"] + + def test_missing_dimension_returns_422(self, client): + response = client.get("/statistics/breakdown") + assert response.status_code == 422 + + def test_currency_star_returns_null_amount(self, client): + _create_nr(client) + response = client.get("/statistics/breakdown?dimension=record_type¤cy=*") + assert response.status_code == 200 + data = response.json() + assert data["dimension"] == "record_type" + for row in data["rows"]: + assert row["amount_cents"] is None + + +# GET /statistics/trend + + +class TestTrend: + def test_by_day(self, client): + _create_nr(client) + response = client.get("/statistics/trend?granularity=day") + assert response.status_code == 200 + data = response.json() + assert data["granularity"] == "day" + assert len(data["rows"]) >= 1 + assert data["rows"][0]["date"] == "2026-06-01" + assert data["rows"][0]["count"] == 1 + + def test_by_month(self, client): + _create_nr(client) + response = client.get("/statistics/trend?granularity=month") + assert response.status_code == 200 + data = response.json() + assert data["granularity"] == "month" + assert data["rows"][0]["date"] == "2026-06" + assert data["rows"][0]["count"] == 1 + + def test_invalid_granularity_returns_422(self, client): + response = client.get("/statistics/trend?granularity=year") + assert response.status_code == 422 + + def test_default_granularity_is_day(self, client): + _create_nr(client) + response = client.get("/statistics/trend") + assert response.status_code == 200 + data = response.json() + assert data["granularity"] == "day" + + def test_currency_star_returns_null_amount(self, client): + _create_nr(client) + response = client.get("/statistics/trend?granularity=day¤cy=*") + assert response.status_code == 200 + data = response.json() + for row in data["rows"]: + assert row["amount_cents"] is None + + def test_no_empty_periods(self, client): + """Periods with no data don't appear.""" + response = client.get("/statistics/trend?granularity=month") + assert response.status_code == 200 + data = response.json() + assert len(data["rows"]) == 0 + + +# GET /statistics/drilldown + + +class TestDrilldown: + def test_basic_pagination(self, client): + _create_nr(client) + response = client.get("/statistics/drilldown") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 1 + assert "id" in data["items"][0] + assert "record_type" in data["items"][0] + + def test_items_use_normalized_record_response_schema(self, client): + """Items should contain NormalizedRecordResponse fields.""" + _create_nr(client) + response = client.get("/statistics/drilldown") + data = response.json() + item = data["items"][0] + assert "id" in item + assert "record_type" in item + assert "business_date" in item + assert "amount_cents" in item + assert "direction" in item + assert "created_at" in item + + def test_total_matches_record_count(self, client): + _create_nr(client) + _create_nr(client) + response = client.get("/statistics/drilldown") + data = response.json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + + def test_skip_limit(self, client): + _create_nr(client) + _create_nr(client) + _create_nr(client) + response = client.get("/statistics/drilldown?skip=1&limit=1") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 3 + assert len(data["items"]) == 1 + assert data["skip"] == 1 + assert data["limit"] == 1 + + def test_limit_exceeds_max_returns_422(self, client): + response = client.get("/statistics/drilldown?limit=201") + assert response.status_code == 422 + + def test_limit_200_is_ok(self, client): + response = client.get("/statistics/drilldown?limit=200") + assert response.status_code == 200 + + def test_skip_beyond_total(self, client): + _create_nr(client) + response = client.get("/statistics/drilldown?skip=100") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 1 + + def test_empty_db(self, client): + response = client.get("/statistics/drilldown") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_department_filter(self, client): + csv = "date,description,amount_cents,direction,department\n2026-06-01,研发支出,5000,outflow,研发部" + _create_nr(client, csv) + csv2 = "date,description,amount_cents,direction,department\n2026-06-01,市场费用,3000,outflow,市场部" + _create_nr(client, csv2) + response = client.get("/statistics/drilldown?department=%E7%A0%94%E5%8F%91%E9%83%A8") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["amount_cents"] == 5000 diff --git a/packages/finance/flutter/.dart_tool/package_config.json b/packages/finance/flutter/.dart_tool/package_config.json new file mode 100644 index 0000000..18b0dad --- /dev/null +++ b/packages/finance/flutter/.dart_tool/package_config.json @@ -0,0 +1,412 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "_fe_analyzer_shared", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/_fe_analyzer_shared-100.0.0", + "packageUri": "lib/", + "languageVersion": "3.11" + }, + { + "name": "analyzer", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/analyzer-13.0.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "args", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/args-2.7.0", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "async", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/async-2.13.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "boolean_selector", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "build", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build-4.0.6", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "build_config", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build_config-1.3.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "build_daemon", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build_daemon-4.1.1", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "build_runner", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/build_runner-2.15.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "built_collection", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/built_collection-5.1.1", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "built_value", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/built_value-8.12.6", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "characters", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/characters-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "checked_yaml", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/checked_yaml-2.0.4", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "clock", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/clock-1.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "code_builder", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/code_builder-4.11.1", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "collection", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "convert", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/convert-3.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "crypto", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/crypto-3.0.7", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "dart_style", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/dart_style-3.1.9", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "fake_async", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/fake_async-1.3.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "file", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/file-7.0.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "fixnum", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/fixnum-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "flutter", + "rootUri": "file:///home/linli/development/flutter/packages/flutter", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "flutter_test", + "rootUri": "file:///home/linli/development/flutter/packages/flutter_test", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "freezed_annotation", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/freezed_annotation-3.1.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "glob", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/glob-2.1.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "graphs", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/graphs-2.3.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "http", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/http-1.6.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "http_multi_server", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/http_multi_server-3.2.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "http_parser", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/http_parser-4.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "io", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/io-1.0.5", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "json_annotation", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/json_annotation-4.12.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "leak_tracker", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker-11.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_flutter_testing", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_flutter_testing-3.0.10", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_testing", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_testing-3.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "logging", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/logging-1.3.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "matcher", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/matcher-0.12.19", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "material_color_utilities", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/material_color_utilities-0.13.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "meta", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/meta-1.18.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "mime", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/mime-2.0.0", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "mockito", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/mockito-5.7.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "package_config", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/package_config-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "path", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pool", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/pool-1.5.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pub_semver", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/pub_semver-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pubspec_parse", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/pubspec_parse-1.5.0", + "packageUri": "lib/", + "languageVersion": "3.6" + }, + { + "name": "quanttide_finance", + "rootUri": "../../dart", + "packageUri": "lib/", + "languageVersion": "3.11" + }, + { + "name": "shelf", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/shelf-1.4.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "shelf_web_socket", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/shelf_web_socket-3.0.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "sky_engine", + "rootUri": "file:///home/linli/development/flutter/bin/cache/pkg/sky_engine", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "source_gen", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_gen-4.2.3", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "source_span", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_span-1.10.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "stack_trace", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "stream_transform", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stream_transform-2.1.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "string_scanner", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test_api", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/test_api-0.7.11", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "typed_data", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/typed_data-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "vector_math", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/vector_math-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "vm_service", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/vm_service-15.2.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "watcher", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/watcher-1.2.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/web-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web_socket", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/web_socket-1.0.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web_socket_channel", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/web_socket_channel-3.0.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "yaml", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/yaml-3.1.3", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "flutter_quanttide_finance", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.11" + } + ], + "generator": "pub", + "generatorVersion": "3.12.0", + "flutterRoot": "file:///home/linli/development/flutter", + "flutterVersion": "3.44.0", + "pubCache": "file:///home/linli/.pub-cache" +} diff --git a/packages/finance/flutter/.dart_tool/package_graph.json b/packages/finance/flutter/.dart_tool/package_graph.json new file mode 100644 index 0000000..f0504d8 --- /dev/null +++ b/packages/finance/flutter/.dart_tool/package_graph.json @@ -0,0 +1,623 @@ +{ + "roots": [ + "flutter_quanttide_finance" + ], + "packages": [ + { + "name": "flutter_quanttide_finance", + "version": "0.1.0", + "dependencies": [ + "flutter", + "http", + "quanttide_finance" + ], + "devDependencies": [ + "build_runner", + "flutter_test", + "mockito" + ] + }, + { + "name": "flutter_test", + "version": "0.0.0", + "dependencies": [ + "clock", + "collection", + "fake_async", + "flutter", + "leak_tracker_flutter_testing", + "matcher", + "meta", + "path", + "stack_trace", + "stream_channel", + "test_api", + "vector_math" + ] + }, + { + "name": "quanttide_finance", + "version": "0.2.0", + "dependencies": [ + "freezed_annotation", + "json_annotation" + ] + }, + { + "name": "flutter", + "version": "0.0.0", + "dependencies": [ + "characters", + "collection", + "material_color_utilities", + "meta", + "sky_engine", + "vector_math" + ] + }, + { + "name": "vector_math", + "version": "2.2.0", + "dependencies": [] + }, + { + "name": "test_api", + "version": "0.7.11", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "meta", + "version": "1.18.0", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.19", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "dependencies": [ + "flutter", + "leak_tracker", + "leak_tracker_testing", + "matcher", + "meta" + ] + }, + { + "name": "fake_async", + "version": "1.3.3", + "dependencies": [ + "clock", + "collection" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "clock", + "version": "1.1.2", + "dependencies": [] + }, + { + "name": "freezed_annotation", + "version": "3.1.0", + "dependencies": [ + "collection", + "json_annotation", + "meta" + ] + }, + { + "name": "sky_engine", + "version": "0.0.0", + "dependencies": [] + }, + { + "name": "material_color_utilities", + "version": "0.13.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "characters", + "version": "1.4.1", + "dependencies": [] + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "dependencies": [ + "leak_tracker", + "matcher", + "meta" + ] + }, + { + "name": "leak_tracker", + "version": "11.0.2", + "dependencies": [ + "clock", + "collection", + "meta", + "path", + "vm_service" + ] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "json_annotation", + "version": "4.12.0", + "dependencies": [ + "meta" + ] + }, + { + "name": "async", + "version": "2.13.1", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.2", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "http", + "version": "1.6.0", + "dependencies": [ + "async", + "http_parser", + "meta", + "web" + ] + }, + { + "name": "web", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "http_parser", + "version": "4.1.2", + "dependencies": [ + "collection", + "source_span", + "string_scanner", + "typed_data" + ] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "mockito", + "version": "5.7.0", + "dependencies": [ + "analyzer", + "build", + "code_builder", + "collection", + "dart_style", + "matcher", + "meta", + "path", + "source_gen", + "test_api" + ] + }, + { + "name": "analyzer", + "version": "13.0.0", + "dependencies": [ + "_fe_analyzer_shared", + "collection", + "convert", + "crypto", + "glob", + "meta", + "package_config", + "path", + "pub_semver", + "source_span", + "watcher", + "yaml" + ] + }, + { + "name": "_fe_analyzer_shared", + "version": "100.0.0", + "dependencies": [ + "meta", + "source_span" + ] + }, + { + "name": "pub_semver", + "version": "2.2.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "yaml", + "version": "3.1.3", + "dependencies": [ + "collection", + "source_span", + "string_scanner" + ] + }, + { + "name": "package_config", + "version": "2.2.0", + "dependencies": [ + "path" + ] + }, + { + "name": "convert", + "version": "3.1.2", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "watcher", + "version": "1.2.1", + "dependencies": [ + "async", + "path" + ] + }, + { + "name": "glob", + "version": "2.1.3", + "dependencies": [ + "async", + "collection", + "file", + "path", + "string_scanner" + ] + }, + { + "name": "file", + "version": "7.0.1", + "dependencies": [ + "meta", + "path" + ] + }, + { + "name": "crypto", + "version": "3.0.7", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "code_builder", + "version": "4.11.1", + "dependencies": [ + "built_collection", + "built_value", + "collection", + "matcher", + "meta" + ] + }, + { + "name": "built_collection", + "version": "5.1.1", + "dependencies": [] + }, + { + "name": "build", + "version": "4.0.6", + "dependencies": [ + "analyzer", + "crypto", + "glob", + "logging", + "package_config", + "path" + ] + }, + { + "name": "logging", + "version": "1.3.0", + "dependencies": [] + }, + { + "name": "source_gen", + "version": "4.2.3", + "dependencies": [ + "analyzer", + "async", + "build", + "dart_style", + "glob", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "dart_style", + "version": "3.1.9", + "dependencies": [ + "analyzer", + "args", + "collection", + "package_config", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "args", + "version": "2.7.0", + "dependencies": [] + }, + { + "name": "built_value", + "version": "8.12.6", + "dependencies": [ + "built_collection", + "collection", + "fixnum", + "meta" + ] + }, + { + "name": "fixnum", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "vm_service", + "version": "15.2.0", + "dependencies": [] + }, + { + "name": "build_runner", + "version": "2.15.0", + "dependencies": [ + "analyzer", + "args", + "async", + "build", + "build_config", + "build_daemon", + "built_collection", + "built_value", + "collection", + "convert", + "crypto", + "dart_style", + "glob", + "graphs", + "http_multi_server", + "io", + "json_annotation", + "logging", + "meta", + "mime", + "package_config", + "path", + "pool", + "pub_semver", + "shelf", + "shelf_web_socket", + "stream_transform", + "watcher", + "web_socket_channel", + "yaml" + ] + }, + { + "name": "build_config", + "version": "1.3.0", + "dependencies": [ + "checked_yaml", + "json_annotation", + "path", + "pubspec_parse" + ] + }, + { + "name": "pool", + "version": "1.5.2", + "dependencies": [ + "async", + "stack_trace" + ] + }, + { + "name": "stream_transform", + "version": "2.1.1", + "dependencies": [] + }, + { + "name": "graphs", + "version": "2.3.2", + "dependencies": [ + "collection" + ] + }, + { + "name": "io", + "version": "1.0.5", + "dependencies": [ + "meta", + "path", + "string_scanner" + ] + }, + { + "name": "http_multi_server", + "version": "3.2.2", + "dependencies": [ + "async" + ] + }, + { + "name": "checked_yaml", + "version": "2.0.4", + "dependencies": [ + "json_annotation", + "source_span", + "yaml" + ] + }, + { + "name": "build_daemon", + "version": "4.1.1", + "dependencies": [ + "built_collection", + "built_value", + "crypto", + "http_multi_server", + "logging", + "path", + "pool", + "shelf", + "shelf_web_socket", + "stream_transform", + "watcher", + "web_socket_channel" + ] + }, + { + "name": "shelf_web_socket", + "version": "3.0.0", + "dependencies": [ + "shelf", + "stream_channel", + "web_socket_channel" + ] + }, + { + "name": "mime", + "version": "2.0.0", + "dependencies": [] + }, + { + "name": "pubspec_parse", + "version": "1.5.0", + "dependencies": [ + "checked_yaml", + "collection", + "json_annotation", + "pub_semver", + "yaml" + ] + }, + { + "name": "web_socket_channel", + "version": "3.0.3", + "dependencies": [ + "async", + "crypto", + "stream_channel", + "web", + "web_socket" + ] + }, + { + "name": "web_socket", + "version": "1.0.1", + "dependencies": [ + "web" + ] + }, + { + "name": "shelf", + "version": "1.4.2", + "dependencies": [ + "async", + "collection", + "http_parser", + "path", + "stack_trace", + "stream_channel" + ] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/packages/finance/flutter/.dart_tool/version b/packages/finance/flutter/.dart_tool/version new file mode 100644 index 0000000..df6486e --- /dev/null +++ b/packages/finance/flutter/.dart_tool/version @@ -0,0 +1 @@ +3.44.0 \ No newline at end of file diff --git a/packages/finance/flutter/build/native_assets/linux/native_assets.json b/packages/finance/flutter/build/native_assets/linux/native_assets.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/packages/finance/flutter/build/native_assets/linux/native_assets.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/packages/finance/flutter/build/test_cache/build/e526d636a6238c5a01b25d33d78dd941.cache.dill.track.dill b/packages/finance/flutter/build/test_cache/build/e526d636a6238c5a01b25d33d78dd941.cache.dill.track.dill new file mode 100644 index 0000000..f549650 Binary files /dev/null and b/packages/finance/flutter/build/test_cache/build/e526d636a6238c5a01b25d33d78dd941.cache.dill.track.dill differ diff --git a/packages/finance/flutter/build/unit_test_assets/AssetManifest.bin b/packages/finance/flutter/build/unit_test_assets/AssetManifest.bin new file mode 100644 index 0000000..86d111f Binary files /dev/null and b/packages/finance/flutter/build/unit_test_assets/AssetManifest.bin differ diff --git a/packages/finance/flutter/build/unit_test_assets/FontManifest.json b/packages/finance/flutter/build/unit_test_assets/FontManifest.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/packages/finance/flutter/build/unit_test_assets/FontManifest.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/packages/finance/flutter/build/unit_test_assets/NOTICES.Z b/packages/finance/flutter/build/unit_test_assets/NOTICES.Z new file mode 100644 index 0000000..22212be Binary files /dev/null and b/packages/finance/flutter/build/unit_test_assets/NOTICES.Z differ diff --git a/packages/finance/flutter/build/unit_test_assets/NativeAssetsManifest.json b/packages/finance/flutter/build/unit_test_assets/NativeAssetsManifest.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/packages/finance/flutter/build/unit_test_assets/NativeAssetsManifest.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/packages/finance/flutter/build/unit_test_assets/shaders/ink_sparkle.frag b/packages/finance/flutter/build/unit_test_assets/shaders/ink_sparkle.frag new file mode 100644 index 0000000..ebd8066 Binary files /dev/null and b/packages/finance/flutter/build/unit_test_assets/shaders/ink_sparkle.frag differ diff --git a/packages/finance/flutter/build/unit_test_assets/shaders/stretch_effect.frag b/packages/finance/flutter/build/unit_test_assets/shaders/stretch_effect.frag new file mode 100644 index 0000000..3c938de Binary files /dev/null and b/packages/finance/flutter/build/unit_test_assets/shaders/stretch_effect.frag differ diff --git a/packages/finance/flutter/lib/api/client.dart b/packages/finance/flutter/lib/api/client.dart new file mode 100644 index 0000000..24c86f7 --- /dev/null +++ b/packages/finance/flutter/lib/api/client.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:quanttide_finance/quanttide_finance.dart'; + +class ApiException implements Exception { + ApiException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => 'ApiException($statusCode): $message'; +} + +class FinanceApiClient { + FinanceApiClient(this.baseUrl, {http.Client? httpClient}) + : _http = httpClient ?? http.Client(); + + final String baseUrl; + final http.Client _http; + + Future> listSourceRecords({ + int skip = 0, + int limit = 20, + }) async { + final uri = Uri.parse('$baseUrl/source-records').replace( + queryParameters: {'skip': skip.toString(), 'limit': limit.toString()}, + ); + + final response = await _http.get(uri); + if (response.statusCode != 200) { + throw ApiException( + 'Failed to list source records', + statusCode: response.statusCode, + ); + } + + final List data = jsonDecode(response.body) as List; + return data + .map((e) => SourceRecordDto.fromJson(e as Map)) + .toList(); + } + + Future getSourceRecord(int id) async { + final uri = Uri.parse('$baseUrl/source-records/$id'); + final response = await _http.get(uri); + if (response.statusCode != 200) { + throw ApiException( + 'Failed to get source record $id', + statusCode: response.statusCode, + ); + } + + final data = jsonDecode(response.body) as Map; + return SourceRecordDto.fromJson(data); + } + + Future> listNormalizedRecords({ + int? sourceRecordId, + int skip = 0, + int limit = 20, + }) async { + final params = { + 'skip': skip.toString(), + 'limit': limit.toString(), + }; + if (sourceRecordId != null) { + params['source_record_id'] = sourceRecordId.toString(); + } + + final uri = Uri.parse( + '$baseUrl/normalized-records', + ).replace(queryParameters: params); + + final response = await _http.get(uri); + if (response.statusCode != 200) { + throw ApiException( + 'Failed to list normalized records', + statusCode: response.statusCode, + ); + } + + final List data = jsonDecode(response.body) as List; + return data + .map((e) => NormalizedRecordDto.fromJson(e as Map)) + .toList(); + } + + Future getNormalizedRecord(int id) async { + final uri = Uri.parse('$baseUrl/normalized-records/$id'); + final response = await _http.get(uri); + if (response.statusCode != 200) { + throw ApiException( + 'Failed to get normalized record $id', + statusCode: response.statusCode, + ); + } + + final data = jsonDecode(response.body) as Map; + return NormalizedRecordDto.fromJson(data); + } + + Future createSourceRecord({ + required String sourceType, + String? sourceChannel, + String rawText = '', + String ingestionStatus = 'pending', + }) async { + final body = { + 'source_type': sourceType, + 'raw_text': rawText, + 'ingestion_status': ingestionStatus, + }; + if (sourceChannel != null) { + body['source_channel'] = sourceChannel; + } + + final response = await _http.post( + Uri.parse('$baseUrl/source-records'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + + if (response.statusCode != 201) { + throw ApiException( + 'Failed to create source record', + statusCode: response.statusCode, + ); + } + + final data = jsonDecode(response.body) as Map; + return SourceRecordDto.fromJson(data); + } + + Future> normalizeSourceRecord(int id) async { + final response = await _http.post( + Uri.parse('$baseUrl/source-records/$id/normalize'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({}), + ); + + if (response.statusCode != 200) { + throw ApiException( + 'Failed to normalize source record $id', + statusCode: response.statusCode, + ); + } + + final List data = jsonDecode(response.body) as List; + return data + .map((e) => NormalizedRecordDto.fromJson(e as Map)) + .toList(); + } + + Future createClassification( + int normalizedRecordId, { + required String category, + required String classifierKind, + String taxonomy = 'expense_type', + double? confidence, + String? modelVersion, + Map? tags, + }) async { + final body = { + 'taxonomy': taxonomy, + 'category': category, + 'classifier_kind': classifierKind, + }; + if (confidence != null) body['confidence'] = confidence; + if (modelVersion != null) body['model_version'] = modelVersion; + if (tags != null) body['tags'] = tags; + + final response = await _http.post( + Uri.parse('$baseUrl/normalized-records/$normalizedRecordId/classifications'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + + if (response.statusCode != 201) { + throw ApiException( + 'Failed to create classification', + statusCode: response.statusCode, + ); + } + + final data = jsonDecode(response.body) as Map; + return ClassificationResultDto.fromJson(data); + } + + Future> listClassifications( + int normalizedRecordId, { + String? reviewStatus, + }) async { + final params = {}; + if (reviewStatus != null) { + params['review_status'] = reviewStatus; + } + + final uri = Uri.parse( + '$baseUrl/normalized-records/$normalizedRecordId/classifications', + ).replace(queryParameters: params.isNotEmpty ? params : null); + + final response = await _http.get(uri); + if (response.statusCode != 200) { + throw ApiException( + 'Failed to list classifications', + statusCode: response.statusCode, + ); + } + + final List data = jsonDecode(response.body) as List; + return data + .map((e) => + ClassificationResultDto.fromJson(e as Map)) + .toList(); + } + + Future reviewClassification( + int id, { + String? reviewStatus, + bool? isActive, + }) async { + final body = {}; + if (reviewStatus != null) body['review_status'] = reviewStatus; + if (isActive != null) body['is_active'] = isActive; + + final response = await _http.patch( + Uri.parse('$baseUrl/classifications/$id'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw ApiException( + 'Failed to review classification $id', + statusCode: response.statusCode, + ); + } + + final data = jsonDecode(response.body) as Map; + return ClassificationResultDto.fromJson(data); + } +} diff --git a/packages/finance/flutter/lib/main.dart b/packages/finance/flutter/lib/main.dart new file mode 100644 index 0000000..9ce6588 --- /dev/null +++ b/packages/finance/flutter/lib/main.dart @@ -0,0 +1,461 @@ +import 'package:flutter/material.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +import 'api/client.dart'; + +void main() { + runApp(const QuanttideFinanceApp()); +} + +class QuanttideFinanceApp extends StatelessWidget { + const QuanttideFinanceApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Quanttide Finance', + debugShowCheckedModeBanner: false, + theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true), + home: const DataViewerPage(), + ); + } +} + +class DataViewerPage extends StatefulWidget { + const DataViewerPage({super.key}); + + @override + State createState() => _DataViewerPageState(); +} + +class _DataViewerPageState extends State { + final _client = FinanceApiClient('http://localhost:8000'); + + final _sourceTypeCtrl = TextEditingController(text: 'manual'); + final _rawTextCtrl = TextEditingController(); + final _categoryCtrl = TextEditingController(); + + int _refreshKey = 0; + + @override + void dispose() { + _sourceTypeCtrl.dispose(); + _rawTextCtrl.dispose(); + _categoryCtrl.dispose(); + super.dispose(); + } + + void _refresh() => setState(() => _refreshKey++); + + Future _createSourceRecord() async { + try { + await _client.createSourceRecord( + sourceType: _sourceTypeCtrl.text, + rawText: _rawTextCtrl.text, + ); + _rawTextCtrl.clear(); + _refresh(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Source record created')), + ); + } + } on ApiException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.message}')), + ); + } + } + } + + Future _normalize(int id) async { + try { + await _client.normalizeSourceRecord(id); + _refresh(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Normalized successfully')), + ); + } + } on ApiException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.message}')), + ); + } + } + } + + Future _classify(int normalizedRecordId) async { + final category = _categoryCtrl.text.trim(); + if (category.isEmpty) return; + try { + await _client.createClassification( + normalizedRecordId, + category: category, + classifierKind: 'manual', + ); + _categoryCtrl.clear(); + _refresh(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Classification created')), + ); + } + } on ApiException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.message}')), + ); + } + } + } + + Future _review(int classificationId, String reviewStatus) async { + try { + await _client.reviewClassification( + classificationId, + reviewStatus: reviewStatus, + ); + _refresh(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Review: $reviewStatus')), + ); + } + } on ApiException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.message}')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Quanttide Finance'), centerTitle: true), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _CreateForm( + sourceTypeCtrl: _sourceTypeCtrl, + rawTextCtrl: _rawTextCtrl, + onCreate: _createSourceRecord, + ), + const SizedBox(height: 24), + _SourceRecordsSection( + client: _client, + refreshKey: _refreshKey, + onNormalize: _normalize, + ), + const SizedBox(height: 24), + _NormalizedRecordsSection( + client: _client, + refreshKey: _refreshKey, + onClassify: (id) => _showClassifyDialog(id), + ), + const SizedBox(height: 24), + ], + ), + ); + } + + void _showClassifyDialog(int normalizedRecordId) { + _categoryCtrl.clear(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Add Classification'), + content: TextField( + controller: _categoryCtrl, + decoration: const InputDecoration( + labelText: 'Category', + hintText: 'e.g. office_supplies', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.pop(ctx); + _classify(normalizedRecordId); + }, + child: const Text('Submit'), + ), + ], + ), + ); + } +} + +// ─── Create Form ──────────────────────────────────────────────────────────── + +class _CreateForm extends StatelessWidget { + const _CreateForm({ + required this.sourceTypeCtrl, + required this.rawTextCtrl, + required this.onCreate, + }); + + final TextEditingController sourceTypeCtrl; + final TextEditingController rawTextCtrl; + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Create Source Record', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + DropdownButtonFormField( + value: sourceTypeCtrl.text, + decoration: const InputDecoration(labelText: 'Source Type'), + items: const [ + DropdownMenuItem(value: 'manual', child: Text('manual')), + DropdownMenuItem(value: 'image', child: Text('image')), + DropdownMenuItem(value: 'chat', child: Text('chat')), + DropdownMenuItem(value: 'csv_row', child: Text('csv_row')), + DropdownMenuItem(value: 'bank_tx', child: Text('bank_tx')), + DropdownMenuItem(value: 'api', child: Text('api')), + DropdownMenuItem(value: 'form', child: Text('form')), + DropdownMenuItem(value: 'other', child: Text('other')), + ], + onChanged: (v) { + if (v != null) sourceTypeCtrl.text = v; + }, + ), + const SizedBox(height: 8), + TextField( + controller: rawTextCtrl, + decoration: const InputDecoration( + labelText: 'Raw Text', + hintText: 'Enter record content...', + ), + maxLines: 2, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add), + label: const Text('Create'), + ), + ], + ), + ), + ); + } +} + +// ─── Source Records Section ───────────────────────────────────────────────── + +class _SourceRecordsSection extends StatelessWidget { + const _SourceRecordsSection({ + required this.client, + required this.refreshKey, + required this.onNormalize, + }); + + final FinanceApiClient client; + final int refreshKey; + final Future Function(int id) onNormalize; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + key: ValueKey('src-$refreshKey'), + future: client.listSourceRecords(), + builder: (context, snapshot) { + final title = 'Source Records'; + if (snapshot.connectionState == ConnectionState.waiting) { + return _sectionHeader(title, child: const LinearProgressIndicator()); + } + if (snapshot.hasError) { + return _errorSection(context, title, snapshot.error); + } + final records = snapshot.data!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionHeader(title, count: records.length), + const SizedBox(height: 8), + if (records.isEmpty) _emptyCard(title) + else ...records.map((r) => _sourceRecordCard(context, r)), + ], + ); + }, + ); + } + + Widget _sourceRecordCard(BuildContext context, SourceRecordDto r) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _kvRow('id', r.id.toString()), + _kvRow('source_type', r.sourceType.name), + _kvRow('raw_text', r.rawText.length > 80 + ? '${r.rawText.substring(0, 80)}...' + : r.rawText), + _kvRow('ingestion_status', r.ingestionStatus.name), + _kvRow('created_at', r.createdAt.toIso8601String()), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: () => onNormalize(r.id), + icon: const Icon(Icons.transform, size: 18), + label: const Text('Normalize'), + ), + ), + ], + ), + ), + ); + } +} + +// ─── Normalized Records Section ───────────────────────────────────────────── + +class _NormalizedRecordsSection extends StatelessWidget { + const _NormalizedRecordsSection({ + required this.client, + required this.refreshKey, + required this.onClassify, + }); + + final FinanceApiClient client; + final int refreshKey; + final void Function(int id) onClassify; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + key: ValueKey('nr-$refreshKey'), + future: client.listNormalizedRecords(), + builder: (context, snapshot) { + final title = 'Normalized Records'; + if (snapshot.connectionState == ConnectionState.waiting) { + return _sectionHeader(title, child: const LinearProgressIndicator()); + } + if (snapshot.hasError) { + return _errorSection(context, title, snapshot.error); + } + final records = snapshot.data!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionHeader(title, count: records.length), + const SizedBox(height: 8), + if (records.isEmpty) _emptyCard(title) + else ...records.map((r) => _normalizedRecordCard(context, r)), + ], + ); + }, + ); + } + + Widget _normalizedRecordCard(BuildContext context, NormalizedRecordDto r) { + final amountYuan = r.amountCents / 100.0; + final sign = r.direction == Direction.outflow ? '-' : ''; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _kvRow('id', r.id.toString()), + _kvRow('record_type', r.recordType.name), + _kvRow('amount', '$sign${amountYuan.toStringAsFixed(2)} 元'), + _kvRow('business_date', r.businessDate), + _kvRow('department', r.department ?? '-'), + _kvRow('person', r.person ?? '-'), + _kvRow('description', r.description.length > 60 + ? '${r.description.substring(0, 60)}...' + : r.description), + _kvRow('created_at', r.createdAt.toIso8601String()), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: () => onClassify(r.id), + icon: const Icon(Icons.label, size: 18), + label: const Text('Classify'), + ), + ), + ], + ), + ), + ); + } +} + +// ─── Shared Helpers ───────────────────────────────────────────────────────── + +Widget _sectionHeader(String title, {int? count, Widget? child}) { + if (child != null) return child; + return Text( + count != null ? '$title ($count)' : title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ); +} + +Widget _emptyCard(String label) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('No $label found.'), + ), + ); +} + +Widget _errorSection(BuildContext context, String title, Object? error) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Error: $error', + style: + TextStyle(color: Theme.of(context).colorScheme.onErrorContainer), + ), + ), + ), + ], + ); +} + +Widget _kvRow(String key, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text('$key:', style: const TextStyle(fontSize: 12)), + ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 13))), + ], + ), + ); +} diff --git a/packages/finance/flutter/pubspec.lock b/packages/finance/flutter/pubspec.lock new file mode 100644 index 0000000..23a4edf --- /dev/null +++ b/packages/finance/flutter/pubspec.lock @@ -0,0 +1,524 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.flutter-io.cn" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.flutter-io.cn" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.12.6" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: c8d040d754367108fbe482dcb79dd72b8fe60ac6727abd15b4783c5560297ee6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.7.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" + quanttide_finance: + dependency: "direct main" + description: + path: "../dart" + relative: true + source: path + version: "0.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.3" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/packages/finance/flutter/pubspec.yaml b/packages/finance/flutter/pubspec.yaml new file mode 100644 index 0000000..57d798a --- /dev/null +++ b/packages/finance/flutter/pubspec.yaml @@ -0,0 +1,20 @@ +name: flutter_quanttide_finance +description: Flutter data exposure shell for quanttide-finance-toolkit. +publish_to: none +version: 0.1.0 + +environment: + sdk: ^3.11.5 + +dependencies: + flutter: + sdk: flutter + quanttide_finance: + path: ../dart + http: ^1.2.0 + +dev_dependencies: + build_runner: ^2.4.0 + flutter_test: + sdk: flutter + mockito: ^5.4.0 diff --git a/packages/finance/flutter/run_flutter.sh b/packages/finance/flutter/run_flutter.sh new file mode 100755 index 0000000..3fd9b14 --- /dev/null +++ b/packages/finance/flutter/run_flutter.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Add Flutter to PATH if available +[ -d "$HOME/flutter/bin" ] && export PATH="$HOME/flutter/bin:$PATH" +cd "$(cd "$(dirname "$0")" && pwd)" +flutter pub get 2>&1 | tail -3 +flutter pub run build_runner build --delete-conflicting-outputs 2>&1 | tail -5 +echo "---BUILD DONE---" +flutter test 2>&1 | tail -20 diff --git a/packages/finance/flutter/test/api/client_test.dart b/packages/finance/flutter/test/api/client_test.dart new file mode 100644 index 0000000..e809f56 --- /dev/null +++ b/packages/finance/flutter/test/api/client_test.dart @@ -0,0 +1,425 @@ +import 'dart:convert'; + +import 'package:flutter_quanttide_finance/api/client.dart'; +import 'package:http/http.dart' as http; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'client_test.mocks.dart'; + +@GenerateMocks([http.Client]) +void main() { + const baseUrl = 'http://test.api'; + late FinanceApiClient client; + late MockClient mockHttp; + + setUp(() { + mockHttp = MockClient(); + client = FinanceApiClient(baseUrl, httpClient: mockHttp); + }); + + group('listSourceRecords', () { + test('returns list of SourceRecordDto on 200', () async { + when(mockHttp.get(any)).thenAnswer( + (_) async => http.Response( + jsonEncode([ + { + 'id': 1, + 'source_type': 'image', + 'raw_text': 'receipt photo', + 'ingestion_status': 'parsed', + 'created_at': '2026-05-01T00:00:00Z', + }, + ]), + 200, + ), + ); + + final records = await client.listSourceRecords(); + expect(records, hasLength(1)); + expect(records.first.id, 1); + expect(records.first.sourceType, SourceType.image); + expect(records.first.ingestionStatus, IngestionStatus.parsed); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.path, '/source-records'); + }); + + test('passes skip and limit query parameters', () async { + when( + mockHttp.get(any), + ).thenAnswer((_) async => http.Response(jsonEncode([]), 200)); + + await client.listSourceRecords(skip: 10, limit: 5); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.path, '/source-records'); + expect(captured.queryParameters['skip'], '10'); + expect(captured.queryParameters['limit'], '5'); + }); + + test('throws ApiException on non-200', () async { + when( + mockHttp.get(any), + ).thenAnswer((_) async => http.Response('Not Found', 404)); + + expect(() => client.listSourceRecords(), throwsA(isA())); + }); + + test('returns empty list when no records exist', () async { + when( + mockHttp.get(any), + ).thenAnswer((_) async => http.Response(jsonEncode([]), 200)); + + final records = await client.listSourceRecords(); + expect(records, isEmpty); + }); + }); + + group('getSourceRecord', () { + test('returns SourceRecordDto on 200', () async { + when(mockHttp.get(any)).thenAnswer( + (_) async => http.Response( + jsonEncode({ + 'id': 42, + 'source_type': 'manual', + 'raw_text': 'manual entry', + 'ingestion_status': 'pending', + 'created_at': '2026-05-15T12:00:00Z', + }), + 200, + ), + ); + + final record = await client.getSourceRecord(42); + expect(record.id, 42); + expect(record.sourceType, SourceType.manual); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.path, '/source-records/42'); + }); + + test('throws ApiException on 404', () async { + when( + mockHttp.get(any), + ).thenAnswer((_) async => http.Response('Not Found', 404)); + + expect(() => client.getSourceRecord(999), throwsA(isA())); + }); + }); + + group('listNormalizedRecords', () { + test('returns list of NormalizedRecordDto on 200', () async { + when(mockHttp.get(any)).thenAnswer( + (_) async => http.Response( + jsonEncode([ + { + 'id': 1, + 'record_type': 'expense', + 'business_date': '2026-05-01', + 'amount_cents': 5000, + 'direction': 'outflow', + 'description': 'office supplies', + 'created_at': '2026-05-01T00:00:00Z', + }, + ]), + 200, + ), + ); + + final records = await client.listNormalizedRecords(); + expect(records, hasLength(1)); + expect(records.first.id, 1); + expect(records.first.recordType, RecordType.expense); + expect(records.first.direction, Direction.outflow); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.path, '/normalized-records'); + }); + + test('includes source_record_id query param when provided', () async { + when( + mockHttp.get(any), + ).thenAnswer((_) async => http.Response(jsonEncode([]), 200)); + + await client.listNormalizedRecords(sourceRecordId: 7); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.path, '/normalized-records'); + expect(captured.queryParameters['source_record_id'], '7'); + }); + + test('throws ApiException on non-200', () async { + when( + mockHttp.get(any), + ).thenAnswer((_) async => http.Response('Server Error', 500)); + + expect( + () => client.listNormalizedRecords(), + throwsA(isA()), + ); + }); + }); + + group('getNormalizedRecord', () { + test('returns NormalizedRecordDto on 200', () async { + when(mockHttp.get(any)).thenAnswer( + (_) async => http.Response( + jsonEncode({ + 'id': 5, + 'record_type': 'income', + 'business_date': '2026-05-10', + 'amount_cents': 1000000, + 'direction': 'inflow', + 'department': 'Engineering', + 'person': 'Alice', + 'description': 'monthly salary', + 'created_at': '2026-05-10T08:00:00Z', + }), + 200, + ), + ); + + final record = await client.getNormalizedRecord(5); + expect(record.id, 5); + expect(record.recordType, RecordType.income); + expect(record.direction, Direction.inflow); + expect(record.department, 'Engineering'); + expect(record.person, 'Alice'); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.path, '/normalized-records/5'); + }); + + test('throws ApiException on 404', () async { + when( + mockHttp.get(any), + ).thenAnswer((_) async => http.Response('Not Found', 404)); + + expect( + () => client.getNormalizedRecord(999), + throwsA(isA()), + ); + }); + }); + + group('ApiException', () { + test('toString includes status code and message', () { + final ex = ApiException('test error', statusCode: 500); + expect(ex.toString(), contains('500')); + expect(ex.toString(), contains('test error')); + }); + + test('toString handles missing status code', () { + final ex = ApiException('network error'); + expect(ex.toString(), contains('network error')); + }); + }); + + group('createSourceRecord', () { + test('returns SourceRecordDto on 201', () async { + when(mockHttp.post(any, headers: anyNamed('headers'), body: anyNamed('body'))).thenAnswer( + (_) async => http.Response( + jsonEncode({ + 'id': 1, + 'source_type': 'manual', + 'raw_text': 'test record', + 'ingestion_status': 'pending', + 'created_at': '2026-06-01T00:00:00Z', + }), + 201, + ), + ); + + final record = await client.createSourceRecord( + sourceType: 'manual', + rawText: 'test record', + ); + + expect(record.id, 1); + expect(record.sourceType, SourceType.manual); + expect(record.rawText, 'test record'); + + final captured = verify(mockHttp.post(captureAny, headers: anyNamed('headers'), body: anyNamed('body'))).captured.first as Uri; + expect(captured.path, '/source-records'); + }); + + test('throws ApiException on non-201', () async { + when(mockHttp.post(any, headers: anyNamed('headers'), body: anyNamed('body'))) + .thenAnswer((_) async => http.Response('Bad Request', 400)); + + expect( + () => client.createSourceRecord(sourceType: 'manual', rawText: ''), + throwsA(isA()), + ); + }); + }); + + group('normalizeSourceRecord', () { + test('returns list of NormalizedRecordDto on 200', () async { + when(mockHttp.post(any, headers: anyNamed('headers'), body: anyNamed('body'))) + .thenAnswer((_) async => http.Response( + jsonEncode([ + { + 'id': 1, + 'record_type': 'expense', + 'business_date': '2026-06-01', + 'amount_cents': 5000, + 'direction': 'outflow', + 'description': 'test expense', + 'created_at': '2026-06-01T00:00:00Z', + }, + ]), + 200, + )); + + final records = await client.normalizeSourceRecord(1); + expect(records, hasLength(1)); + expect(records.first.recordType, RecordType.expense); + + final captured = verify(mockHttp.post(captureAny, headers: anyNamed('headers'), body: anyNamed('body'))).captured.first as Uri; + expect(captured.path, '/source-records/1/normalize'); + }); + + test('throws ApiException on 404', () async { + when(mockHttp.post(any, headers: anyNamed('headers'), body: anyNamed('body'))) + .thenAnswer((_) async => http.Response('Not Found', 404)); + + expect( + () => client.normalizeSourceRecord(999), + throwsA(isA()), + ); + }); + }); + + group('createClassification', () { + test('returns ClassificationResultDto on 201', () async { + when(mockHttp.post(any, headers: anyNamed('headers'), body: anyNamed('body'))) + .thenAnswer((_) async => http.Response( + jsonEncode({ + 'id': 1, + 'normalized_record_id': 42, + 'taxonomy': 'expense_type', + 'category': 'office_supplies', + 'classifier_kind': 'manual', + 'review_status': 'candidate', + 'is_active': true, + 'created_at': '2026-06-01T00:00:00Z', + 'updated_at': '2026-06-01T00:00:00Z', + }), + 201, + )); + + final result = await client.createClassification( + 42, + category: 'office_supplies', + classifierKind: 'manual', + ); + + expect(result.id, 1); + expect(result.normalizedRecordId, 42); + expect(result.category, 'office_supplies'); + expect(result.classifierKind, ClassifierKind.manual); + expect(result.reviewStatus, ReviewStatus.candidate); + + final captured = verify(mockHttp.post(captureAny, headers: anyNamed('headers'), body: anyNamed('body'))).captured.first as Uri; + expect(captured.path, '/normalized-records/42/classifications'); + }); + + test('throws ApiException on 400', () async { + when(mockHttp.post(any, headers: anyNamed('headers'), body: anyNamed('body'))) + .thenAnswer((_) async => http.Response('Bad Request', 400)); + + expect( + () => client.createClassification(1, category: '', classifierKind: 'manual'), + throwsA(isA()), + ); + }); + }); + + group('listClassifications', () { + test('returns list of ClassificationResultDto on 200', () async { + when(mockHttp.get(any)).thenAnswer((_) async => http.Response( + jsonEncode([ + { + 'id': 1, + 'normalized_record_id': 42, + 'taxonomy': 'expense_type', + 'category': 'office_supplies', + 'classifier_kind': 'ai', + 'review_status': 'candidate', + 'is_active': true, + 'created_at': '2026-06-01T00:00:00Z', + 'updated_at': '2026-06-01T00:00:00Z', + }, + ]), + 200, + )); + + final results = await client.listClassifications(42); + expect(results, hasLength(1)); + expect(results.first.category, 'office_supplies'); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.path, '/normalized-records/42/classifications'); + }); + + test('passes review_status query param when provided', () async { + when(mockHttp.get(any)) + .thenAnswer((_) async => http.Response(jsonEncode([]), 200)); + + await client.listClassifications(42, reviewStatus: 'accepted'); + + final captured = verify(mockHttp.get(captureAny)).captured.first as Uri; + expect(captured.queryParameters['review_status'], 'accepted'); + }); + + test('throws ApiException on non-200', () async { + when(mockHttp.get(any)) + .thenAnswer((_) async => http.Response('Not Found', 404)); + + expect( + () => client.listClassifications(999), + throwsA(isA()), + ); + }); + }); + + group('reviewClassification', () { + test('updates review_status on 200', () async { + when(mockHttp.patch(any, headers: anyNamed('headers'), body: anyNamed('body'))) + .thenAnswer((_) async => http.Response( + jsonEncode({ + 'id': 1, + 'normalized_record_id': 42, + 'taxonomy': 'expense_type', + 'category': 'office_supplies', + 'classifier_kind': 'ai', + 'review_status': 'accepted', + 'is_active': true, + 'created_at': '2026-06-01T00:00:00Z', + 'updated_at': '2026-06-01T00:30:00Z', + }), + 200, + )); + + final result = await client.reviewClassification(1, reviewStatus: 'accepted'); + expect(result.reviewStatus, ReviewStatus.accepted); + + final captured = verify(mockHttp.patch(captureAny, headers: anyNamed('headers'), body: anyNamed('body'))).captured.first as Uri; + expect(captured.path, '/classifications/1'); + }); + + test('throws ApiException on 404', () async { + when(mockHttp.patch(any, headers: anyNamed('headers'), body: anyNamed('body'))) + .thenAnswer((_) async => http.Response('Not Found', 404)); + + expect( + () => client.reviewClassification(999, reviewStatus: 'accepted'), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/finance/flutter/test/api/client_test.mocks.dart b/packages/finance/flutter/test/api/client_test.mocks.dart new file mode 100644 index 0000000..b122a28 --- /dev/null +++ b/packages/finance/flutter/test/api/client_test.mocks.dart @@ -0,0 +1,220 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in flutter_quanttide_finance/test/api/client_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i4; +import 'dart:typed_data' as _i6; + +import 'package:http/http.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeStreamedResponse_1 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i2.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future read(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); + + @override + _i3.Future<_i6.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); + + @override + _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); + + @override + void close() => super.noSuchMethod( + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); +} diff --git a/src/studio/README.md b/src/studio/README.md index d7ef47c..5537c97 100644 --- a/src/studio/README.md +++ b/src/studio/README.md @@ -24,3 +24,21 @@ git config core.hooksPath .githooks # 激活 pre-commit 检查(dart analyze flutter test # 运行全部 166 个测试 dart analyze lib/ test/ # 静态检查 ``` + +## Finance 联调 + +Studio 中的 finance 工作区通过 `QTADMIN_FINANCE_API_BASE_URL` 注入后端地址。 + +默认值: + +```bash +http://localhost:8000 +``` + +本地联调示例: + +```bash +flutter run --dart-define=QTADMIN_FINANCE_API_BASE_URL=http://127.0.0.1:8000 +``` + +finance 路由不会再在代码里写死 API 地址,统一通过 `AppData.financeConfig` 透传。 diff --git a/src/studio/lib/blocs/app_bloc.dart b/src/studio/lib/blocs/app_bloc.dart index 8c59396..d2e2949 100644 --- a/src/studio/lib/blocs/app_bloc.dart +++ b/src/studio/lib/blocs/app_bloc.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:qtadmin_finance/finance.dart'; import 'package:qtadmin_studio/models/metadata.dart'; import 'package:qtadmin_qtconsult/qtconsult.dart'; import 'package:qtadmin_qtclass/qtclass.dart'; @@ -23,6 +24,11 @@ final _thinkingLoader = final _orgLoader = DataLoader(_source, 'data/company/org.json', OrgDashboard.fromJson); +const _defaultFinanceApiBaseUrl = String.fromEnvironment( + 'QTADMIN_FINANCE_API_BASE_URL', + defaultValue: 'http://localhost:8000', +); + // Events sealed class AppEvent {} @@ -57,6 +63,7 @@ class AppData { final List workspaces; final Map sectionDefs; final Map navData; + final FinanceModuleConfig financeConfig; final QtConsult consultData; final QtClass classData; final Thinking thinkingData; @@ -66,6 +73,7 @@ class AppData { required this.workspaces, required this.sectionDefs, required this.navData, + required this.financeConfig, required this.consultData, required this.classData, required this.thinkingData, @@ -105,6 +113,9 @@ class AppBloc extends Bloc { 'founder': (results[1] as DataSuccess).data, 'company': (results[2] as DataSuccess).data, }, + financeConfig: const FinanceModuleConfig( + apiBaseUrl: _defaultFinanceApiBaseUrl, + ), consultData: (results[3] as DataSuccess).data, classData: (results[4] as DataSuccess).data, thinkingData: (results[5] as DataSuccess).data, diff --git a/src/studio/lib/main.dart b/src/studio/lib/main.dart index 293412b..e5411c1 100644 --- a/src/studio/lib/main.dart +++ b/src/studio/lib/main.dart @@ -82,6 +82,7 @@ class _QtAdminStudioState extends State { dashboard: dashState.dashboard(dir), workspaceName: data.workspaces[wsIndex >= 0 ? wsIndex : 0].name, selectedWorkspace: wsIndex >= 0 ? wsIndex : 0, + financeConfig: data.financeConfig, thinkingData: data.thinkingData, consultData: data.consultData, classData: data.classData, diff --git a/src/studio/lib/router.dart b/src/studio/lib/router.dart index 8fb6566..21f7650 100644 --- a/src/studio/lib/router.dart +++ b/src/studio/lib/router.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:qtadmin_dashboard/dashboard_barrel.dart'; +import 'package:qtadmin_finance/finance.dart'; import 'package:qtadmin_think/think.dart'; import 'package:qtadmin_qtconsult/consult.dart'; import 'package:qtadmin_qtclass/class.dart'; @@ -9,6 +10,7 @@ class ScreenContext { final Dashboard dashboard; final String workspaceName; final int selectedWorkspace; + final FinanceModuleConfig financeConfig; final Thinking? thinkingData; final QtConsult? consultData; final QtClass? classData; @@ -18,6 +20,7 @@ class ScreenContext { required this.dashboard, required this.workspaceName, required this.selectedWorkspace, + required this.financeConfig, this.thinkingData, this.consultData, this.classData, @@ -95,13 +98,9 @@ class RouteConfig { ), 'finance': RouteConfig( id: 'finance', label: '财务管理', icon: Icons.account_balance_outlined, - builder: (ctx) { - final card = ctx.dashboard.functionCards.firstWhere( - (c) => c.name == '财务管理', - orElse: () => throw StateError('未找到职能卡: 财务管理'), - ); - return FuncDetailScreen(card: card); - }, + builder: (ctx) => FinanceWorkspaceScreen( + config: ctx.financeConfig, + ), ), 'strategy': RouteConfig( id: 'strategy', label: '战略管理', icon: Icons.track_changes_outlined, diff --git a/src/studio/packages/qtadmin-finance/.dart_tool/package_config.json b/src/studio/packages/qtadmin-finance/.dart_tool/package_config.json new file mode 100644 index 0000000..641b449 --- /dev/null +++ b/src/studio/packages/qtadmin-finance/.dart_tool/package_config.json @@ -0,0 +1,208 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "async", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/async-2.13.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "boolean_selector", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "characters", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/characters-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "clock", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/clock-1.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "collection", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "fake_async", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/fake_async-1.3.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "flutter", + "rootUri": "file:///home/linli/development/flutter/packages/flutter", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "flutter_quanttide_finance", + "rootUri": "../../../../../packages/finance/flutter", + "packageUri": "lib/", + "languageVersion": "3.11" + }, + { + "name": "flutter_test", + "rootUri": "file:///home/linli/development/flutter/packages/flutter_test", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "freezed_annotation", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/freezed_annotation-2.4.4", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "http", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/http-1.6.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "http_parser", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/http_parser-4.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "json_annotation", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/json_annotation-4.12.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "leak_tracker", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker-11.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_flutter_testing", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_flutter_testing-3.0.10", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_testing", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_testing-3.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "matcher", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/matcher-0.12.19", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "material_color_utilities", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/material_color_utilities-0.13.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "meta", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/meta-1.18.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "path", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "quanttide_finance", + "rootUri": "../../../../../packages/finance/dart", + "packageUri": "lib/", + "languageVersion": "3.11" + }, + { + "name": "sky_engine", + "rootUri": "file:///home/linli/development/flutter/bin/cache/pkg/sky_engine", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "source_span", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/source_span-1.10.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "stack_trace", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "string_scanner", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test_api", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/test_api-0.7.11", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "typed_data", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/typed_data-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "vector_math", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/vector_math-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "vm_service", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/vm_service-15.2.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "web", + "rootUri": "file:///home/linli/.pub-cache/hosted/pub.flutter-io.cn/web-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "qtadmin_finance", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.8" + } + ], + "generator": "pub", + "generatorVersion": "3.12.0", + "flutterRoot": "file:///home/linli/development/flutter", + "flutterVersion": "3.44.0", + "pubCache": "file:///home/linli/.pub-cache" +} diff --git a/src/studio/packages/qtadmin-finance/.dart_tool/package_graph.json b/src/studio/packages/qtadmin-finance/.dart_tool/package_graph.json new file mode 100644 index 0000000..5f4ed0e --- /dev/null +++ b/src/studio/packages/qtadmin-finance/.dart_tool/package_graph.json @@ -0,0 +1,277 @@ +{ + "roots": [ + "qtadmin_finance" + ], + "packages": [ + { + "name": "qtadmin_finance", + "version": "0.0.1", + "dependencies": [ + "flutter", + "flutter_quanttide_finance" + ], + "devDependencies": [ + "flutter_test" + ] + }, + { + "name": "flutter_test", + "version": "0.0.0", + "dependencies": [ + "clock", + "collection", + "fake_async", + "flutter", + "leak_tracker_flutter_testing", + "matcher", + "meta", + "path", + "stack_trace", + "stream_channel", + "test_api", + "vector_math" + ] + }, + { + "name": "flutter_quanttide_finance", + "version": "0.1.0", + "dependencies": [ + "flutter", + "http", + "quanttide_finance" + ] + }, + { + "name": "flutter", + "version": "0.0.0", + "dependencies": [ + "characters", + "collection", + "material_color_utilities", + "meta", + "sky_engine", + "vector_math" + ] + }, + { + "name": "vector_math", + "version": "2.2.0", + "dependencies": [] + }, + { + "name": "test_api", + "version": "0.7.11", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "meta", + "version": "1.18.0", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.19", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "dependencies": [ + "flutter", + "leak_tracker", + "leak_tracker_testing", + "matcher", + "meta" + ] + }, + { + "name": "fake_async", + "version": "1.3.3", + "dependencies": [ + "clock", + "collection" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "clock", + "version": "1.1.2", + "dependencies": [] + }, + { + "name": "quanttide_finance", + "version": "0.2.0", + "dependencies": [ + "freezed_annotation", + "json_annotation" + ] + }, + { + "name": "sky_engine", + "version": "0.0.0", + "dependencies": [] + }, + { + "name": "material_color_utilities", + "version": "0.13.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "characters", + "version": "1.4.1", + "dependencies": [] + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "dependencies": [ + "leak_tracker", + "matcher", + "meta" + ] + }, + { + "name": "leak_tracker", + "version": "11.0.2", + "dependencies": [ + "clock", + "collection", + "meta", + "path", + "vm_service" + ] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "freezed_annotation", + "version": "2.4.4", + "dependencies": [ + "collection", + "json_annotation", + "meta" + ] + }, + { + "name": "json_annotation", + "version": "4.12.0", + "dependencies": [ + "meta" + ] + }, + { + "name": "async", + "version": "2.13.1", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.2", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "http", + "version": "1.6.0", + "dependencies": [ + "async", + "http_parser", + "meta", + "web" + ] + }, + { + "name": "web", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "http_parser", + "version": "4.1.2", + "dependencies": [ + "collection", + "source_span", + "string_scanner", + "typed_data" + ] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "vm_service", + "version": "15.2.0", + "dependencies": [] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/src/studio/packages/qtadmin-finance/.dart_tool/version b/src/studio/packages/qtadmin-finance/.dart_tool/version new file mode 100644 index 0000000..df6486e --- /dev/null +++ b/src/studio/packages/qtadmin-finance/.dart_tool/version @@ -0,0 +1 @@ +3.44.0 \ No newline at end of file diff --git a/src/studio/packages/qtadmin-finance/build/native_assets/linux/native_assets.json b/src/studio/packages/qtadmin-finance/build/native_assets/linux/native_assets.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/src/studio/packages/qtadmin-finance/build/native_assets/linux/native_assets.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/src/studio/packages/qtadmin-finance/build/test_cache/build/e526d636a6238c5a01b25d33d78dd941.cache.dill.track.dill b/src/studio/packages/qtadmin-finance/build/test_cache/build/e526d636a6238c5a01b25d33d78dd941.cache.dill.track.dill new file mode 100644 index 0000000..0e465ae Binary files /dev/null and b/src/studio/packages/qtadmin-finance/build/test_cache/build/e526d636a6238c5a01b25d33d78dd941.cache.dill.track.dill differ diff --git a/src/studio/packages/qtadmin-finance/build/unit_test_assets/AssetManifest.bin b/src/studio/packages/qtadmin-finance/build/unit_test_assets/AssetManifest.bin new file mode 100644 index 0000000..86d111f Binary files /dev/null and b/src/studio/packages/qtadmin-finance/build/unit_test_assets/AssetManifest.bin differ diff --git a/src/studio/packages/qtadmin-finance/build/unit_test_assets/FontManifest.json b/src/studio/packages/qtadmin-finance/build/unit_test_assets/FontManifest.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/src/studio/packages/qtadmin-finance/build/unit_test_assets/FontManifest.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/src/studio/packages/qtadmin-finance/build/unit_test_assets/NOTICES.Z b/src/studio/packages/qtadmin-finance/build/unit_test_assets/NOTICES.Z new file mode 100644 index 0000000..a6a626f Binary files /dev/null and b/src/studio/packages/qtadmin-finance/build/unit_test_assets/NOTICES.Z differ diff --git a/src/studio/packages/qtadmin-finance/build/unit_test_assets/NativeAssetsManifest.json b/src/studio/packages/qtadmin-finance/build/unit_test_assets/NativeAssetsManifest.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/src/studio/packages/qtadmin-finance/build/unit_test_assets/NativeAssetsManifest.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/src/studio/packages/qtadmin-finance/build/unit_test_assets/shaders/ink_sparkle.frag b/src/studio/packages/qtadmin-finance/build/unit_test_assets/shaders/ink_sparkle.frag new file mode 100644 index 0000000..ebd8066 Binary files /dev/null and b/src/studio/packages/qtadmin-finance/build/unit_test_assets/shaders/ink_sparkle.frag differ diff --git a/src/studio/packages/qtadmin-finance/build/unit_test_assets/shaders/stretch_effect.frag b/src/studio/packages/qtadmin-finance/build/unit_test_assets/shaders/stretch_effect.frag new file mode 100644 index 0000000..3c938de Binary files /dev/null and b/src/studio/packages/qtadmin-finance/build/unit_test_assets/shaders/stretch_effect.frag differ diff --git a/src/studio/packages/qtadmin-finance/lib/finance.dart b/src/studio/packages/qtadmin-finance/lib/finance.dart new file mode 100644 index 0000000..c08e756 --- /dev/null +++ b/src/studio/packages/qtadmin-finance/lib/finance.dart @@ -0,0 +1,4 @@ +library qtadmin_finance; + +export 'src/config/finance_module_config.dart'; +export 'src/screens/finance_workspace_screen.dart'; diff --git a/src/studio/packages/qtadmin-finance/lib/src/config/finance_module_config.dart b/src/studio/packages/qtadmin-finance/lib/src/config/finance_module_config.dart new file mode 100644 index 0000000..92aa2eb --- /dev/null +++ b/src/studio/packages/qtadmin-finance/lib/src/config/finance_module_config.dart @@ -0,0 +1,11 @@ +class FinanceModuleConfig { + const FinanceModuleConfig({ + required this.apiBaseUrl, + this.enableReviewQueue = true, + this.enableStatistics = true, + }); + + final String apiBaseUrl; + final bool enableReviewQueue; + final bool enableStatistics; +} diff --git a/src/studio/packages/qtadmin-finance/lib/src/screens/finance_workspace_screen.dart b/src/studio/packages/qtadmin-finance/lib/src/screens/finance_workspace_screen.dart new file mode 100644 index 0000000..fe2f14b --- /dev/null +++ b/src/studio/packages/qtadmin-finance/lib/src/screens/finance_workspace_screen.dart @@ -0,0 +1,1479 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quanttide_finance/api/client.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +import '../config/finance_module_config.dart'; + +const _expenseCategories = [ + '办公用品', + '差旅', + '采购', + '工资', + '其他', +]; + +class FinanceWorkspaceScreen extends StatefulWidget { + const FinanceWorkspaceScreen({ + super.key, + required this.config, + this.client, + }); + + final FinanceModuleConfig config; + final FinanceApiClient? client; + + @override + State createState() => _FinanceWorkspaceScreenState(); +} + +class _FinanceWorkspaceScreenState extends State { + late Future<_FinanceWorkspaceData> _future; + final Set _selectedReviewRecordIds = {}; + + final _rawTextController = TextEditingController(); + final _dateController = TextEditingController(text: '2026-06-12'); + final _amountController = TextEditingController(); + final _departmentController = TextEditingController(); + final _personController = TextEditingController(); + final _descriptionController = TextEditingController(); + + String _recordType = 'expense'; + String _direction = 'outflow'; + int? _editingNormalizedRecordId; + + FinanceApiClient get _client => + widget.client ?? FinanceApiClient(widget.config.apiBaseUrl); + + @override + void initState() { + super.initState(); + _future = _loadWorkspace(); + } + + @override + void dispose() { + _rawTextController.dispose(); + _dateController.dispose(); + _amountController.dispose(); + _departmentController.dispose(); + _personController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future<_FinanceWorkspaceData> _loadWorkspace() async { + final summaryFuture = _client.getStatisticsSummary(); + final breakdownFuture = _client.getStatisticsBreakdown( + dimension: 'department', + ); + final trendFuture = _client.getStatisticsTrend(); + final normalizedFuture = _client.listNormalizedRecords(limit: 50); + + final summary = await summaryFuture; + final breakdown = await breakdownFuture; + final trend = await trendFuture; + final normalized = await normalizedFuture; + + final reviewItems = <_ReviewItem>[]; + for (final record in normalized) { + final classifications = await _client.listClassifications(record.id); + ClassificationResultDto? latest; + if (classifications.isNotEmpty) { + latest = classifications.first; + } + reviewItems.add( + _ReviewItem( + record: record, + latestClassification: latest, + sourceHint: record.description, + ), + ); + } + + return _FinanceWorkspaceData( + summary: summary, + breakdown: breakdown, + trend: trend, + reviewItems: reviewItems, + ); + } + + void _reload() { + setState(() { + _future = _loadWorkspace(); + _selectedReviewRecordIds.clear(); + }); + } + + Future _submitManualRecord() async { + final validationMessage = _validateEntryForm(); + if (validationMessage != null) { + _showMessage(validationMessage); + return; + } + final amount = int.parse(_amountController.text.trim()); + + try { + if (_editingNormalizedRecordId != null) { + await _client.updateNormalizedRecord( + _editingNormalizedRecordId!, + recordType: _recordType, + businessDate: _dateController.text.trim(), + amountCents: amount, + direction: _direction, + department: _departmentController.text.trim(), + person: _personController.text.trim(), + description: _descriptionController.text.trim(), + normalizationStatus: 'reviewed', + ); + _showMessage('记录已更新。'); + } else { + final source = await _client.createSourceRecord( + sourceType: 'manual', + rawText: _rawTextController.text.trim(), + ); + + await _client.createNormalizedRecord( + primarySourceId: source.id, + recordType: _recordType, + businessDate: _dateController.text.trim(), + amountCents: amount, + direction: _direction, + department: _departmentController.text.trim(), + person: _personController.text.trim(), + description: _descriptionController.text.trim(), + normalizationStatus: 'normalized', + ); + _showMessage('录入成功。'); + } + + _resetEntryForm(); + _reload(); + } on ApiException catch (error) { + final action = _editingNormalizedRecordId != null ? '更新失败' : '录入失败'; + _showMessage('$action: ${error.message}'); + } + } + + String? _validateEntryForm() { + final rawText = _rawTextController.text.trim(); + final date = _dateController.text.trim(); + final amountText = _amountController.text.trim(); + final description = _descriptionController.text.trim(); + + if (_editingNormalizedRecordId == null && rawText.isEmpty) { + return 'Raw Text 不能为空。'; + } + if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(date)) { + return 'Business Date 必须是 YYYY-MM-DD。'; + } + final amount = int.tryParse(amountText); + if (amount == null) { + return '金额必须是整数分。'; + } + if (amount < 0) { + return '金额不能为负数。'; + } + if (description.isEmpty) { + return 'Description 不能为空。'; + } + return null; + } + + void _startEditingRecord(_ReviewItem item) { + final record = item.record; + setState(() { + _editingNormalizedRecordId = record.id; + _rawTextController.text = item.sourceHint; + _dateController.text = record.businessDate; + _amountController.text = record.amountCents.toString(); + _departmentController.text = record.department ?? ''; + _personController.text = record.person ?? ''; + _descriptionController.text = record.description; + _recordType = record.recordType.name; + _direction = record.direction.name; + }); + _showMessage('已载入编辑表单。'); + } + + void _resetEntryForm() { + setState(() { + _editingNormalizedRecordId = null; + _rawTextController.clear(); + _dateController.text = '2026-06-12'; + _amountController.clear(); + _departmentController.clear(); + _personController.clear(); + _descriptionController.clear(); + _recordType = 'expense'; + _direction = 'outflow'; + }); + } + + Future _applyReview( + _ReviewItem item, { + required String category, + required String reviewStatus, + }) async { + try { + final current = item.latestClassification; + if (current == null) { + final created = await _client.createClassification( + item.record.id, + category: category, + classifierKind: 'manual', + ); + await _client.reviewClassification( + created.id, + reviewStatus: reviewStatus, + ); + } else { + if (current.category != category) { + final created = await _client.createClassification( + item.record.id, + category: category, + classifierKind: 'manual', + ); + await _client.reviewClassification( + created.id, + reviewStatus: reviewStatus, + ); + } else { + await _client.reviewClassification( + current.id, + reviewStatus: reviewStatus, + ); + } + } + + _showMessage('审核已更新。'); + _reload(); + } on ApiException catch (error) { + _showMessage('审核失败: ${error.message}'); + } + } + + Future _applyBulkReview({ + required List<_ReviewItem> items, + required String category, + required String reviewStatus, + }) async { + if (items.isEmpty) { + _showMessage('请先选择记录。'); + return; + } + + try { + for (final item in items) { + final current = item.latestClassification; + if (current == null || current.category != category) { + final created = await _client.createClassification( + item.record.id, + category: category, + classifierKind: 'manual', + ); + await _client.reviewClassification( + created.id, + reviewStatus: reviewStatus, + ); + } else { + await _client.reviewClassification( + current.id, + reviewStatus: reviewStatus, + ); + } + } + + _showMessage('批量审核完成。'); + _reload(); + } on ApiException catch (error) { + _showMessage('批量审核失败: ${error.message}'); + } + } + + void _showReviewDialog(_ReviewItem item) { + String selectedCategory = + item.latestClassification?.category ?? _expenseCategories.first; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: const Text('审核分类'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.record.description.isEmpty + ? '无描述' + : item.record.description), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedCategory, + decoration: const InputDecoration(labelText: '分类'), + items: _expenseCategories + .map( + (category) => DropdownMenuItem( + value: category, + child: Text(category), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setDialogState(() { + selectedCategory = value; + }); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _applyReview( + item, + category: selectedCategory, + reviewStatus: 'rejected', + ); + }, + child: const Text('驳回'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + _applyReview( + item, + category: selectedCategory, + reviewStatus: 'accepted', + ); + }, + child: const Text('确认'), + ), + ], + ); + }, + ); + }, + ); + } + + void _showBulkReviewDialog(List<_ReviewItem> selectedItems) { + String selectedCategory = _expenseCategories.first; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text('批量审核 ${selectedItems.length} 条'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('统一设置分类并批量确认。'), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedCategory, + decoration: const InputDecoration(labelText: '分类'), + items: _expenseCategories + .map( + (category) => DropdownMenuItem( + value: category, + child: Text(category), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setDialogState(() { + selectedCategory = value; + }); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + _applyBulkReview( + items: selectedItems, + category: selectedCategory, + reviewStatus: 'accepted', + ); + }, + child: const Text('批量确认'), + ), + ], + ); + }, + ); + }, + ); + } + + void _showMessage(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF6F1E8), + body: SafeArea( + child: FutureBuilder<_FinanceWorkspaceData>( + future: _future, + builder: (context, snapshot) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1180), + child: Padding( + padding: const EdgeInsets.all(24), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 32, + offset: Offset(0, 12), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _WorkspaceHeader( + config: widget.config, + apiBaseUrl: _client.baseUrl, + onReload: _reload, + ), + const SizedBox(height: 20), + if (snapshot.connectionState == ConnectionState.waiting) + const _LoadingPanel() + else if (snapshot.hasError) + _ErrorPanel( + message: snapshot.error.toString(), + onRetry: _reload, + ) + else if (!snapshot.hasData) + _ErrorPanel( + message: 'Finance workspace returned no data.', + onRetry: _reload, + ) + else + _WorkspaceBody( + data: snapshot.data!, + rawTextController: _rawTextController, + dateController: _dateController, + amountController: _amountController, + departmentController: _departmentController, + personController: _personController, + descriptionController: _descriptionController, + recordType: _recordType, + direction: _direction, + onRecordTypeChanged: (value) { + setState(() { + _recordType = value; + }); + }, + onDirectionChanged: (value) { + setState(() { + _direction = value; + }); + }, + onSubmitManualRecord: _submitManualRecord, + editingNormalizedRecordId: _editingNormalizedRecordId, + onCancelEditing: _resetEntryForm, + onOpenReviewDialog: _showReviewDialog, + onStartEditingRecord: _startEditingRecord, + selectedReviewRecordIds: _selectedReviewRecordIds, + onToggleReviewSelection: (recordId, selected) { + setState(() { + if (selected) { + _selectedReviewRecordIds.add(recordId); + } else { + _selectedReviewRecordIds.remove(recordId); + } + }); + }, + onOpenBulkReviewDialog: _showBulkReviewDialog, + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +class _FinanceWorkspaceData { + const _FinanceWorkspaceData({ + required this.summary, + required this.breakdown, + required this.trend, + required this.reviewItems, + }); + + final StatisticsSummaryResponse summary; + final StatisticsBreakdownResponse breakdown; + final StatisticsTrendResponse trend; + final List<_ReviewItem> reviewItems; +} + +class _ReviewItem { + const _ReviewItem({ + required this.record, + required this.latestClassification, + required this.sourceHint, + }); + + final NormalizedRecordDto record; + final ClassificationResultDto? latestClassification; + final String sourceHint; +} + +class _WorkspaceHeader extends StatelessWidget { + const _WorkspaceHeader({ + required this.config, + required this.apiBaseUrl, + required this.onReload, + }); + + final FinanceModuleConfig config; + final String apiBaseUrl; + final VoidCallback onReload; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Finance', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w800, + letterSpacing: -0.8, + color: Color(0xFF1E2A24), + ), + ), + SizedBox(height: 8), + Text( + 'Manual entry, review queue, and live statistics for normalized finance records.', + style: TextStyle( + fontSize: 15, + height: 1.5, + color: Color(0xFF55615B), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + FilledButton.tonalIcon( + onPressed: onReload, + icon: const Icon(Icons.refresh_outlined), + label: const Text('刷新'), + ), + ], + ), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _MetricChip(label: 'API Base URL', value: apiBaseUrl), + _MetricChip( + label: 'Review Queue', + value: config.enableReviewQueue ? 'enabled' : 'disabled', + ), + _MetricChip( + label: 'Statistics', + value: config.enableStatistics ? 'enabled' : 'disabled', + ), + ], + ), + ], + ); + } +} + +class _LoadingPanel extends StatelessWidget { + const _LoadingPanel(); + + @override + Widget build(BuildContext context) { + return const Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 12), + Text('Loading finance workspace...'), + ], + ), + ), + ); + } +} + +class _ErrorPanel extends StatelessWidget { + const _ErrorPanel({ + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFFFFF3F0), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Color(0xFFC6452D)), + const SizedBox(height: 12), + const Text( + 'Finance workspace load failed', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF735B53)), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: onRetry, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } +} + +class _WorkspaceBody extends StatelessWidget { + const _WorkspaceBody({ + required this.data, + required this.rawTextController, + required this.dateController, + required this.amountController, + required this.departmentController, + required this.personController, + required this.descriptionController, + required this.recordType, + required this.direction, + required this.onRecordTypeChanged, + required this.onDirectionChanged, + required this.onSubmitManualRecord, + required this.editingNormalizedRecordId, + required this.onCancelEditing, + required this.onOpenReviewDialog, + required this.onStartEditingRecord, + required this.selectedReviewRecordIds, + required this.onToggleReviewSelection, + required this.onOpenBulkReviewDialog, + }); + + final _FinanceWorkspaceData data; + final TextEditingController rawTextController; + final TextEditingController dateController; + final TextEditingController amountController; + final TextEditingController departmentController; + final TextEditingController personController; + final TextEditingController descriptionController; + final String recordType; + final String direction; + final ValueChanged onRecordTypeChanged; + final ValueChanged onDirectionChanged; + final Future Function() onSubmitManualRecord; + final int? editingNormalizedRecordId; + final VoidCallback onCancelEditing; + final void Function(_ReviewItem item) onOpenReviewDialog; + final void Function(_ReviewItem item) onStartEditingRecord; + final Set selectedReviewRecordIds; + final void Function(int recordId, bool selected) onToggleReviewSelection; + final void Function(List<_ReviewItem> items) onOpenBulkReviewDialog; + + @override + Widget build(BuildContext context) { + final summary = data.summary; + final breakdownRows = data.breakdown.rows.take(5).toList(); + final trendRows = data.trend.rows.take(6).toList(); + + return Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _StatCard( + title: 'Records', + value: '${summary.recordCount}', + note: 'Normalized records in current scope', + accent: const Color(0xFFE3EEE8), + ), + _StatCard( + title: 'Amount', + value: _formatCurrency(summary.amountCents), + note: 'Aggregated from statistics summary', + accent: const Color(0xFFF3E8D7), + ), + _StatCard( + title: 'Classified', + value: '${summary.classifiedCount}', + note: 'Accepted classifications in reporting scope', + accent: const Color(0xFFE7EEF8), + ), + ], + ), + const SizedBox(height: 24), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _Panel( + title: 'Manual Entry', + subtitle: 'Create a source record and normalized record together', + child: _EntryForm( + rawTextController: rawTextController, + dateController: dateController, + amountController: amountController, + departmentController: departmentController, + personController: personController, + descriptionController: descriptionController, + recordType: recordType, + direction: direction, + onRecordTypeChanged: onRecordTypeChanged, + onDirectionChanged: onDirectionChanged, + onSubmit: onSubmitManualRecord, + editingNormalizedRecordId: editingNormalizedRecordId, + onCancelEditing: onCancelEditing, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _Panel( + title: 'Review Queue', + subtitle: 'Review normalized records and accept manual categories', + child: _ReviewQueue( + items: data.reviewItems, + onOpenReviewDialog: onOpenReviewDialog, + onStartEditingRecord: onStartEditingRecord, + selectedReviewRecordIds: selectedReviewRecordIds, + onToggleSelection: onToggleReviewSelection, + onOpenBulkReviewDialog: onOpenBulkReviewDialog, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _Panel( + title: 'Department Breakdown', + subtitle: 'Top departments by amount', + child: breakdownRows.isEmpty + ? const Text('No breakdown data available.') + : Column( + children: [ + for (final row in breakdownRows) + _BreakdownRowTile(row: row), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _Panel( + title: 'Monthly Trend', + subtitle: 'Recent amount and count movement', + child: trendRows.isEmpty + ? const Text('No trend data available.') + : Column( + children: [ + for (final row in trendRows) + _TrendRowTile(row: row), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 16), + _Panel( + title: 'Reporting Scope', + subtitle: 'Validated backend filter payload', + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final entry in data.summary.filters.entries) + _FilterPill(label: entry.key, value: '${entry.value}'), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _EntryForm extends StatelessWidget { + const _EntryForm({ + required this.rawTextController, + required this.dateController, + required this.amountController, + required this.departmentController, + required this.personController, + required this.descriptionController, + required this.recordType, + required this.direction, + required this.onRecordTypeChanged, + required this.onDirectionChanged, + required this.onSubmit, + required this.editingNormalizedRecordId, + required this.onCancelEditing, + }); + + final TextEditingController rawTextController; + final TextEditingController dateController; + final TextEditingController amountController; + final TextEditingController departmentController; + final TextEditingController personController; + final TextEditingController descriptionController; + final String recordType; + final String direction; + final ValueChanged onRecordTypeChanged; + final ValueChanged onDirectionChanged; + final Future Function() onSubmit; + final int? editingNormalizedRecordId; + final VoidCallback onCancelEditing; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (editingNormalizedRecordId != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Text( + 'Editing #$editingNormalizedRecordId', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF7B5C28), + ), + ), + const Spacer(), + TextButton( + onPressed: onCancelEditing, + child: const Text('取消编辑'), + ), + ], + ), + ), + TextField( + controller: rawTextController, + decoration: const InputDecoration( + labelText: 'Raw Text', + hintText: '打车到机场,188 元,王琳提交', + ), + maxLines: 2, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: dateController, + decoration: const InputDecoration(labelText: 'Business Date'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: amountController, + decoration: const InputDecoration(labelText: 'Amount Cents'), + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: recordType, + decoration: const InputDecoration(labelText: 'Record Type'), + items: const [ + DropdownMenuItem(value: 'expense', child: Text('expense')), + DropdownMenuItem(value: 'income', child: Text('income')), + DropdownMenuItem(value: 'transfer', child: Text('transfer')), + DropdownMenuItem( + value: 'reimbursement', + child: Text('reimbursement'), + ), + DropdownMenuItem(value: 'other', child: Text('other')), + ], + onChanged: (value) { + if (value != null) onRecordTypeChanged(value); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + initialValue: direction, + decoration: const InputDecoration(labelText: 'Direction'), + items: const [ + DropdownMenuItem(value: 'outflow', child: Text('outflow')), + DropdownMenuItem(value: 'inflow', child: Text('inflow')), + ], + onChanged: (value) { + if (value != null) onDirectionChanged(value); + }, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: departmentController, + decoration: const InputDecoration(labelText: 'Department'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: personController, + decoration: const InputDecoration(labelText: 'Person'), + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: descriptionController, + decoration: const InputDecoration( + labelText: 'Description', + hintText: '差旅打车费用', + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: onSubmit, + icon: Icon( + editingNormalizedRecordId == null + ? Icons.add_card_outlined + : Icons.save_outlined, + ), + label: Text( + editingNormalizedRecordId == null ? '提交录入' : '保存修改', + ), + ), + ), + ], + ); + } +} + +class _ReviewQueue extends StatelessWidget { + const _ReviewQueue({ + required this.items, + required this.onOpenReviewDialog, + required this.onStartEditingRecord, + required this.selectedReviewRecordIds, + required this.onToggleSelection, + required this.onOpenBulkReviewDialog, + }); + + final List<_ReviewItem> items; + final void Function(_ReviewItem item) onOpenReviewDialog; + final void Function(_ReviewItem item) onStartEditingRecord; + final Set selectedReviewRecordIds; + final void Function(int recordId, bool selected) onToggleSelection; + final void Function(List<_ReviewItem> items) onOpenBulkReviewDialog; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) { + return const Text('No normalized records available for review.'); + } + + final selectedItems = items + .where((item) => selectedReviewRecordIds.contains(item.record.id)) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '已选 ${selectedItems.length} 条', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF55615B), + ), + ), + const Spacer(), + FilledButton.tonalIcon( + onPressed: selectedItems.isEmpty + ? null + : () => onOpenBulkReviewDialog(selectedItems), + icon: const Icon(Icons.done_all_outlined, size: 18), + label: const Text('批量确认'), + ), + ], + ), + const SizedBox(height: 12), + for (final item in items.take(8)) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE7E1D6)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: selectedReviewRecordIds.contains(item.record.id), + onChanged: (value) { + onToggleSelection(item.record.id, value ?? false); + }, + ), + Expanded( + child: Text( + item.record.description.isEmpty + ? 'Record #${item.record.id}' + : item.record.description, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E2A24), + ), + ), + ), + Text( + _reviewStatusLabel(item.latestClassification?.reviewStatus), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6A736E), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '${item.record.businessDate} · ${_formatCurrency(item.record.amountCents)} · ${item.record.department ?? '未分配部门'}', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF55615B), + ), + ), + const SizedBox(height: 6), + Text( + 'Current category: ${item.latestClassification?.category ?? '未分类'}', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF55615B), + ), + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () => onStartEditingRecord(item), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('编辑'), + ), + OutlinedButton.icon( + onPressed: () => onOpenReviewDialog(item), + icon: const Icon(Icons.fact_check_outlined, size: 18), + label: const Text('审核'), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _MetricChip extends StatelessWidget { + const _MetricChip({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFF3E8D7), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF8A7558), + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF2D352F), + ), + ), + ], + ), + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.title, + required this.value, + required this.note, + required this.accent, + }); + + final String title; + final String value; + final String note; + final Color accent; + + @override + Widget build(BuildContext context) { + return Container( + width: 320, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Color(0xFF5B635F), + ), + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Color(0xFF1E2A24), + ), + ), + const SizedBox(height: 6), + Text( + note, + style: const TextStyle( + fontSize: 13, + height: 1.4, + color: Color(0xFF5B635F), + ), + ), + ], + ), + ); + } +} + +class _Panel extends StatelessWidget { + const _Panel({ + required this.title, + required this.subtitle, + required this.child, + }); + + final String title; + final String subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F6F1), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFF1E2A24), + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF6A736E), + ), + ), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} + +class _BreakdownRowTile extends StatelessWidget { + const _BreakdownRowTile({ + required this.row, + }); + + final StatisticsRow row; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Expanded( + child: Text( + row.key ?? 'Unassigned', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF27312B), + ), + ), + ), + Text( + '${row.count} records', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF6A736E), + ), + ), + const SizedBox(width: 16), + Text( + _formatCurrency(row.amountCents), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF27312B), + ), + ), + ], + ), + ); + } +} + +class _TrendRowTile extends StatelessWidget { + const _TrendRowTile({ + required this.row, + }); + + final StatisticsTrendRow row; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + SizedBox( + width: 96, + child: Text( + row.date, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF27312B), + ), + ), + ), + Expanded( + child: Text( + '${row.count} records', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF6A736E), + ), + ), + ), + Text( + _formatCurrency(row.amountCents), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF27312B), + ), + ), + ], + ), + ); + } +} + +class _FilterPill extends StatelessWidget { + const _FilterPill({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFE2DDD4)), + ), + child: Text( + '$label: $value', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF4B554F), + ), + ), + ); + } +} + +String _reviewStatusLabel(ReviewStatus? status) { + switch (status) { + case ReviewStatus.accepted: + return 'accepted'; + case ReviewStatus.rejected: + return 'rejected'; + case ReviewStatus.candidate: + return 'candidate'; + case ReviewStatus.unknown: + return 'unknown'; + case null: + return 'unreviewed'; + } +} + +String _formatCurrency(int? amountCents) { + final cents = amountCents ?? 0; + final sign = cents < 0 ? '-' : ''; + final absolute = cents.abs(); + final yuan = absolute ~/ 100; + final remainder = absolute % 100; + final yuanString = yuan.toString().replaceAllMapped( + RegExp(r'\B(?=(\d{3})+(?!\d))'), + (match) => ',', + ); + final decimal = remainder.toString().padLeft(2, '0'); + return '$sign¥$yuanString.$decimal'; +} diff --git a/src/studio/packages/qtadmin-finance/pubspec.lock b/src/studio/packages/qtadmin-finance/pubspec.lock new file mode 100644 index 0000000..017ee2e --- /dev/null +++ b/src/studio/packages/qtadmin-finance/pubspec.lock @@ -0,0 +1,251 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_quanttide_finance: + dependency: "direct main" + description: + path: "../../../../packages/finance/flutter" + relative: true + source: path + version: "0.1.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.4" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + quanttide_finance: + dependency: transitive + description: + path: "../../../../packages/finance/dart" + relative: true + source: path + version: "0.2.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/src/studio/packages/qtadmin-finance/pubspec.yaml b/src/studio/packages/qtadmin-finance/pubspec.yaml new file mode 100644 index 0000000..4c6b952 --- /dev/null +++ b/src/studio/packages/qtadmin-finance/pubspec.yaml @@ -0,0 +1,18 @@ +name: qtadmin_finance +description: Finance workspace for qtadmin Studio, layered on top of the shared finance adapter. +publish_to: 'none' +version: 0.0.1 + +environment: + sdk: ">=3.8.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_quanttide_finance: + path: ../../../../packages/finance/flutter + +dev_dependencies: + flutter_test: + sdk: flutter + diff --git a/src/studio/packages/qtadmin-finance/test/finance_workspace_screen_test.dart b/src/studio/packages/qtadmin-finance/test/finance_workspace_screen_test.dart new file mode 100644 index 0000000..ebd42ba --- /dev/null +++ b/src/studio/packages/qtadmin-finance/test/finance_workspace_screen_test.dart @@ -0,0 +1,349 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:qtadmin_finance/finance.dart'; +import 'package:flutter_quanttide_finance/api/client.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; + +class _FakeFinanceApiClient extends FinanceApiClient { + _FakeFinanceApiClient() : super('http://fake.api'); + + final List createdSources = []; + final List<(int id, String description, int amountCents)> updatedRecords = []; + final List<(int normalizedRecordId, String category, String reviewStatus)> + bulkReviewLog = []; + final List normalizedRecords = [ + NormalizedRecordDto( + id: 41, + recordType: RecordType.expense, + businessDate: '2026-06-01', + amountCents: 18800, + direction: Direction.outflow, + department: 'Finance', + person: 'Lin', + description: 'taxi to airport', + createdAt: DateTime.utc(2026, 6, 1), + ), + NormalizedRecordDto( + id: 42, + recordType: RecordType.expense, + businessDate: '2026-06-02', + amountCents: 3200, + direction: Direction.outflow, + department: 'Marketing', + person: 'Chen', + description: 'coffee beans', + createdAt: DateTime.utc(2026, 6, 2), + ), + ]; + final Map> classifications = { + 41: [ + ClassificationResultDto( + id: 7, + normalizedRecordId: 41, + taxonomy: 'expense_type', + category: '差旅', + classifierKind: ClassifierKind.manual, + reviewStatus: ReviewStatus.candidate, + isActive: true, + createdAt: DateTime.utc(2026, 6, 1), + updatedAt: DateTime.utc(2026, 6, 1), + ), + ], + 42: [], + }; + + @override + Future getStatisticsSummary({ + String currency = 'CNY', + }) async { + return StatisticsSummaryResponse( + recordCount: 12, + amountCents: 345600, + classifiedCount: 9, + filters: const {'currency': 'CNY'}, + ); + } + + @override + Future getStatisticsBreakdown({ + required String dimension, + String currency = 'CNY', + }) async { + return StatisticsBreakdownResponse( + dimension: dimension, + rows: [ + StatisticsRow(key: '财务部', count: 4, amountCents: 120000), + StatisticsRow(key: '市场部', count: 2, amountCents: 80000), + ], + filters: const {'currency': 'CNY'}, + ); + } + + @override + Future getStatisticsTrend({ + String granularity = 'month', + String currency = 'CNY', + }) async { + return StatisticsTrendResponse( + granularity: granularity, + rows: [ + StatisticsTrendRow(date: '2026-04', count: 3, amountCents: 100000), + StatisticsTrendRow(date: '2026-05', count: 5, amountCents: 160000), + ], + filters: const {'currency': 'CNY'}, + ); + } + + @override + Future> listNormalizedRecords({ + int? sourceRecordId, + int skip = 0, + int limit = 20, + }) async { + return normalizedRecords; + } + + @override + Future> listClassifications( + int normalizedRecordId, { + String? reviewStatus, + }) async { + return classifications[normalizedRecordId] ?? const []; + } + + @override + Future createSourceRecord({ + required String sourceType, + String? sourceChannel, + String rawText = '', + String ingestionStatus = 'pending', + }) async { + final created = SourceRecordDto( + id: 101, + sourceType: SourceType.manual, + rawText: rawText, + ingestionStatus: IngestionStatus.pending, + createdAt: DateTime.utc(2026, 6, 12), + ); + createdSources.add(created); + return created; + } + + @override + Future createNormalizedRecord({ + int? primarySourceId, + required String recordType, + required String businessDate, + required int amountCents, + String currency = 'CNY', + required String direction, + String? department, + String? person, + String? counterparty, + String description = '', + String normalizationStatus = 'draft', + }) async { + final created = NormalizedRecordDto( + id: 102, + recordType: RecordType.expense, + businessDate: businessDate, + amountCents: amountCents, + direction: Direction.outflow, + department: department, + person: person, + description: description, + createdAt: DateTime.utc(2026, 6, 12), + ); + normalizedRecords.add(created); + classifications[created.id] = []; + return created; + } + + @override + Future createClassification( + int normalizedRecordId, { + required String category, + required String classifierKind, + String taxonomy = 'expense_type', + double? confidence, + String? modelVersion, + Map? tags, + }) async { + final created = ClassificationResultDto( + id: 900 + normalizedRecordId, + normalizedRecordId: normalizedRecordId, + taxonomy: taxonomy, + category: category, + classifierKind: ClassifierKind.manual, + reviewStatus: ReviewStatus.candidate, + isActive: true, + createdAt: DateTime.utc(2026, 6, 12), + updatedAt: DateTime.utc(2026, 6, 12), + ); + classifications[normalizedRecordId] = [created]; + return created; + } + + @override + Future reviewClassification( + int id, { + String? reviewStatus, + bool? isActive, + }) async { + for (final entry in classifications.entries) { + final index = entry.value.indexWhere((item) => item.id == id); + if (index >= 0) { + final current = entry.value[index]; + final updated = current.copyWith( + reviewStatus: reviewStatus == 'accepted' + ? ReviewStatus.accepted + : reviewStatus == 'rejected' + ? ReviewStatus.rejected + : current.reviewStatus, + isActive: isActive ?? current.isActive, + ); + entry.value[index] = updated; + bulkReviewLog.add(( + current.normalizedRecordId, + current.category, + reviewStatus ?? current.reviewStatus.name, + )); + return updated; + } + } + throw ApiException('Classification not found', statusCode: 404); + } + + @override + Future updateNormalizedRecord( + int id, { + String? recordType, + String? businessDate, + int? amountCents, + String? currency, + String? direction, + String? department, + String? person, + String? counterparty, + String? description, + String? normalizationStatus, + }) async { + final index = normalizedRecords.indexWhere((record) => record.id == id); + if (index < 0) { + throw ApiException('Normalized record not found', statusCode: 404); + } + final current = normalizedRecords[index]; + final updated = current.copyWith( + businessDate: businessDate ?? current.businessDate, + amountCents: amountCents ?? current.amountCents, + department: department ?? current.department, + person: person ?? current.person, + description: description ?? current.description, + ); + normalizedRecords[index] = updated; + updatedRecords.add((id, updated.description, updated.amountCents)); + return updated; + } +} + +void main() { + testWidgets('renders finance statistics workspace', (tester) async { + final client = _FakeFinanceApiClient(); + tester.view.physicalSize = const Size(1600, 2200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + MaterialApp( + home: FinanceWorkspaceScreen( + config: const FinanceModuleConfig(apiBaseUrl: 'http://localhost:8000'), + client: client, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Finance'), findsOneWidget); + expect(find.text('Department Breakdown'), findsOneWidget); + expect(find.text('Monthly Trend'), findsOneWidget); + expect(find.text('Manual Entry'), findsOneWidget); + expect(find.text('Review Queue'), findsWidgets); + expect(find.text('12'), findsOneWidget); + expect(find.text('¥3,456.00'), findsOneWidget); + expect(find.text('财务部'), findsOneWidget); + expect(find.text('2026-05'), findsOneWidget); + expect(find.text('taxi to airport'), findsOneWidget); + + await tester.enterText(find.byType(TextField).at(0), '出差打车票据'); + await tester.enterText(find.byType(TextField).at(2), '18800'); + await tester.enterText(find.byType(TextField).at(5), '差旅打车费用'); + await tester.tap(find.text('提交录入')); + await tester.pumpAndSettle(); + + expect(client.createdSources, hasLength(1)); + expect(find.text('录入成功。'), findsOneWidget); + + await tester.tap(find.byType(Checkbox).at(0)); + await tester.tap(find.byType(Checkbox).at(1)); + await tester.pumpAndSettle(); + + expect(find.text('已选 2 条'), findsOneWidget); + await tester.tap(find.text('批量确认')); + await tester.pumpAndSettle(); + await tester.tap(find.text('批量确认').last); + await tester.pumpAndSettle(); + + expect(client.bulkReviewLog.length, 2); + expect( + client.bulkReviewLog.every((entry) => entry.$3 == 'accepted'), + isTrue, + ); + + await tester.tap(find.text('编辑').first); + await tester.pumpAndSettle(); + + expect(find.textContaining('Editing #41'), findsOneWidget); + await tester.enterText(find.byType(TextField).at(2), '25600'); + await tester.enterText(find.byType(TextField).at(5), 'updated taxi'); + await tester.tap(find.text('保存修改')); + await tester.pumpAndSettle(); + + expect(client.updatedRecords, hasLength(1)); + expect(client.updatedRecords.first.$1, 41); + expect(client.updatedRecords.first.$2, 'updated taxi'); + expect(client.updatedRecords.first.$3, 25600); + }); + + testWidgets('shows validation message for invalid manual entry', (tester) async { + final client = _FakeFinanceApiClient(); + tester.view.physicalSize = const Size(1600, 2200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + MaterialApp( + home: FinanceWorkspaceScreen( + config: const FinanceModuleConfig(apiBaseUrl: 'http://localhost:8000'), + client: client, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).at(0), ''); + await tester.enterText(find.byType(TextField).at(1), '2026/06/12'); + await tester.enterText(find.byType(TextField).at(2), '-10'); + await tester.enterText(find.byType(TextField).at(5), ''); + await tester.tap(find.text('提交录入')); + await tester.pumpAndSettle(); + + expect(find.text('Raw Text 不能为空。'), findsOneWidget); + expect(client.createdSources, isEmpty); + }); +} diff --git a/src/studio/pubspec.yaml b/src/studio/pubspec.yaml index 9ba6f36..ae13cf7 100644 --- a/src/studio/pubspec.yaml +++ b/src/studio/pubspec.yaml @@ -51,6 +51,8 @@ dependencies: path: packages/qtadmin-dashboard qtadmin_navigation: path: packages/qtadmin-navigation + qtadmin_finance: + path: packages/qtadmin-finance dev_dependencies: flutter_test: diff --git a/src/studio/test/widgets/finance_workspace_route_test.dart b/src/studio/test/widgets/finance_workspace_route_test.dart new file mode 100644 index 0000000..ecf2d84 --- /dev/null +++ b/src/studio/test/widgets/finance_workspace_route_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_quanttide_finance/api/client.dart'; +import 'package:qtadmin_dashboard/dashboard_barrel.dart'; +import 'package:qtadmin_finance/finance.dart'; +import 'package:quanttide_finance/quanttide_finance.dart'; +import 'package:qtadmin_studio/router.dart'; + +class _FakeFinanceApiClient extends FinanceApiClient { + _FakeFinanceApiClient() : super('http://fake.api'); + + @override + Future getStatisticsSummary({ + String currency = 'CNY', + }) async { + return StatisticsSummaryResponse( + recordCount: 8, + amountCents: 123400, + classifiedCount: 6, + filters: const {'currency': 'CNY'}, + ); + } + + @override + Future getStatisticsBreakdown({ + required String dimension, + String currency = 'CNY', + }) async { + return StatisticsBreakdownResponse( + dimension: dimension, + rows: [StatisticsRow(key: '运营部', count: 3, amountCents: 45600)], + filters: const {'currency': 'CNY'}, + ); + } + + @override + Future getStatisticsTrend({ + String granularity = 'month', + String currency = 'CNY', + }) async { + return StatisticsTrendResponse( + granularity: granularity, + rows: [StatisticsTrendRow(date: '2026-06', count: 3, amountCents: 45600)], + filters: const {'currency': 'CNY'}, + ); + } + + @override + Future> listNormalizedRecords({ + int? sourceRecordId, + int skip = 0, + int limit = 20, + }) async { + return [ + NormalizedRecordDto( + id: 1, + recordType: RecordType.expense, + businessDate: '2026-06-01', + amountCents: 45600, + direction: Direction.outflow, + department: 'Ops', + person: 'Li', + description: 'server bill', + createdAt: DateTime.utc(2026, 6, 1), + ), + ]; + } + + @override + Future> listClassifications( + int normalizedRecordId, { + String? reviewStatus, + }) async { + return const []; + } +} + +void main() { + testWidgets('renders finance workspace screen', (tester) async { + tester.view.physicalSize = const Size(1600, 2200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + MaterialApp( + home: FinanceWorkspaceScreen( + config: const FinanceModuleConfig(apiBaseUrl: 'http://localhost:8000'), + client: _FakeFinanceApiClient(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Finance'), findsOneWidget); + expect(find.text('Department Breakdown'), findsOneWidget); + expect(find.text('Review Queue'), findsOneWidget); + }); + + testWidgets('finance route uses ScreenContext financeConfig', (tester) async { + tester.view.physicalSize = const Size(1600, 2200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final route = RouteConfig.find('finance'); + final context = ScreenContext( + dashboard: const Dashboard( + businessUnits: [], + functionCards: [], + ), + workspaceName: '量潮科技', + selectedWorkspace: 0, + financeConfig: const FinanceModuleConfig( + apiBaseUrl: 'http://finance.internal:9000', + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: FinanceWorkspaceScreen( + config: context.financeConfig, + client: _FakeFinanceApiClient(), + ), + ), + ); + await tester.pumpAndSettle(); + + final built = route.builder(context); + expect(built, isA()); + expect((built as FinanceWorkspaceScreen).config.apiBaseUrl, 'http://finance.internal:9000'); + }); +}