# 架构设计

## 系统概览

意大利 EPR 数据处理器是一个持久化运行的 Laravel Command，每 8 秒轮询 SQL Server 源数据库，自动处理待处理的 EPR 业务记录。

## 分层架构

```
┌──────────────────────────────────────────────────┐
│              Console Command Layer                │
│            EprProcessItalyCommand                 │
└──────────────────────┬───────────────────────────┘
                       │
┌──────────────────────┴───────────────────────────┐
│                  Service Layer                   │
│  EprProcessorService ─ 业务编排（核心调度）       │
│  DataTransformService ─ 电话/姓名/日期转换        │
│  TranslationService  ─ DeepL 翻译（带缓存+重试）  │
│  DocumentGeneratorService ─ Word 文档生成         │
│  FileUploadService   ─ 腾讯云 COS 上传            │
│  WeChatNotificationService ─ 微信通知             │
└──────────────────────┬───────────────────────────┘
                       │
┌──────────────────────┴───────────────────────────┐
│                Repository Layer                  │
│  SourceDbRepository ─ 读取待处理记录 + 更新状态   │
│  TargetDbRepository ─ 保存处理结果                │
└──────────────────────┬───────────────────────────┘
                       ┌──────────────┐
                       │ 外部服务     │
                       │ SQL Server   │
                       │ DeepL API    │
                       │ Tencent COS  │
                       └──────────────┘
```

## 组件职责

### EprProcessorService（核心编排）
- 协调各服务按顺序执行处理流程
- 异常捕获与失败状态更新
- 处理耗时统计

### DataTransformService（数据转换）

| 方法 | 输入 | 输出 |
|------|------|------|
| `formatPhoneNumber()` | `33-752913685` | `0033752913685` |
| `splitLegalName()` | `Shaohua SHI` | `['Shaohua', 'SHI']` |
| `splitBirthDate()` | `1982-02-28` | `[28, 2, 1982]` |
| `formatDateForWord()` | `1982-02-28` | `28/02/1982` |

**电话号码格式化规则：**
- 含 `+` 或空格/`-` 分隔符 → 识别为国际号码，提取已有区号
- `00` 开头 → 直接返回
- `86` 开头且长度 > 11 → 识别为中国号码
- 其他纯数字 → 默认加 `86` 区号
- 最终格式：`00` + 区号 + 号码（去掉号码前导 0）

### WeChatNotificationService（企业微信通知）
- 必填字段校验失败时发送企业微信通知（含 BusinessSerialNumber、公司名、错误信息、手机号）
- 不影响主流程：通知发送失败不阻断处理

### TranslationService（翻译服务）
- DeepL API 翻译国家名为意大利语
- 文件缓存：`storage/app/translations/country_names_it.json`
- 重试策略：3 次（间隔 2s, 5s, 10s），全部失败则停止程序

### DocumentGeneratorService（文档生成）
- 使用 WordTemplateProcessor 替换 `{{占位符}}`
- 模板文件：`EPR_WORD_TEMPLATE_PATH`（默认 `Certificate_of_Use_Template.docx`）
- 输出到 `storage/app/temp/`，上传后由 FileUploadService 清理

### FileUploadService（文件上传）
- 腾讯云 COS SDK
- 源流程对象键：`epr/italy/desc/{year}/{month}/{code}_{timestamp}.docx`
- API 流程对象键（`uploadApiFile()`）：`{EPR_API_OSS_PREFIX}{年}/epr_italia/{业务流水号}/{文件名}`，
  文件名不含唯一因子，靠流水号子目录保证唯一（与 RPA 侧 `build_api_cos_key` 拼法必须一致）
- 上传成功后删除本地临时文件

### Repository 层
- **SourceDbRepository**：`source` 连接，查询条件 `Country='IT' AND PushTaxBureauStatus=5`
- **TargetDbRepository**：`target` 连接，写入 `italia_epr_file` 表
- 两者都有连接重试机制：10 次，间隔 5 秒

## 数据流

