# 法国 LEKO/CITEO 统一文件生成接口文档

> **对接契约来源**：本文档描述本项目（`fr_epr_reg`）实现侧的接口契约。
> 完整对接契约（字段总表、PushType 枚举、新系统侧流程）以新系统仓库文档为准：
> `E:\ou\meiou-app\app_withdrawn\docs\UNIFIED_API_DESIGN.md` §4.4.21（LEKO）/ §4.4.7（CITEO）。
> 测试样例：`E:\ou\meiou-app\app_withdrawn\docs\法国LEKO注册文件.json`、`法国CITEO注册文件.json`（与文档示例逐字段一致；本项目不再保留副本）。
>
> **同族接口（LEKO 完整链路）**：生成文件（本文）→ [合并文件](french-merge-xlsx-api.md)（`FR_EPR_REGISTER_LEKO_FILE_MERGE`，
> 生成结果 `data.files[].url` 原样作为入参）→ [注册提交](french-registration-mail-api.md)（`FR_EPR_REGISTER_LEKO`，
> 发注册邮件给 Léko）→ 下号（UIN，RPA 流程，本服务不覆盖）。三个接口共享同一组守卫与每 IP 限流配额。

## 接口地址

**POST** `/fr_epr_reg/api/epr/fr/file-generation`

服务器地址：`http://automation.usaeu.com:8888/`

完整地址：`http://automation.usaeu.com:8888/fr_epr_reg/api/epr/fr/file-generation`

## 功能说明

供新系统（SaaS）中转同步调用：接收统一信封 `{PushType, Country, Data, bizParam}`，按 `PushType`
精确分派生成法国注册文件，上传腾讯云 COS 后同步返回 `data.files` 文件数组（`url` 为 OSS 相对路径）。

**关键差异**：本接口对源库（`sqlsrv_source`）**零读写**——不查 `EPRRegInfo`、不回写
`PushTaxBureauStatus`、不读写附件表，公司数据全部取自请求体 `Data`。这与 `epr:process`
轮询流程（Source_Flow）完全不同；Source_Flow 行为保持不变。

| PushType | 生成文件 | 返回槽位 |
|---|---|---|
| `FR_EPR_REGISTER_LEKO_FILE` | LEKO 注册 XLSX + POA PDF | `files[0]` = XLSX（`EPR业务申请表`）、`files[1]` = POA PDF（`授权书`） |
| `FR_EPR_REGISTER_CITEO_FILE` | CITEO POA PDF | `files[0]` = POA PDF（`授权书`） |

LEKO 且公司为法国时，服务端额外调用 INSEE SIRENE 补全 XLSX 字段（见下）。

## 访问控制

本接口**不受** `internal.network`（172.16.x.x）限制——新系统中转从其自有主机调用，改用三层守卫：

| 中间件 | 作用 | 失败响应 |
|---|---|---|
| `fr.api.transport` | 仅接受 POST + `Content-Type: application/json`；请求体上限 `FR_FILE_API_MAX_BODY_BYTES`（默认 1 MB） | 405 / 400 |
| `fr.api.bearer` | 可选 Bearer 鉴权，由 `FR_FILE_API_AUTH_ENABLED` 控制（默认 `false`）；令牌为 `FR_FILE_API_AUTH_TOKEN` | 401 |
| `fr.api.rate` | 按客户端 IP 限流，`FR_FILE_API_RATE_LIMIT_PER_MINUTE`（默认 60） | 429 |

## 请求

### Content-Type

`application/json`

### 请求参数（统一信封）

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| PushType | string | 是 | `FR_EPR_REGISTER_LEKO_FILE` 或 `FR_EPR_REGISTER_CITEO_FILE`；白名单精确分派，禁止动态类名/方法调用 |
| Country | string | 是 | 业务国，**必须恰为 `FR`**（validator 取 `strtoupper(trim(...))` 后全等判断，非前缀匹配）；公司国家见 `Data.Country` |
| Data | object | 是 | 业务字段，见下方字段表 |
| bizParam | object | 是 | 透传参数；成功与失败响应均**原样回传**。LEKO 必填子字段 `BusinessSerialNumber`（CITEO 选填）；该值进入 OSS 唯一段：不含反斜杠/控制字符、不含 `.`/`..`/空路径段（含 `%2e%2e` 编码变体）、≤64 字符 |

### Data 字段表

**LEKO（`FR_EPR_REGISTER_LEKO_FILE`）** — 固定必填 12 项：

