# project-002-后端服务框架

一个 Python 后端服务，面向**荷兰 VAT/EPR 申报文档自动化**：从 SaaS 业务库（SQL Server）读取注册订单与企业、法人、证件附件数据，经腾讯云 OCR 识别补充字段，按国家模板渲染 Word 文档、插入证件照片、合并转 PDF，上传腾讯云 COS，并回写生成状态与附件记录。

- 创建日期：2026-08-22
- 状态：进行中（荷兰 VAT/EPR 申报文档生成已可用；SaaS 部分回写待补）

## 目录结构

```
project-002-后端服务框架\
├── README.md
├── docs\
│   ├── 01-需求说明.md          # 需求与决策记录
│   └── 02-API接口.md           # API 参考
├── code\
│   ├── app\
│   │   ├── main.py             # FastAPI 入口
│   │   ├── init_db.py          # 建表 / 灌入测试数据
│   │   ├── api\                # 路由：health / template / records / enterprise / countries / convert
│   │   ├── core\               # 配置 / 数据库 / 依赖注入 / 响应 / 日志 / 路径 / 国家 / 下载 / 图片
│   │   ├── jobs\               # 定时任务（文档生成轮询）
│   │   ├── models\             # ORM 模型（源库 / 目标库）
│   │   ├── repositories\       # 数据访问层（企业 / 订单 / 记录 / 生成日志）
│   │   ├── schemas\            # 请求 / 响应模型
│   │   ├── scripts\            # 一次性脚本（OCR 企业附件）
│   │   └── services\           # 业务逻辑（渲染编排 / 企业文档 / OCR / COS / 转换 / 合并）
│   │       └── renderers\      # 格式渲染器（docx / xlsx / pdf / weee）
│   ├── templates\              # 模板文件（at\ 奥地利、nl\ 荷兰）
│   ├── tests\                  # 单元测试
│   ├── requirements.txt
│   ├── requirements-dev.txt
│   ├── pytest.ini
│   ├── .env                    # 实际配置（含密钥，勿提交）
│   └── .env.example
├── data\                       # 本地 SQLite 兜底库（source.db / target.db）
├── outputs\                    # 生成的文件（generate\ 下按企业名归档）
└── logs\                       # 运行日志
```

## 核心能力

### 1. 双库（源 / 目标）读写分离

配置两个连接串：`SOURCE` 为外部只读源库（SaaS 业务库），`TARGET` 为本系统所有的目标库，各自独立连接池：

```ini
SOURCE_DB_URL=mssql+pyodbc://user:pass@host:port/vat_db?driver=ODBC+Driver+18+for+SQL+Server
TARGET_DB_URL=mssql+pyodbc://user:pass@host:port/rpa?driver=ODBC+Driver+18+for+SQL+Server
```

- 不配置时自动回退到 `data/source.db` 与 `data/target.db`（SQLite），便于本地开发与测试。
- 代码中通过依赖注入选择：`Depends(get_source_db)` / `Depends(get_target_db)`。

### 2. API 接口

- 统一响应结构：`{ "code": 0, "message": "success", "data": ... }`
- 全局异常兜底（业务异常 / 参数校验 / 未捕获异常）
- 自动 Swagger 文档（`/docs`）

### 3. 企业文档自动生成（VAT / EPR）

`POST /api/enterprise/{code}` 按企业代码一次性完成整条流程：

1. 从源库关联查询企业 / 法人 / 附件信息（`VATBusinessRecord` + `VATRegInfo` + `Base_Customer_Company` + `Base_Sales_Platform_Shop` + `Base_AnnexesFile`）。
2. 按企业国家（`Country`）查找对应模板目录（`templates/<国家>/`）。
3. 下载证件照 / 营业执照附件，PDF 转图、EXIF 转正。
4. 身份证正反面 OCR（腾讯云）补充「签发城市」「法人性别」。
5. 逐个模板渲染：注册表为**可填写 PDF 表单**（填字段值），其余为 **DOCX**（占位符替换 + 证件照片插入后转 PDF）。
6. 合并为单个 PDF（pymupdf）。
7. 上传 COS；回写 `VATRegInfo.PushTaxBureauStatus` 与 `Base_AnnexesFile` 附件记录。