```
启动 Command
  ↓
循环：查询 source DB（TOP 1）
  ├─ 无记录 → sleep(8) → 继续
  └─ 有记录 →
      ↓
    校验必填字段（除 F_Mobile 和 LegalSignedFile 外不可为空）
      ├─ 失败 → 更新 source 状态 = -1 + 发送企业微信通知 → 继续循环
      └─ 通过 →
          ↓
        数据转换（电话/姓名/日期）
      ↓
    翻译国家名（DeepL + 缓存）
      ↓
    生成 Word 文档
      ↓
    上传 COS
      ↓
    写入 target DB（事务，desc_status=2）
      ↓
    （source 状态 = 6 由 RPA 授权书阶段完成后回写，PHP 侧不更新）
      ↓
    sleep(8) → 继续循环

异常时：
  更新 source 状态 = -1 + 错误信息（截断 100 字符）
  记录错误日志
  继续循环
```

## API 接入模式（data_source='api'）

对齐 es_haiya 参考设计（`app_withdrawn/docs/UNIFIED_API_DESIGN.md`）：受理同步校验快速返回，处理结果（成功/失败）通过异步回调通知。接口契约详见 [api.md](api.md)。

### 新增分层组件

```
┌──────────────────────────────────────────────────┐
│              HTTP 传输层（php artisan serve）     │
│  EprApiController / EprApiAuthGuard / EprApiResponse │
└──────────────────────┬───────────────────────────┘
                       │
┌──────────────────────┴───────────────────────────┐
│                  Service Layer                   │
│  EprApiIntakeService  ─ 受理校验/防重/落库/同步生成│
│  EprProcessorService::processApiRecord ─ 复用管线│
│  EprProcessApiCommand ─ 兜底轮询处理器（历史遗留/陈旧行回收）│
│  AsyncResultNotifier ─ 失败回调（仅兜底路径，不在受理链路上）│
└──────────────────────┬───────────────────────────┘
                       │
┌──────────────────────┴───────────────────────────┐
│                Repository Layer                  │
│  TargetDbRepository（insertApiRecord / claim /   │
│    updateApiRecordSuccess / updateApiRecordFailed）│
└──────────────────────────────────────────────────┘
```

### API 数据流

```
POST /api/epr/italy（Bearer 鉴权 + 限流 60次/分）
  → 校验信封（PushType=IT_EPR_REGISTER_FILE, Country=IT）
  → 校验 Data 必填字段（聚合 400）
  → VAT 解析（DataTransformService 纯函数，与源流程共用规则）
  → 防重（code / BusinessId 非终态）
  → INSERT italia_epr_file：data_source='api', desc_status=1（处理中）, callback_url=env
                            （callback_url 供 RPA 读取，受理路径自身不发回调）
  → 受理内同步生成使用说明（同一管线，复用 processApiRecord）：
      校验 → 转换 → DeepL 翻译（用 API 提供的 CountryEn，不查源库）
      → Word 生成 → COS 上传 → UPDATE 行（desc_status=2 + desc_file_url）
    失败（无重试）→ desc_status=3 + 企业微信 → 信封 code=500 + 分级原因（HTTP 恒 200，不发回调）
  → 200 信封（ProcessMode=async；两份文件仍走 RPA 的统一回调）

epr:process-api（独立 NSSM 服务，仅兜底）
  → 回收历史遗留 desc_status=0 行与陈旧 desc_status=1 行（10 分钟陈旧阈值，崩溃恢复）
  → 行级重试上限 EPR_API_DESC_MAX_RETRIES（默认 3），达上限 → desc_status=3 + 失败回调 + 企业微信

RPA 阶段（python/rpa_get.py / rpa_save.py 按 data_source 分流）
  → API 行：不查 vat_db 签名文件（直接取接口传值）、不写 Base_AnnexesFile、不更新 EPRRegInfo
  → 授权书上传 {EPR_API_OSS_PREFIX}{年}/epr_italia/{流水号}/ → auth_status=2
  → 完成 → 成功回调（data.files = 说明书[EPR注册文件] + 授权书，url 为 OSS 相对路径）
  → 失败（字段缺失 / 认领耗尽 / 上传或回写异常）→ auth_status=3 → 失败回调（data=null，原因放 msg）
```

### API 行状态机

```
desc_status: 1处理中（受理落库即置）→ 2成功（等待RPA，受理内同步生成完成） / 3失败（终态）
auth_status: 0待处理 → 1处理中(RPA) → 2成功（终态，成功回调） / 3失败（终态，失败回调）
回调投递：notify_status = skipped/success/failed（±notify_attempts / notify_time；列未迁移时跳过落库）
```