`NameEng`、`RegAddressEng`、`CompanyAddressLine1En`、`CompanyAddressPostcode`、`CityEngName`、
`Country`、`RegNumber`、`LegalPersonNamePinyin`、`LegalPersonSurnamePinyin`、`LegalPersonEmail`、
`LegalPersonPhone`、`LegalPersonGender`

条件必填：

| 条件 | 字段 |
|------|------|
| `Country` = `CN` | `AreaName` |
| `Country` = `FR` | `RegisteredCapital`、`RegisteredCapitalCurrency` |
| 欧盟国家 | `VATNumber` |

长度上限：`NameEng` ≤ 50、`CompanyAddressLine1En` ≤ 100 字符。

> **`Data.Country` 是公司注册国家二字码**，与信封顶层 `Country`（业务国，恒为 `FR`）是不同层级的两个字段。

**CITEO（`FR_EPR_REGISTER_CITEO_FILE`）** — 固定必填 6 项：

`NameEng`、`Country`、`CityEngName`、`RegAddressEng`、`LegalPersonNamePinyin`、
`LegalPersonSurnamePinyin`

条件必填：

| 条件 | 字段 |
|------|------|
| 欧盟且非法国 | `VATNumber`（作为 BusinessLicenseNo 使用） |
| 其他国家（含法国） | `RegNumber` |

**法人英文名**由 `LegalPersonNamePinyin`（名）+ `LegalPersonSurnamePinyin`（姓）两个**必填**字段组成，
服务端按「名 姓」拼接（如 `MING` + `LI` → `MING LI`）供 POA 模板与自动签名使用。
旧字段 `LegalPersonFullNamePinYin` **已废弃、不再接受**；即使传入也不采纳，一律由两个拆分字段重算。

### 请求示例

LEKO：

```json
{
    "PushType": "FR_EPR_REGISTER_LEKO_FILE",
    "Country": "FR",
    "bizParam": {
        "BusinessSerialNumber": "FREPR2026000001"
    },
    "Data": {
        "NameEng": "EXAMPLE COMPANY SAS",
        "RegAddressEng": "10 Rue Exemple, 75001 Paris, France",
        "CompanyAddressLine1En": "10 Rue Exemple",
        "CompanyAddressPostcode": "75001",
        "CityEngName": "Paris",
        "Country": "FR",
        "AreaName": "Ile-de-France",
        "RegNumber": "123 456 789 R.C.S. Paris",
        "RegisteredCapital": "10000.00",
        "RegisteredCapitalCurrency": "EUR",
        "LegalPersonNamePinyin": "MING",
        "LegalPersonSurnamePinyin": "LI",
        "LegalPersonEmail": "legal@example-company.fr",
        "LegalPersonPhone": "8613800000000",
        "LegalPersonGender": "1",
        "VATNumber": "FR00123456789"
    }
}
```

CITEO：

```json
{
    "PushType": "FR_EPR_REGISTER_CITEO_FILE",
    "Country": "FR",
    "bizParam": {},
    "Data": {
        "NameEng": "EXAMPLE COMPANY SAS",
        "Country": "FR",
        "CityEngName": "Paris",
        "RegAddressEng": "10 Rue Exemple, 75001 Paris, France",
        "LegalPersonNamePinyin": "MING",
        "LegalPersonSurnamePinyin": "LI",
        "RegNumber": "123456789"
    }
}
```

## 响应

### 成功响应（200）

同步模式，`ProcessMode` 固定为 `sync`，`msg` 固定为 `success`：