> 当前 `_DETAIL_SQL` 硬编码 `Country='nl'`（荷兰）；模板目录现有 `at\`（奥地利）与 `nl\`（荷兰）。
> 生成过程中的环节 / 异常会写入目标库 `vat_nl_file_log`（见「数据库表」）；单附件 / 单模板失败跳过继续、记日志，不中断整条。

### 4. 模板占位符替换与文件格式转换

- 占位符形式：`<<key>>`、`{{key}}`、`((key))`、`《key》`（键可为中文）。
- 模板类型与实现：

| 模板 | 实现 | 说明 |
| --- | --- | --- |
| `.docx` | python-docx | 替换正文、表格、页眉页脚；支持跨 run 拆分占位符兜底；WEEE 模板自动勾选 ☐/☒ 分类 |
| `.xlsx` | openpyxl | 替换单元格；整格占位且值为数字时保留数值类型 |
| `.pdf` | pymupdf | 填充 AcroForm 表单字段（字段「值」里放占位符，如【营业执照号码】） |
| 文本类 | 原生替换 | txt/md/html/xml/json/csv 等 |

- 文件格式转换 `POST /api/convert/{target}`：docx/doc ↔ pdf，后端可选 **Microsoft Office COM** 或 **LibreOffice headless**（由 `PDF_CONVERTER` 指定，见「配置说明」）。

### 5. 定时文档生成 job（轮询）

独立进程 `app/jobs/generate_documents_job.py`，自动扫描待推送记录（`PushType='101' AND PushTaxBureauStatus=5`）生成文档：

```powershell
cd "E:\personal info\workspace\project-002-后端服务框架\code"

# 正常：轮询执行（有记录处理完休眠 1 分钟；空结果休眠 10 分钟）
python -m app.jobs.generate_documents_job

# 测试：执行一轮后退出
python -m app.jobs.generate_documents_job --once
```

- 待推送记录用 `get_schedule_recode` 查询（一记录一行、不含附件），每条记录再按 code 用 `get_annex_files` 取附件，复用 `generate_from_record` 生成。
- 无附件或生成失败回写 `PushTaxBureauStatus=-1`，成功回写 `6`，均脱离待推送集合。
- 间隔可配：`JOB_POLL_SECONDS`（默认 60）、`JOB_EMPTY_SLEEP_SECONDS`（默认 600）。
- 法国(FR) job：`python -m app.jobs.generate_documents_job_fr`（`--once` 同）；调度查询与日志按 `country` 分离（NL→`vat_nl_file_log`，FR→`epr_fr_file_log`）。

### 注册为 Windows 任务计划程序

**职责划分（避免与 job 内部轮询冲突）**：任务计划程序只做「进程守护」——开机启动一次、
崩溃后自动重启，它**不负责**定时频率；业务调度（多久查一次、查什么）完全由 job 内部的
`while True` 轮询负责（间隔 `JOB_POLL_SECONDS` / `JOB_EMPTY_SLEEP_SECONDS`）。二者各管一段、互不冲突。

> ⚠️ 唯一会冲突的写法：给触发器设「重复任务间隔（每 N 分钟）」且 job 用**常驻模式**（不带
> `--once`）。此时每 N 分钟会再拉起一个新实例，而旧实例仍在轮询不退出，导致多实例叠加、
> 重复处理同一条记录。因此常驻模式下触发器只用「启动时」。
>
> 若希望由任务计划程序掌握调度频率，改用**一次性模式**：触发器设「每天」或「每 N 分钟重复」，
> 操作参数改为 `-m app.jobs.generate_documents_job --once`（每轮跑完即退出，不会叠加）。

1. 新建启动脚本 `code/win_bat/vat_nl_file_start_job.bat`：

   ```bat
   @echo off
   rem Document generation job launcher (for Windows Task Scheduler)
   rem cd to code\ (parent of win_bat\), so .env and templates/ are found
   cd /d "%~dp0.."
   rem Reads code\.env by default; uncomment the next line for production config:
   rem set APP_ENV=prod
   python -m app.jobs.generate_documents_job
   ```

2. `Win+R` 输入 `taskschd.msc` 打开「任务计划程序」，右侧点「创建任务…」（非「创建基本任务」）。
3. 配置任务：
   - 常规：名称如 `vat-doc-job`；勾选「不管用户是否登录都要运行」；「配置」选 Windows 10/11。
   - 触发器：新建 →「启动时」（一次性，开机拉起）；可另加「任务失败后：每 1 分钟重启一次，最多 3 次」作**崩溃恢复**（仅进程异常退出才触发，非周期调度）。
   - 操作：新建 →「启动程序」；程序/脚本填 `E:\personal info\workspace\project-002-后端服务框架\code\win_bat\vat_nl_file_start_job.bat`；「起始于」填 `E:\personal info\workspace\project-002-后端服务框架\code\win_bat`。
   - 条件：取消勾选「只有在计算机使用交流电源时才启动此任务」。
4. 环境变量：脚本默认读 `code\.env`（不设 `APP_ENV`）；如需生产配置，取消脚本里 `set APP_ENV=prod` 的注释。
5. 若 `python` 不在任务计划程序运行账户的 PATH 中，把脚本里的 `python` 改为 python.exe 绝对路径（如 `E:\tool\python\python.exe`）。
6. 验证：右键任务「运行」观察 `logs\` 日志持续输出；或先手动 `python -m app.jobs.generate_documents_job --once` 跑一轮冒烟。

## 快速开始

### 前置条件

- Python 3.12+（本机为 3.14）
- SQL Server 需安装 **ODBC Driver 17/18**
- PDF 转换（`/api/template/render` 的 `to_pdf`、`/api/convert`）依赖 **Microsoft Office + pywin32** 或 **LibreOffice**（二选一；无 Office 的服务器用 LibreOffice，并把 `PDF_CONVERTER` 设为 `libreoffice`）

### 步骤

```powershell
cd "E:\personal info\workspace\project-002-后端服务框架\code"