**终态后重投复用原行**：唯一索引 `UX_italia_epr_file_api_code` 只按 `code` 过滤、**不含 `desc_status`**，故 `desc_status=3` 的行仍占用其流水号；重投命中该索引时以本次 payload 刷新 `request_data`/`biz_param`/`callback_url` 并重置为 `desc_status=1`（`TargetDbRepository::buildRetryResetColumns`）。不复新 payload 的话，RPA 回调会读出行内**首次提交**的 `biz_param` 并回传给调用方。

### 与源流程的关键差异

| 维度 | 源流程（source） | API 流程（api） |
|------|------------------|-----------------|
| 数据入口 | source 库轮询（EPRRegInfo） | HTTP 受理（request_data JSON） |
| 国家名翻译 | source 库 Country 表查英文名 → DeepL | Data.CountryEn 直接进 DeepL |
| VAT 解析 | 源库联表取值 + 同一规则 | 受理时按同一规则解析 |
| 签名文件 | RPA 查 vat_db 附件表解析 | 原样透传（URL / JSON 数组） |
| 说明书生成时机 | 轮询到记录后处理 | 受理请求内同步生成（epr:process-api 仅兜底历史/陈旧行） |
| OSS 路径 | 桶 `TENCENT_COS_BUCKET`，键 `epr/italy/desc/{年}/{月}/`；库内存**完整 URL** | 桶 `EPR_API_COS_BUCKET`（留空回落 source 桶），键 `{EPR_API_OSS_PREFIX}{年}/epr_italia/{流水号}/`；库内存**相对路径**（与回调口径一致） |
| 失败语义 | 单条失败打 source -1 + 微信，DeepL/DB 故障退出进程 | 说明书阶段失败直接终态 desc_status=3 + 回调 + 微信（无重试）；仅 DB 重试耗尽退出进程 |
| 成功通知 | RPA 回写 EPRRegInfo=6/7 + 微信 | 异步结果回调（`data.files` 两份文件，RPA 侧发送） |
| 结果通道 | source 库状态 + 企业微信 | 异步结果回调（.env 配置，重试 3 次/间隔 5 秒） |


## 字段映射

### Source → Target 主要映射

| Source 字段 | Target 字段 | 转换 |
|-------------|-------------|------|
| `Id` | `saas_id` | 直传 |
| `NameEng` | `company_name_en` | 直传 |
| `RegAddressEng` | `company_address_en` | 直传 |
| `LegalPersonPhone` | `company_tel` | 格式化 |
| `LegalPersonFullNamePinYin` | `legal_rep_first_name` + `legal_rep_last_name` | 拆分 |
| `LegalPersonBirthDate` | `legal_rep_birth_day/month/year` | 拆分 |
| `Country` | `company_country_it` | 翻译 |
| `RegNumber`（解析后） | `vat_number` | CN/HK 取营业执照号；其他国家先取 VAT税号，为空回退营业执照号（不加国家前缀） |
| `VATNumber`（解析后） | `italy_vat_number` | 仅非 CN/HK 公司；税号前面带字母时去掉前两位（如 IT12345678901→12345678901），可为空 |
| `BusinessSerialNumber` | `code` | 直传 |
| `bu.F_Mobile` | `sales_phone` | 格式化 |

### Word 模板占位符

```
{{company_name}}        → company_name_en
{{company_address}}     → company_address_en
{{company_zip_code}}    → company_zip_code
{{company_city}}        → company_city_en
{{company_province}}    → company_province_en
{{company_country}}     → company_country_it（翻译后）
{{vat_number}}          → vat_number
{{company_tel}}         → company_tel（格式化后）
{{company_email}}       → company_email
{{legal_rep_name}}      → first_name + " " + last_name
{{legal_rep_birthplace}}→ legal_rep_birthplace
{{legal_rep_birthdate}} → DD/MM/YYYY
{{doc_date}}            → DD/MM/YYYY（当天）
```

## 状态机

```
Source 记录状态：
  5（待处理）→ 6（处理成功）
             → -1（处理失败，附错误信息）

Target 记录：
  desc_status = 2（处理成功）
```
