feat: initial project setup
- Add .NET 8 backend with Clean Architecture - Add React + Vite + TypeScript frontend - Implement authentication with JWT - Implement Azure Blob Storage client - Implement OCR integration - Implement supplier matching service - Implement voucher generation - Implement Fortnox provider - Add unit and integration tests - Add Docker Compose configuration
This commit is contained in:
879
DATABASE_SCHEMA.md
Normal file
879
DATABASE_SCHEMA.md
Normal file
@@ -0,0 +1,879 @@
|
||||
# Invoice Master - 数据库 Schema 设计
|
||||
|
||||
**版本**: v3.0
|
||||
**数据库**: PostgreSQL 15+
|
||||
**ORM**: Entity Framework Core 8
|
||||
**日期**: 2026-02-03
|
||||
|
||||
---
|
||||
|
||||
## 1. ER 图 (多会计系统版)
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────────────┐ ┌──────────────────┐
|
||||
│ users │ │ accounting_connections │ │ supplier_cache │
|
||||
├──────────────────┤ ├──────────────────────────┤ ├──────────────────┤
|
||||
│ id (PK) │◄──────┤ id (PK) │◄──────┤ id (PK) │
|
||||
│ email │ │ user_id (FK) │ │ connection_id(FK)│
|
||||
│ hashed_password │ │ provider (e.g. 'fortnox')│ │ supplier_number │
|
||||
│ is_active │ │ access_token_encrypted │ │ name │
|
||||
│ created_at │ │ refresh_token_encrypted │ │ org_number │
|
||||
└──────────────────┘ │ company_name │ │ cached_at │
|
||||
│ company_org_number │ └──────────────────┘
|
||||
│ UNIQUE(user_id, provider)│
|
||||
└───────────┬──────────────┘
|
||||
│
|
||||
│
|
||||
┌───────────▼───────────┐
|
||||
│ invoices │
|
||||
├───────────────────────┤
|
||||
│ id (PK) │
|
||||
│ connection_id (FK) │◄──────────────────────┐
|
||||
│ provider │ │
|
||||
│ file_path │ │
|
||||
│ extraction_data │ │
|
||||
│ supplier_number │ │
|
||||
│ voucher_number │ │
|
||||
│ status │ │
|
||||
└───────────┬───────────┘ │
|
||||
│ │
|
||||
│ │
|
||||
┌───────────▼───────────┐ ┌───────────────▼──┐
|
||||
│ invoice_reviews │ │ processing_queue │
|
||||
├───────────────────────┤ ├──────────────────┤
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ invoice_id (FK) │ │ invoice_id (FK) │
|
||||
│ reviewed_by │ │ status │
|
||||
│ corrections │ │ retry_count │
|
||||
│ approved │ │ error_message │
|
||||
└───────────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 表结构定义
|
||||
|
||||
### 2.1 users - 用户表
|
||||
|
||||
存储系统用户基本信息。
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
hashed_password VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_superuser BOOLEAN DEFAULT false,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_active ON users(is_active);
|
||||
```
|
||||
|
||||
### 2.2 accounting_connections - 会计系统连接表
|
||||
|
||||
存储多个会计系统的 OAuth 连接信息,一个用户可以连接多个会计系统。
|
||||
|
||||
```sql
|
||||
CREATE TABLE accounting_connections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 会计系统标识
|
||||
provider VARCHAR(50) NOT NULL, -- 'fortnox', 'visma', 'hogia', etc.
|
||||
|
||||
-- OAuth Tokens (加密存储)
|
||||
access_token_encrypted TEXT NOT NULL,
|
||||
refresh_token_encrypted TEXT NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
scope TEXT, -- 权限范围,不同 provider 格式不同
|
||||
|
||||
-- 公司信息
|
||||
company_name VARCHAR(255),
|
||||
company_org_number VARCHAR(20),
|
||||
|
||||
-- 默认设置 (各 provider 可能有不同的默认值)
|
||||
default_voucher_series VARCHAR(10) DEFAULT 'A',
|
||||
default_account_code INTEGER DEFAULT 5460,
|
||||
auto_attach_pdf BOOLEAN DEFAULT true,
|
||||
auto_create_supplier BOOLEAN DEFAULT false,
|
||||
|
||||
-- 状态
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- 一个用户可连接多个会计系统,但不能重复连接同一个
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_connections_user ON accounting_connections(user_id);
|
||||
CREATE INDEX idx_connections_provider ON accounting_connections(provider);
|
||||
CREATE INDEX idx_connections_active ON accounting_connections(is_active);
|
||||
CREATE INDEX idx_connections_expires ON accounting_connections(expires_at);
|
||||
```
|
||||
|
||||
### 2.3 invoices - 发票处理记录表
|
||||
|
||||
存储发票处理历史和状态,支持多会计系统。
|
||||
|
||||
```sql
|
||||
CREATE TYPE invoice_status AS ENUM (
|
||||
'pending', -- 等待处理
|
||||
'uploading', -- 正在上传
|
||||
'processing', -- OCR 处理中
|
||||
'preview', -- 等待用户确认
|
||||
'importing', -- 正在导入会计系统
|
||||
'imported', -- 已成功导入
|
||||
'failed' -- 处理失败
|
||||
);
|
||||
|
||||
CREATE TYPE supplier_match_action AS ENUM (
|
||||
'USE_EXISTING', -- 使用现有供应商
|
||||
'CREATE_NEW', -- 创建新供应商
|
||||
'SUGGEST_MATCH' -- 建议匹配
|
||||
);
|
||||
|
||||
CREATE TABLE invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
connection_id UUID NOT NULL REFERENCES accounting_connections(id) ON DELETE CASCADE,
|
||||
|
||||
-- 冗余存储 provider,便于查询
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
|
||||
-- 文件信息
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
file_hash VARCHAR(64), -- SHA-256 用于去重
|
||||
|
||||
-- OCR 提取结果 (JSONB 存储结构化数据)
|
||||
extraction_data JSONB,
|
||||
extraction_confidence DECIMAL(3,2), -- 0.00 - 1.00
|
||||
|
||||
-- 提取的字段 (冗余存储便于查询)
|
||||
extracted_supplier_name VARCHAR(255),
|
||||
extracted_supplier_org_number VARCHAR(20),
|
||||
extracted_invoice_number VARCHAR(100),
|
||||
extracted_invoice_date DATE,
|
||||
extracted_due_date DATE,
|
||||
extracted_amount_total DECIMAL(12,2),
|
||||
extracted_amount_vat DECIMAL(12,2),
|
||||
extracted_vat_rate INTEGER,
|
||||
extracted_ocr_number VARCHAR(50),
|
||||
extracted_bankgiro VARCHAR(50),
|
||||
extracted_plusgiro VARCHAR(50),
|
||||
extracted_currency VARCHAR(3) DEFAULT 'SEK',
|
||||
|
||||
-- 供应商匹配结果
|
||||
supplier_number VARCHAR(50),
|
||||
supplier_match_confidence DECIMAL(3,2),
|
||||
supplier_match_action supplier_match_action,
|
||||
|
||||
-- 会计凭证信息
|
||||
voucher_series VARCHAR(10),
|
||||
voucher_number VARCHAR(50),
|
||||
voucher_url TEXT,
|
||||
|
||||
-- 会计科目分配 (JSONB 数组)
|
||||
voucher_rows JSONB,
|
||||
|
||||
-- 处理状态
|
||||
status invoice_status DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
error_code VARCHAR(50),
|
||||
|
||||
-- 用户审核
|
||||
reviewed_by UUID REFERENCES users(id),
|
||||
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- 附件信息
|
||||
attachment_id VARCHAR(100),
|
||||
attachment_url TEXT,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
processed_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_invoices_connection ON invoices(connection_id);
|
||||
CREATE INDEX idx_invoices_provider ON invoices(provider);
|
||||
CREATE INDEX idx_invoices_status ON invoices(status);
|
||||
CREATE INDEX idx_invoices_created ON invoices(created_at DESC);
|
||||
CREATE INDEX idx_invoices_file_hash ON invoices(file_hash);
|
||||
CREATE INDEX idx_invoices_supplier ON invoices(extracted_supplier_org_number);
|
||||
|
||||
-- GIN 索引用于 JSONB 查询
|
||||
CREATE INDEX idx_invoices_extraction ON invoices USING GIN (extraction_data);
|
||||
```
|
||||
|
||||
### 2.4 supplier_cache - 供应商缓存表
|
||||
|
||||
缓存各会计系统的供应商列表,减少 API 调用。
|
||||
|
||||
```sql
|
||||
CREATE TABLE supplier_cache (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
connection_id UUID NOT NULL REFERENCES accounting_connections(id) ON DELETE CASCADE,
|
||||
|
||||
-- 供应商信息 (各 provider 字段可能不同)
|
||||
supplier_number VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
organisation_number VARCHAR(20),
|
||||
address1 VARCHAR(255),
|
||||
address2 VARCHAR(255),
|
||||
postcode VARCHAR(20),
|
||||
city VARCHAR(100),
|
||||
country VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
email VARCHAR(255),
|
||||
bankgiro_number VARCHAR(50),
|
||||
plusgiro_number VARCHAR(50),
|
||||
|
||||
-- 缓存元数据
|
||||
cached_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + INTERVAL '1 hour',
|
||||
|
||||
UNIQUE(connection_id, supplier_number)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_supplier_cache_connection ON supplier_cache(connection_id);
|
||||
CREATE INDEX idx_supplier_cache_org ON supplier_cache(organisation_number);
|
||||
CREATE INDEX idx_supplier_cache_name ON supplier_cache(name);
|
||||
CREATE INDEX idx_supplier_cache_expires ON supplier_cache(expires_at);
|
||||
```
|
||||
|
||||
### 2.5 invoice_reviews - 发票审核记录表
|
||||
|
||||
存储用户对 OCR 结果的修改和确认。
|
||||
|
||||
```sql
|
||||
CREATE TABLE invoice_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
|
||||
-- 审核人
|
||||
reviewed_by UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
-- 用户修正的数据 (JSONB)
|
||||
corrections JSONB,
|
||||
|
||||
-- 审核结果
|
||||
approved BOOLEAN NOT NULL,
|
||||
rejection_reason TEXT,
|
||||
|
||||
-- 时间戳
|
||||
reviewed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_reviews_invoice ON invoice_reviews(invoice_id);
|
||||
CREATE INDEX idx_reviews_user ON invoice_reviews(reviewed_by);
|
||||
```
|
||||
|
||||
### 2.6 processing_queue - 处理队列表
|
||||
|
||||
异步任务队列,用于发票处理。
|
||||
|
||||
```sql
|
||||
CREATE TYPE queue_status AS ENUM (
|
||||
'queued',
|
||||
'processing',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
);
|
||||
|
||||
CREATE TABLE processing_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
|
||||
-- 任务类型
|
||||
task_type VARCHAR(50) NOT NULL, -- 'ocr', 'import', 'upload_attachment'
|
||||
|
||||
-- 状态
|
||||
status queue_status DEFAULT 'queued',
|
||||
priority INTEGER DEFAULT 5, -- 1-10, 数字越小优先级越高
|
||||
|
||||
-- 重试机制
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
max_retries INTEGER DEFAULT 3,
|
||||
|
||||
-- 调度
|
||||
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
started_at TIMESTAMP WITH TIME ZONE,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- 错误信息
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
|
||||
-- 处理结果
|
||||
result_data JSONB,
|
||||
|
||||
-- 工作节点
|
||||
worker_id VARCHAR(100)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_queue_status ON processing_queue(status);
|
||||
CREATE INDEX idx_queue_scheduled ON processing_queue(scheduled_at);
|
||||
CREATE INDEX idx_queue_invoice ON processing_queue(invoice_id);
|
||||
CREATE INDEX idx_queue_priority ON processing_queue(status, priority, scheduled_at);
|
||||
```
|
||||
|
||||
### 2.7 account_mappings - 科目映射表
|
||||
|
||||
存储用户自定义的会计科目映射规则,支持多会计系统。
|
||||
|
||||
```sql
|
||||
CREATE TABLE account_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
connection_id UUID NOT NULL REFERENCES accounting_connections(id) ON DELETE CASCADE,
|
||||
|
||||
-- 会计系统标识
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
|
||||
-- 匹配规则
|
||||
supplier_org_number VARCHAR(20), -- 特定供应商
|
||||
keyword VARCHAR(100), -- 关键词匹配
|
||||
|
||||
-- 科目设置 (不同 provider 可能有不同的科目体系)
|
||||
account_code INTEGER NOT NULL,
|
||||
account_name VARCHAR(255),
|
||||
vat_rate INTEGER DEFAULT 25,
|
||||
|
||||
-- 描述模板
|
||||
description_template VARCHAR(255), -- e.g., "{supplier_name} - {invoice_number}"
|
||||
|
||||
-- 优先级 (数字越大优先级越高)
|
||||
priority INTEGER DEFAULT 0,
|
||||
|
||||
-- 状态
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- 约束: supplier_org_number 和 keyword 至少有一个
|
||||
CONSTRAINT check_mapping_rule CHECK (
|
||||
supplier_org_number IS NOT NULL OR keyword IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_mappings_connection ON account_mappings(connection_id);
|
||||
CREATE INDEX idx_mappings_provider ON account_mappings(provider);
|
||||
CREATE INDEX idx_mappings_supplier ON account_mappings(supplier_org_number);
|
||||
CREATE INDEX idx_mappings_keyword ON account_mappings(keyword);
|
||||
```
|
||||
|
||||
### 2.8 api_logs - API 日志表
|
||||
|
||||
记录关键 API 调用用于审计和调试。
|
||||
|
||||
```sql
|
||||
CREATE TABLE api_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- 请求信息
|
||||
request_id VARCHAR(100) NOT NULL,
|
||||
connection_id UUID REFERENCES accounting_connections(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
|
||||
-- API 详情
|
||||
provider VARCHAR(50), -- 'fortnox', 'visma', 'ocr', 'internal'
|
||||
endpoint VARCHAR(255) NOT NULL,
|
||||
method VARCHAR(10) NOT NULL,
|
||||
|
||||
-- 状态
|
||||
status_code INTEGER,
|
||||
is_success BOOLEAN,
|
||||
|
||||
-- 性能
|
||||
request_started_at TIMESTAMP WITH TIME ZONE,
|
||||
request_ended_at TIMESTAMP WITH TIME ZONE,
|
||||
duration_ms INTEGER,
|
||||
|
||||
-- 错误
|
||||
error_code VARCHAR(50),
|
||||
error_message TEXT,
|
||||
|
||||
-- 元数据 (脱敏)
|
||||
metadata JSONB,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_logs_request ON api_logs(request_id);
|
||||
CREATE INDEX idx_logs_connection ON api_logs(connection_id);
|
||||
CREATE INDEX idx_logs_provider ON api_logs(provider);
|
||||
CREATE INDEX idx_logs_created ON api_logs(created_at DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 视图
|
||||
|
||||
### 3.1 invoice_summary - 发票摘要视图
|
||||
|
||||
```sql
|
||||
CREATE VIEW invoice_summary AS
|
||||
SELECT
|
||||
i.id,
|
||||
i.connection_id,
|
||||
i.provider,
|
||||
i.original_filename,
|
||||
i.status,
|
||||
i.extraction_confidence,
|
||||
i.extracted_supplier_name,
|
||||
i.extracted_invoice_number,
|
||||
i.extracted_invoice_date,
|
||||
i.extracted_amount_total,
|
||||
i.extracted_currency,
|
||||
i.supplier_number,
|
||||
i.supplier_match_confidence,
|
||||
i.voucher_number,
|
||||
i.voucher_series,
|
||||
i.created_at,
|
||||
i.processed_at,
|
||||
ac.company_name as connection_company,
|
||||
CASE
|
||||
WHEN i.status = 'imported' THEN true
|
||||
ELSE false
|
||||
END as is_imported
|
||||
FROM invoices i
|
||||
JOIN accounting_connections ac ON i.connection_id = ac.id;
|
||||
```
|
||||
|
||||
### 3.2 connection_stats - 连接统计视图
|
||||
|
||||
```sql
|
||||
CREATE VIEW connection_stats AS
|
||||
SELECT
|
||||
ac.id as connection_id,
|
||||
ac.provider,
|
||||
ac.company_name,
|
||||
ac.is_active,
|
||||
COUNT(i.id) as total_invoices,
|
||||
COUNT(CASE WHEN i.status = 'imported' THEN 1 END) as imported_invoices,
|
||||
COUNT(CASE WHEN i.status = 'failed' THEN 1 END) as failed_invoices,
|
||||
AVG(i.extraction_confidence) as avg_confidence,
|
||||
MAX(i.created_at) as last_invoice_at
|
||||
FROM accounting_connections ac
|
||||
LEFT JOIN invoices i ON ac.id = i.connection_id
|
||||
GROUP BY ac.id, ac.provider, ac.company_name, ac.is_active;
|
||||
```
|
||||
|
||||
### 3.3 user_connections_view - 用户连接视图
|
||||
|
||||
```sql
|
||||
CREATE VIEW user_connections_view AS
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.email,
|
||||
ac.id as connection_id,
|
||||
ac.provider,
|
||||
ac.company_name,
|
||||
ac.company_org_number,
|
||||
ac.is_active as connection_active,
|
||||
ac.created_at as connected_at
|
||||
FROM users u
|
||||
LEFT JOIN accounting_connections ac ON u.id = ac.user_id;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 函数和触发器
|
||||
|
||||
### 4.1 自动更新时间戳
|
||||
|
||||
```sql
|
||||
-- 创建更新函数
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 应用到各表
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_connections_updated_at BEFORE UPDATE ON accounting_connections
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_invoices_updated_at BEFORE UPDATE ON invoices
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_mappings_updated_at BEFORE UPDATE ON account_mappings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
```
|
||||
|
||||
### 4.2 发票状态变更日志
|
||||
|
||||
```sql
|
||||
CREATE TABLE invoice_status_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
old_status invoice_status,
|
||||
new_status invoice_status NOT NULL,
|
||||
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
changed_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- 触发器记录状态变更
|
||||
CREATE OR REPLACE FUNCTION log_invoice_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||
INSERT INTO invoice_status_history (invoice_id, old_status, new_status)
|
||||
VALUES (NEW.id, OLD.status, NEW.status);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER invoice_status_change_trigger
|
||||
AFTER UPDATE ON invoices
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_invoice_status_change();
|
||||
```
|
||||
|
||||
### 4.3 自动设置 provider 字段
|
||||
|
||||
```sql
|
||||
-- 当插入发票时,自动从 connection 获取 provider
|
||||
CREATE OR REPLACE FUNCTION set_invoice_provider()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.provider IS NULL THEN
|
||||
SELECT provider INTO NEW.provider
|
||||
FROM accounting_connections
|
||||
WHERE id = NEW.connection_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER set_invoice_provider_trigger
|
||||
BEFORE INSERT ON invoices
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_invoice_provider();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 初始化数据
|
||||
|
||||
```sql
|
||||
-- 默认会计科目映射 (BAS 2024 - Fortnox)
|
||||
INSERT INTO account_mappings (connection_id, provider, account_code, account_name, vat_rate, description_template, priority)
|
||||
SELECT
|
||||
ac.id,
|
||||
ac.provider,
|
||||
code,
|
||||
name,
|
||||
vat_rate,
|
||||
description_template,
|
||||
priority
|
||||
FROM accounting_connections ac
|
||||
CROSS JOIN (VALUES
|
||||
(2440, 'Leverantörsskulder', 0, 'Faktura {invoice_number}', 0),
|
||||
(2610, 'Ingående moms', 25, 'Moms 25%', 0),
|
||||
(2620, 'Ingående moms', 12, 'Moms 12%', 0),
|
||||
(2630, 'Ingående moms', 6, 'Moms 6%', 0),
|
||||
(5460, 'Kontorsmaterial', 25, 'Kontorsmaterial', 1),
|
||||
(5710, 'Frakter', 25, 'Frakt', 1),
|
||||
(6100, 'Övriga externa tjänster', 25, 'Övriga tjänster', 1),
|
||||
(6210, 'Konsultarvoden', 25, 'Konsult', 2)
|
||||
) AS defaults(code, name, vat_rate, description_template, priority)
|
||||
WHERE ac.provider = 'fortnox';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 迁移脚本
|
||||
|
||||
### 6.1 从 v1.0 迁移到 v2.0
|
||||
|
||||
```sql
|
||||
-- 迁移脚本: 从单 Fortnox 支持到多会计系统支持
|
||||
|
||||
-- 1. 创建新表 accounting_connections
|
||||
CREATE TABLE accounting_connections (
|
||||
-- ... (见 2.2 节定义)
|
||||
);
|
||||
|
||||
-- 2. 迁移 fortnox_tenants 数据到 accounting_connections
|
||||
INSERT INTO accounting_connections (
|
||||
id, user_id, provider, access_token_encrypted, refresh_token_encrypted,
|
||||
expires_at, scope, company_name, company_org_number,
|
||||
default_voucher_series, default_account_code, auto_attach_pdf, auto_create_supplier,
|
||||
is_active, last_sync_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, user_id, 'fortnox', access_token_encrypted, refresh_token_encrypted,
|
||||
expires_at, scope, company_name, company_org_number,
|
||||
default_voucher_series, default_account_code, auto_attach_pdf, auto_create_supplier,
|
||||
is_active, last_sync_at, created_at, updated_at
|
||||
FROM fortnox_tenants;
|
||||
|
||||
-- 3. 创建新表 invoices
|
||||
CREATE TABLE invoices (
|
||||
-- ... (见 2.3 节定义)
|
||||
);
|
||||
|
||||
-- 4. 迁移 fortnox_invoices 数据到 invoices
|
||||
INSERT INTO invoices (
|
||||
id, connection_id, provider, original_filename, storage_path, file_size, file_hash,
|
||||
extraction_data, extraction_confidence,
|
||||
extracted_supplier_name, extracted_supplier_org_number, extracted_invoice_number,
|
||||
extracted_invoice_date, extracted_due_date, extracted_amount_total, extracted_amount_vat,
|
||||
extracted_vat_rate, extracted_ocr_number, extracted_bankgiro, extracted_plusgiro, extracted_currency,
|
||||
supplier_number, supplier_match_confidence, supplier_match_action,
|
||||
voucher_series, voucher_number, voucher_url, voucher_rows,
|
||||
status, error_message, error_code,
|
||||
reviewed_by, reviewed_at,
|
||||
attachment_id, attachment_url,
|
||||
created_at, updated_at, processed_at
|
||||
)
|
||||
SELECT
|
||||
id, tenant_id, 'fortnox', original_filename, storage_path, file_size, file_hash,
|
||||
extraction_data, extraction_confidence,
|
||||
extracted_supplier_name, extracted_supplier_org_number, extracted_invoice_number,
|
||||
extracted_invoice_date, extracted_due_date, extracted_amount_total, extracted_amount_vat,
|
||||
extracted_vat_rate, extracted_ocr_number, extracted_bankgiro, extracted_plusgiro, extracted_currency,
|
||||
supplier_number, supplier_match_confidence, supplier_match_action,
|
||||
voucher_series, voucher_number, voucher_url, voucher_rows,
|
||||
status, error_message, error_code,
|
||||
reviewed_by, reviewed_at,
|
||||
attachment_id, attachment_url,
|
||||
created_at, updated_at, processed_at
|
||||
FROM fortnox_invoices;
|
||||
|
||||
-- 5. 更新外键引用
|
||||
-- 更新 supplier_cache
|
||||
ALTER TABLE supplier_cache DROP CONSTRAINT supplier_cache_tenant_id_fkey;
|
||||
ALTER TABLE supplier_cache RENAME COLUMN tenant_id TO connection_id;
|
||||
ALTER TABLE supplier_cache ADD CONSTRAINT supplier_cache_connection_id_fkey
|
||||
FOREIGN KEY (connection_id) REFERENCES accounting_connections(id) ON DELETE CASCADE;
|
||||
|
||||
-- 6. 删除旧表 (在确认迁移成功后)
|
||||
-- DROP TABLE fortnox_invoices;
|
||||
-- DROP TABLE fortnox_tenants;
|
||||
|
||||
-- 7. 创建新索引和视图
|
||||
-- ... (见相应章节)
|
||||
```
|
||||
|
||||
### 6.2 初始迁移 (新部署)
|
||||
|
||||
```sql
|
||||
-- 启用 UUID 扩展
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- 创建枚举类型
|
||||
CREATE TYPE invoice_status AS ENUM ('pending', 'uploading', 'processing', 'preview', 'importing', 'imported', 'failed');
|
||||
CREATE TYPE supplier_match_action AS ENUM ('USE_EXISTING', 'CREATE_NEW', 'SUGGEST_MATCH');
|
||||
CREATE TYPE queue_status AS ENUM ('queued', 'processing', 'completed', 'failed', 'cancelled');
|
||||
|
||||
-- 创建表 (按依赖顺序)
|
||||
-- 1. users
|
||||
-- 2. accounting_connections
|
||||
-- 3. invoices
|
||||
-- 4. supplier_cache
|
||||
-- 5. invoice_reviews
|
||||
-- 6. processing_queue
|
||||
-- 7. account_mappings
|
||||
-- 8. api_logs
|
||||
-- 9. invoice_status_history
|
||||
|
||||
-- 创建索引
|
||||
-- 创建视图
|
||||
-- 创建函数和触发器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 性能优化建议
|
||||
|
||||
### 7.1 分区策略
|
||||
|
||||
对于 `invoices` 和 `api_logs` 表,建议按时间分区:
|
||||
|
||||
```sql
|
||||
-- 按月分区 (示例)
|
||||
CREATE TABLE invoices_partitioned (
|
||||
LIKE invoices INCLUDING ALL
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
-- 创建分区
|
||||
CREATE TABLE invoices_2024_01 PARTITION OF invoices_partitioned
|
||||
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
|
||||
|
||||
CREATE TABLE invoices_2024_02 PARTITION OF invoices_partitioned
|
||||
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
|
||||
```
|
||||
|
||||
### 7.2 多会计系统查询优化
|
||||
|
||||
```sql
|
||||
-- 为常用查询创建复合索引
|
||||
CREATE INDEX idx_invoices_provider_status ON invoices(provider, status);
|
||||
CREATE INDEX idx_invoices_connection_created ON invoices(connection_id, created_at DESC);
|
||||
|
||||
-- 为 provider 统计查询优化
|
||||
CREATE INDEX idx_connections_user_provider ON accounting_connections(user_id, provider);
|
||||
```
|
||||
|
||||
### 7.3 清理策略
|
||||
|
||||
```sql
|
||||
-- 定期清理过期缓存
|
||||
DELETE FROM supplier_cache WHERE expires_at < NOW();
|
||||
|
||||
-- 归档旧日志
|
||||
-- 建议将 90 天前的 api_logs 迁移到冷存储
|
||||
CREATE TABLE api_logs_archive (LIKE api_logs INCLUDING ALL);
|
||||
|
||||
-- 归档脚本
|
||||
INSERT INTO api_logs_archive
|
||||
SELECT * FROM api_logs
|
||||
WHERE created_at < NOW() - INTERVAL '90 days';
|
||||
|
||||
DELETE FROM api_logs
|
||||
WHERE created_at < NOW() - INTERVAL '90 days';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 备份策略
|
||||
|
||||
| 数据类型 | 备份频率 | 保留期 |
|
||||
|----------|----------|--------|
|
||||
| 全量备份 | 每日 | 30 天 |
|
||||
| 增量备份 | 每小时 | 7 天 |
|
||||
| 事务日志 | 持续 | 7 天 |
|
||||
|
||||
```sql
|
||||
-- 关键表逻辑备份示例
|
||||
pg_dump --table=accounting_connections --table=invoices --table=users > backup.sql
|
||||
|
||||
-- 按 provider 备份
|
||||
pg_dump --table=invoices --where="provider = 'fortnox'" > fortnox_invoices_backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. EF Core 迁移
|
||||
|
||||
### 9.1 创建迁移
|
||||
|
||||
```bash
|
||||
# 添加迁移
|
||||
cd backend/src/InvoiceMaster.Infrastructure
|
||||
dotnet ef migrations add InitialCreate --startup-project ../InvoiceMaster.API
|
||||
|
||||
# 更新数据库
|
||||
dotnet ef database update --startup-project ../InvoiceMaster.API
|
||||
|
||||
# 生成 SQL 脚本
|
||||
dotnet ef migrations script --startup-project ../InvoiceMaster.API -o migration.sql
|
||||
```
|
||||
|
||||
### 9.2 领域事件存储配置
|
||||
|
||||
```csharp
|
||||
// InvoiceMaster.Infrastructure/Data/Configurations/DomainEventConfiguration.cs
|
||||
public class DomainEventConfiguration : IEntityTypeConfiguration<DomainEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DomainEvent> builder)
|
||||
{
|
||||
builder.ToTable("DomainEvents");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.EventType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property(e => e.AggregateType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property(e => e.Payload)
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
builder.HasIndex(e => new { e.AggregateType, e.AggregateId })
|
||||
.HasDatabaseName("IX_DomainEvents_Aggregate");
|
||||
|
||||
builder.HasIndex(e => e.OccurredAt)
|
||||
.HasDatabaseName("IX_DomainEvents_OccurredAt");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Schema 变更日志
|
||||
|
||||
### v3.0 (2026-02-03)
|
||||
|
||||
**技术栈变更:**
|
||||
- Python/SQLAlchemy → .NET 8/EF Core 8
|
||||
- 新增 `DomainEvents` 表用于审计
|
||||
|
||||
**新增表:**
|
||||
- `DomainEvents` - 领域事件存储(审计)
|
||||
|
||||
**EF Core 配置:**
|
||||
- 所有表使用 Fluent API 配置
|
||||
- 启用全局查询过滤器(软删除)
|
||||
- 配置领域事件拦截器
|
||||
|
||||
### v2.0 (2026-02-03)
|
||||
|
||||
**新增表:**
|
||||
- `accounting_connections` - 替换 `fortnox_tenants`,支持多会计系统
|
||||
- `invoices` - 替换 `fortnox_invoices`,添加 `provider` 字段
|
||||
|
||||
**修改表:**
|
||||
- `supplier_cache`: `tenant_id` → `connection_id`
|
||||
- `account_mappings`: 添加 `connection_id` 和 `provider` 字段
|
||||
- `api_logs`: `tenant_id` → `connection_id`,`api_name` → `provider`
|
||||
|
||||
**新增视图:**
|
||||
- `connection_stats` - 替换 `tenant_stats`
|
||||
- `user_connections_view` - 用户连接视图
|
||||
|
||||
**新增触发器:**
|
||||
- `set_invoice_provider_trigger` - 自动设置 provider 字段
|
||||
|
||||
---
|
||||
|
||||
**文档历史:**
|
||||
|
||||
| 版本 | 日期 | 作者 | 变更 |
|
||||
|------|------|------|------|
|
||||
| 3.0 | 2026-02-03 | Claude Code | 重构为 .NET + EF Core + 审计支持 |
|
||||
| 2.0 | 2026-02-03 | Claude Code | 重构为多会计系统 Schema |
|
||||
| 1.0 | 2026-02-03 | Claude Code | 初始版本 |
|
||||
Reference in New Issue
Block a user