# 安装依赖
python -m pip install -r requirements.txt
python -m pip install -r requirements-dev.txt

# 复制并修改配置（不配则自动用本地 SQLite 兜底）
Copy-Item .env.example .env

# 多环境可复制为 .env.dev / .env.prod，启动时用 APP_ENV 选择（见「配置说明」→「多环境配置」）

# 启动
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```

启动后访问：
- API 文档：http://127.0.0.1:8000/docs
- 健康检查：http://127.0.0.1:8000/api/health

## 配置说明

配置集中在 `code/.env`（可用 `APP_ENV` 切换 `.env.dev`/`.env.prod`，见下文「多环境配置」；字段定义见 `app/core/config.py` 的 `Settings`）：

| 键 | 默认 | 说明 |
| --- | --- | --- |
| `SOURCE_DB_URL` | 空 → SQLite 兜底 | 外部只读源库连接串 |
| `TARGET_DB_URL` | 空 → SQLite 兜底 | 本系统目标库连接串 |
| `DB_POOL_SIZE` / `DB_POOL_RECYCLE` / `DB_ECHO` | 5 / 1800 / false | 连接池 |
| `DATA_DIR` / `TEMPLATE_DIR` / `OUTPUT_DIR` / `LOG_DIR` | 按项目结构自动定位 | 目录 |
| `CORS_ORIGINS` | `*` | 逗号分隔的来源 |
| `TENCENT_SECRET_ID` / `TENCENT_SECRET_KEY` / `TENCENT_REGION` | `ap-guangzhou` | 腾讯云 OCR |
| `COS_SECRET_ID` / `COS_SECRET_KEY` / `COS_REGION` / `COS_BUCKET` / `COS_BASE_DIR` | `ap-guangzhou` / `outputs` | 腾讯云 COS |
| `JOB_POLL_SECONDS` / `JOB_EMPTY_SLEEP_SECONDS` | 60 / 600 | 定时 job 轮询间隔 / 空结果休眠秒数 |
| `PDF_CONVERTER` | `auto` | PDF 转换后端：`auto`=先试 MS Office 失败回退 LibreOffice；`msoffice`=仅 COM；`libreoffice`=仅 LibreOffice |
| `SOFFICE_PATH` | `soffice` | LibreOffice 可执行文件路径（已在 PATH 填 `soffice`，否则填绝对路径） |

> PDF 转换后端：本地装有 MS Office 用默认 `auto` 即可；服务器只有 LibreOffice 时在 `.env.prod` 设 `PDF_CONVERTER=libreoffice`、`SOFFICE_PATH=soffice`。

### 多环境配置

通过 `APP_ENV` 环境变量选择配置文件，区分开发 / 生产等环境：

| `APP_ENV` | 读取文件 |
| --- | --- |
| 未设置 | `.env` |
| `dev` | `.env.dev` |
| `prod` | `.env.prod` |

用法：把 `.env.example` 复制为 `.env.dev` / `.env.prod` 并分别填写对应环境配置，启动时设置 `APP_ENV`：

```powershell
# PowerShell
$env:APP_ENV="prod"; python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```

```bash
# bash
APP_ENV=prod python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```

> `.env.dev` / `.env.prod` 含真实密钥，已被 `.gitignore` 忽略，勿提交。

## API 一览

完整参数与示例见 [docs/02-API接口.md](docs/02-API接口.md)。

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/` | 服务信息 |
| GET | `/api/health` | 健康检查 + 双库连接状态 |
| POST | `/api/template/render` | 按企业/订单渲染单模板，可选转 PDF、上传 COS |
| GET | `/api/template/download/{filename}` | 下载生成文件 |
| GET | `/api/template/list` | 列出可用模板（含国家解析） |
| GET | `/api/render/records` | 分页查询文件替换结果记录 |
| GET | `/api/enterprise/{code}/info` | 企业详情（公司/法人/附件） |
| POST | `/api/enterprise/{code}` | 按国家生成企业全部文档（替换→合并→上传→回写） |
| GET | `/api/countries` | 国家代码映射 |
| POST | `/api/convert/{target}` | 文件格式转换（pdf/docx/doc） |