```json
{
    "code": 200,
    "msg": "success",
    "ProcessMode": "sync",
    "data": {
        "files": [
            {
                "url": "common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000001/Leko_Template list of companies_v3.11 2026.xlsx",
                "name": "Leko_Template list of companies_v3.11 2026.xlsx",
                "type": "EPR业务申请表"
            },
            {
                "url": "common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000001/POA-EXAMPLE COMPANY SAS.pdf",
                "name": "POA-EXAMPLE COMPANY SAS.pdf",
                "type": "授权书"
            }
        ]
    },
    "bizParam": {
        "BusinessSerialNumber": "FREPR2026000001"
    }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| code | int | 200 表示成功 |
| msg | string | 固定 `success` |
| ProcessMode | string | 固定 `sync` |
| data.files | array | 文件数组，**无文件时为空数组 `[]`（不是 `null`）** |
| data.files[].url | string | **OSS 相对路径，不含域名**（域名由 SaaS 侧拼接，与 `UNIFIED_API_DESIGN` §5.1 一致；出现绝对 URL 会被判为内部错误并返回 400） |
| data.files[].name | string | 上传后的完整文件名 |
| data.files[].type | string | 文件类型枚举（§5.2 固定取值）。LEKO XLSX = `EPR业务申请表`；POA PDF = `授权书` |
| data.files[].date | string | 可选，**该键可能不存在**（本接口不返回） |
| bizParam | object | 请求 `bizParam` 原样回传 |

> **文件顺序**沿用 `UNIFIED_API_DESIGN` §5.4 的槽序并跳过空项：
> LEKO = `[XLSX, POA]`（原 file1、file2）；CITEO = `[POA]`（原 file1）。
>
> **变更说明（相对早期版本）**：早期版本返回 `data: {file1..file10}` 且槽值为**完整 OSS URL**。
> 现已按 §5.1 的统一形状改为 `data.files[]` + **相对路径**，与 `es_haiya_epr`、DE/SE/BE/AT/FR-WEEE
> 等流程保持一致 —— 调用方需自行拼接域名。

### 校验失败（400）

```json
{
    "code": 400,
    "msg": "Validation failed: Data.NameEng is required",
    "data": null
}
```

所有字段错误聚合在**一次** 400 响应中（`msg` 为分号分隔的错误列表）。

### 业务失败（400）

`msg` 为稳定错误类别（不回显路径、堆栈、凭证或内部端点）：

```json
{
    "code": 400,
    "msg": "INSEE_QUERY_FAILED",
    "data": null
}
```

| 类别 | 触发场景 |
|------|----------|
| `INSEE_QUERY_FAILED` | LEKO 法国公司 INSEE 查询最终失败（无文件生成，**不回退源库**） |
| `TEMPLATE_MISSING` | 模板文件缺失（`storage/Leko_Template.xlsx` / `Leko_Template.docx` / `Citeo_Template.docx`） |
| `DOCX_GENERATION_FAILED` | DOCX 占位符替换/生成失败 |
| `XLSX_GENERATION_FAILED` | XLSX 生成失败 |
| `PDF_CONVERSION_FAILED` | DOCX → PDF 转换失败 |
| `OSS_UPLOAD_FAILED` | 腾讯云 COS 上传失败 |
| `OSS_KEY_INVALID` | OSS 唯一段不安全（兜底防线；正常请求先被校验层以聚合校验错误 400 拦截） |

### 传输 / 鉴权 / 限流 / 未分类失败

| HTTP | msg | 场景 |
|------|-----|------|
| 405 | （传输守卫固定文案） | 非 POST 方法 |
| 400 | （传输守卫固定文案） | Content-Type 非 `application/json`，或请求体超限 |
| 401 | （鉴权守卫固定文案） | `FR_FILE_API_AUTH_ENABLED=true` 且 Bearer 缺失/错误 |
| 429 | （限流守卫固定文案） | 超出每 IP 每分钟配额 |
| 500 | `Internal error` | 未分类异常；不回显内部消息，仅记录异常类名 |

## 生成细节

### 字段处理（`FrenchFileGenerationMapper`）

`Data` 值统一 `trim`；`Country` 转大写；`HK` 空 `AreaName` → `香港`；非 CN/HK 空 `AreaName`
→ 国家二字码大写；数组值以逗号连接。`InseeLegalForm`、`InseeNafCode`、`InseeSiret`、
`CompanyAddressLine2En` 不属于本契约，一律丢弃。

Mapper 在 **API 边界**把新系统契约字段名归一化为内部名，使下游生成器、DOCX 模板占位符与
Source_Flow（轮询老流程）**全部零改动**：

| 契约字段（入） | 内部字段（出） |
|---------------|---------------|
| `Country` | `CountryTwoCode` |
| `LegalPersonNamePinyin` + `LegalPersonSurnamePinyin` | `LegalPersonFullNamePinYin`（按「名 姓」拼接：`trim(名.' '.姓)`） |

### LEKO 流程（`FrenchFileGenerationService` → `LekoInseeResolver` → `LekoApiFileGenerator`）

仅**法国公司**调用 INSEE：SIREN 取 `RegNumber` 前 9 位数字；恰为 9 位时调用 `InseeApiClient`
（SIRENE 3.11，最多尝试 5 次、间隔 6 秒，映射与默认值同 `EprRegProcessor::fetchInseeData`）；
最终失败返回 `INSEE_QUERY_FAILED` 400 且不生成文件。非法国或 SIREN 不合法时 INSEE 数据为空
（XLSX 对应列取默认 `Autre` / `Other` / `''`）。

模板：`storage/Leko_Template.xlsx` → XLSX（数据起始第 14 行）；`storage/Leko_Template.docx` → POA
（内联签名 PNG）→ PDF（`firstPageOnly=true`，仅首页）→ COS
`{OSS_API_PREFIX}{当前年}/{OSS_API_MODULE_DIR}/leko/{BusinessSerialNumber}/`（**API 专用桶**；
业务线目录 `leko/` 与 CITEO 的 `citeo/` 区分）。
唯一段（`BusinessSerialNumber`）须为安全路径段：含反斜杠、控制字符或 `.`/`..`/空路径段
（含 `%2e%2e` 等编码变体）时 400 拒绝（与合并/注册接口的读侧
key 校验同标准，保证产物可被下游消费）。

### CITEO 流程（`FrenchFileGenerationService` → `CiteoPoaGenerator`）

不调用 INSEE。模板 `storage/Citeo_Template.docx` → PDF（`firstPageOnly=false`，全部页面，签名在第 2 页）
→ COS **API 专用桶** `{OSS_API_PREFIX}{当前年}/{OSS_API_MODULE_DIR}/citeo/{唯一段}/`。CITEO 的
`bizParam` 无固定必填子字段，`BusinessSerialNumber` 可能缺失，此时唯一段回退
`{净化后 NameEng}-{uniqid}`（等价原 `citeo_poa` 子目录的防覆盖作用）。

### OSS 桶分流（API 流程 vs source 流程）

对齐 `es_haiya_epr` 的做法：**API 流程与 source 流程写入不同的 COS 桶**。

| 流程 | 桶 | 配置 | Key 布局 |
|------|-----|------|----------|
| **API**（本接口） | `usaeu-1259285998` | `OSS_API_BUCKET` | `{OSS_API_PREFIX}{年}/{OSS_API_MODULE_DIR}/{业务线}/{唯一段}/{文件名}`（业务线 = `leko` / `citeo`；合并产物 `.../merged/merged_*.xlsx`） |
| source（`epr:process` 轮询、邮件监控、旧 `/api/epr/*`） | `vat-1259285998` | `OSS_BUCKET` | 不变（`epr_factory/fr/reg/{BSN}/`、`citeo_poa/{NameEng}/{uniqid}/`） |

- `OSS_API_PREFIX` 默认 `common-prod/generatefile/`；测试环境填 `common-test/generatefile/`。
- `OSS_API_MODULE_DIR` 默认 `fr_epr_reg`（等价 es_haiya_epr 的 `es_epr_haiya`）；**缺失时 fail-closed 报错**。
- `usaeu` 为 SaaS 共享**私有桶**：API 流程上传的对象保持私有（disk `visibility=private`，
  不发送 `ACL:public-read`；对齐 es_haiya_epr 私有对象 + 签名取件），消费方按需签名访问。
- 实现：`FrenchApiOssPath::directory()` 构造 key，`OssUploader::upload(..., $disk)` 选择磁盘
  （`OssUploader::API_DISK` / `SOURCE_DISK`）；旧 `generate-citeo-poa`（Id 版）不传唯一段，
  仍走 source 桶与旧目录。

文件名与 OSS 路径中的公司名统一经 `FileNameSanitizer::sanitize()` 处理（剔除
`\ / : * ? " ' < > | # %`，保留 `&`）；`OssUploader` 返回的 URL 路径已解码 `%XX`，调用方**不得**
再次 `urldecode`（`+` 会被还原为空格）。

### 日志（`api_french` 通道）

`SensitiveDataRedactor` 对 Bearer、INSEE key、`RegNumber`、SIREN、SIRET、VAT、电话、邮箱、地址、
URL 做脱敏；共享组件（INSEE 请求、DOCX 替换）的日志同样脱敏。

## cURL 调用示例

```bash
# 示例文件统一存放于新系统仓库目录：
# E:\ou\meiou-app\app_withdrawn\docs\

curl -X POST \
  http://automation.usaeu.com:8888/fr_epr_reg/api/epr/fr/file-generation \
  -H 'Content-Type: application/json' \
  -d @"E:/ou/meiou-app/app_withdrawn/docs/法国LEKO注册文件.json"

curl -X POST \
  http://automation.usaeu.com:8888/fr_epr_reg/api/epr/fr/file-generation \
  -H 'Content-Type: application/json' \
  -d @"E:/ou/meiou-app/app_withdrawn/docs/法国CITEO注册文件.json"
```

启用 Bearer 时追加请求头 `-H 'Authorization: Bearer <FR_FILE_API_AUTH_TOKEN>'`。