## 数据库表

| 库 | 表 | 说明 |
| --- | --- | --- |
| 源库（vat_db，只读） | `VATBusinessRecord` | VAT 业务登记记录（含 `Country`、`Code`） |
| 源库 | `VATRegInfo` | VAT 注册信息（含 `PushTaxBureauStatus` 回写列） |
| 源库 | `Base_Customer_Company` | 企业信息（公司中/英文名、地址、邮编、国家、法人等） |
| 源库 | `Base_Sales_Platform_Shop` | 店铺信息（含 `SellerID`） |
| 源库 | `Base_AnnexesFile` | 附件文件（营业执照 / 身份证正反面，含生成文件回写） |
| 源库 | `order_info` | 订单表（订单号、关联企业、`categories` JSON 设备分类） |
| 目标库（rpa，本系统所有） | `render_record` | 文件替换结果记录（企业/订单 ID、模板、输出、PDF、替换数、状态） |
| 目标库（rpa，本系统所有） | `vat_nl_file_log` | 生成日志（批次号、企业代码、环节、级别、信息、详情、时间） |
| 目标库（rpa，本系统所有） | `epr_fr_file_log` | 生成日志（法国，结构同 `vat_nl_file_log`） |

`vat_nl_file_log` 建表 DDL（目标库 `rpa`，手动创建）：

```sql
CREATE TABLE vat_nl_file_log (
    id              BIGINT IDENTITY(1,1) PRIMARY KEY,
    batch_id        VARCHAR(36)  NULL,
    code            NVARCHAR(64)  NULL,
    vat_reg_info_id NVARCHAR(64)  NULL,
    enterprise_name NVARCHAR(255) NULL,
    country         NVARCHAR(16)  NULL,
    step            NVARCHAR(64)  NOT NULL,
    level           NVARCHAR(16)  NOT NULL,
    message         NVARCHAR(1000) NULL,
    detail          NVARCHAR(MAX) NULL,
    created_at      DATETIME      NOT NULL DEFAULT GETDATE(),
    updated_at      DATETIME      NULL
);
-- 唯一约束：同一企业同一环节只保留一行（配合 upsert，按 code + step）
ALTER TABLE vat_nl_file_log ADD CONSTRAINT UQ_vat_nl_file_log_code_step UNIQUE (code, step);
```

`epr_fr_file_log` 建表 DDL（目标库 `rpa`，手动创建，结构同 `vat_nl_file_log`）：

```sql
CREATE TABLE epr_fr_file_log (
    id              BIGINT IDENTITY(1,1) PRIMARY KEY,
    batch_id        VARCHAR(36)  NULL,
    code            NVARCHAR(64)  NULL,
    vat_reg_info_id NVARCHAR(64)  NULL,
    enterprise_name NVARCHAR(255) NULL,
    country         NVARCHAR(16)  NULL,
    step            NVARCHAR(64)  NOT NULL,
    level           NVARCHAR(16)  NOT NULL,
    message         NVARCHAR(1000) NULL,
    detail          NVARCHAR(MAX) NULL,
    created_at      DATETIME      NOT NULL DEFAULT GETDATE(),
    updated_at      DATETIME      NULL
);
-- 唯一约束：同一企业同一环节只保留一行（配合 upsert，按 code + step）
ALTER TABLE epr_fr_file_log ADD CONSTRAINT UQ_epr_fr_file_log_code_step UNIQUE (code, step);
```

初始化本地兜底库并灌入测试数据：

```powershell
cd "E:\personal info\workspace\project-002-后端服务框架\code"
python -m app.init_db
```

## 运行测试

```powershell
cd "E:\personal info\workspace\project-002-后端服务框架\code"
python -m pytest -q
```

## 安全提示

`code/.env` 内含 SQL Server 密码、腾讯云 OCR / COS 密钥等真实凭据，**切勿提交到版本库或公开**（已在 `.gitignore` 中忽略）。

## 后续规划

- SaaS 回写（`render.py` / `ocr_enterprise.py` 中 TODO 待补）
- 更多国家模板与占位符映射扩展（`core/countries.py` 目前仅 AT/NL/FR/GB/DE）
- 鉴权（JWT / API Key）
- Redis 缓存
- Alembic 数据库迁移
