# 意大利 PROFIS 注册数据提取导入任务流程文档

> 适用范围：`vat_api_it_client/job/` 下两个定时任务脚本
> - `aa7_profis_it_register.php` —— 意大利 AA7 税表注册数据（PushType=`109`）
> - `anr3_profis_it_register.php` —— 意大利 ANR3 税表注册数据（PushType=`110`，欧盟公司注册意大利 VAT）
>
> 共同职责：从 SaaS 源库（SQL Server `vat_db`）提取意大利 VAT 注册数据 → 校验/转换/（OCR 提取）→ 写入本地业务库（SQL Server `rpa`）→ 回写 SaaS 源库状态，供下游 PROFIS 系统消费。

---

## 1. 任务定位

| 维度 | aa7_profis_it_register.php | anr3_profis_it_register.php |
| --- | --- | --- |
| 业务含义 | 意大利国内公司 VAT 注册（AA7 表） | 欧盟公司注册意大利 VAT（ANR3 表） |
| 取数条件 | `vr.PushType = '109'` | `vr.PushType = '110'` |
| 每次处理量 | `TOP 130` 条 | `TOP 1` 条（一次只处理 1 条） |
| 目标表 | `it_profis_register`（rpa 库） | `anr3_it_profis_register`（rpa 库） |
| 是否需要 PDF/OCR | 否（直接取 SaaS 字段） | 是（下载税号证书 PDF → 提取文本 → 解析字段） |
| SaaS 反写 | 仅更新 `VATRegInfo` 推送状态 | 更新 `VATRegInfo` 状态 + `Base_Customer_Company` 法人信息 + `VATBusinessRecord` 本国税号 |

---

## 2. 运行环境与启动方式

### 2.1 依赖组件

```php
require_once __DIR__ . '/../vendor/autoload.php';          // Composer 自动加载（Fpdi / PhpWord / Dompdf 等）
require_once __DIR__ . '/../db/sql_server_db.php';          // SqlServerDb 连接类
require_once __DIR__ . '/../config/database_config.php';    // 数据库配置
require_once __DIR__ . '/../config/api_config.php';         // 站点路径 / OCR Key 等常量
require_once __DIR__ . '/../public/function.php';           // logMessage / downloadFile 等工具函数
// —— 以下仅 anr3 引入 ——
require_once __DIR__ . '/../public/ossfile.php';            // HttpCosUploader（OSS 上传，预留）
require_once __DIR__ . '/../public/PaddleOcrClient.php';    // 百度千帆 PaddleOCR 客户端（PDF 识别降级方案）
```

ANR3 额外环境要求：

```php
set_time_limit(0);          // 取消 PHP 脚本执行时间限制
ini_set('max_execution_time', 0);
ini_set('memory_limit', '512M');
```

以及本机需安装 `python` + `pdfplumber` 库（用于 PDF 文本提取，脚本位于 `public/ANR3.py`）。

### 2.2 启动方式

两个脚本均通过 **`basename(__FILE__) == basename($_SERVER['PHP_SELF'])`** 判断是否为直接执行（CLI 运行 `php job/xxx.php`）：

1. 创建 `logs/` 目录（不存在时）
2. 创建一次性标记日志 `logs/6.log`（aa7）/ `logs/9.log`（anr3），写入创建时间
3. 实例化任务类并调用 `run()`
4. 打印「等待 1 分钟后再次执行」提示，随后**删除**标记日志

> 代码中 `sleep(60)` 已注释、外层 `while(true)` 已注释，即当前每次运行只执行一轮；如需常驻循环可自行开启。

### 2.3 日志机制

- `logMessage($message, $context, $level)`：静态缓冲 + 批量写 `logs/log_YYYY-MM-DD.log`（缓冲区 >100 条或距上次写入 >5 秒时落盘）
- 两个脚本执行中大量使用 `var_dump()` 输出调试信息（生产环境建议清除）

---

## 3. 数据库连接

两个脚本的数据库连接逻辑完全一致（`initializeDatabaseConnections` / `closeDatabaseConnections`）：

| 成员变量 | 配置来源 | 实际库 | 用途 |
| --- | --- | --- | --- |
| `$sqlServerDb` | `getSqlServerConfig()` | `vat_db`（SaaS 源库） | 读取业务数据、回写推送状态 |
| `$localsqlServerDb` | `getlocalSqlserverConfig()` | `rpa`（本地业务库） | 写入 PROFIS 注册待处理表 |

> ⚠️ 注意：代码中变量名/注释写作 MySQL，**实际两个连接均为腾讯云 SQL Server**（`gz-mssql-4jvvbair.sql.tencentcdb.com,25944`，用户 `rpa_sa`），仅 database 不同。`closeDatabaseConnections()` 将连接置 null 由 PDO 析构自动关闭。

---

## 4. aa7_profis_it_register.php 流程详解

### 4.1 总体流程

```
run()
 ├─ 初始化连接（vat_db + rpa）
 ├─ processGermanVatData()
 │   ├─ SQL 取数（TOP 130，PushType=109，PushTaxBureauStatus=1）
 │   ├─ 无数据 → 直接结束
 │   └─ 逐条循环（sleep(1)）：
 │       ├─ 香港特殊处理（省份/城市补 HONGKONG）
 │       ├─ packFieldFormats() 字段校验
 │       │    ├─ 通过 → processSingleRecord() 写 rpa 库
 │       │    │        └─ updateVATRegInfo(状态=2 成功)
 │       │    └─ 不通过 → updatePackRecordmsg()（rpa 库记失败）
 │       │             └─ updateVATRegInfo(状态=-1 + 错误信息)
 │       └─ 单条异常 → logMessage 记录后继续下一条
 └─ finally 关闭连接
```

### 4.2 取数 SQL

```sql
SELECT TOP 130
    vr.Id  AS VATRegInfoId,     -- 注册信息表主键（回写状态用）
    br.CompanyID,               -- 客户公司 ID
    br.ID   AS businessID,      -- 业务单 ID
    c.*,                        -- 公司全部字段
    br.Code AS tid              -- 订单流水号
FROM VATBusinessRecord br
LEFT JOIN Base_Customer_Company c ON br.CompanyID = c.ID
LEFT JOIN VATRegInfo vr ON br.ID = vr.VATBusinessRecordId
WHERE br.Country = 'IT'
  AND vr.PushTaxBureauStatus = 1      -- 待推送状态
  AND vr.PushType = '109'
```

### 4.3 逐条处理逻辑

**① 香港公司特殊处理**：`Country='香港'` 时强制 `CompanyAddressProvinceEn='HONGKONG'`、`CityEngName='HONGKONG'`（绕过 Base_Area 反查）。

**② 字段校验（packFieldFormats）**，校验规则如下：

| 字段 | 校验规则 | 错误提示 |
| --- | --- | --- |
| `NameEng` | 必填 | 公司英文名称 不能为空 |
| `RegAddressEng` | 必填 | 公司地址 不能为空 |
| `RegNumber` | 必填 | 公司营业执照号 不能为空 |
| `Country` | 必填 | 公司注册所在国 不能为空 |
| `RegNumber` | 正则 `^[a-zA-Z0-9 ]+$` | 公司营业执照号只能包含数字、英文字母和空格 |
| `CompanyAddressProvinceEn` / `RegAddressProvince` | 二选一必填；用 `RegAddressProvince` 时必须为数字（区域 ID） | 公司注册所在省份 不能为空 / 异常 |
| `CityEngName` / `RegAddressCity` | 二选一必填；用 `RegAddressCity` 时必须为数字 | 公司所在城市 不能为空 / 异常 |

**③ 校验结果处理**：

- **失败**：`$errorMsg = implode('; ', $errors)`
  - `updatePackRecordmsg($tid, $errorMsg)`：rpa 库 `it_profis_register` 置 `status=-1` + 写入 `msg`
  - `updateVATRegInfo($VATRegInfoId, $errorMsg)`：SaaS 库 `VATRegInfo` 置 `PushTaxBureauStatus=-1` + `PushTaxBureauErrorMsg`
- **成功**：`processSingleRecord()` 写库后 `updateVATRegInfo($VATRegInfoId, '', 2)`（状态 2=成功、清空错误信息）

### 4.4 processSingleRecord 单条落库

**① 幂等查重**：

```sql
SELECT id, status FROM it_profis_register WHERE tid = :tid
```

- 已存在且 `status = 2`（成功）→ 直接跳过，不再处理

**② 省份/城市名称反查（Base_Area）**：

- 省份：优先 `CompanyAddressProvinceEn`（英文名直接用）；为空则取 `RegAddressProvince`（区域 ID）→ `SELECT F_QuickQuery FROM Base_Area WHERE F_AreaId = :id` 得到拼音/简写
- 城市：优先 `CityEngName`；为空则取 `RegAddressCity`（区域 ID）→ 同上反查 `F_QuickQuery`

**③ 字段映射表**（写入 `it_profis_register`）：

| 目标字段 | 来源 | 说明 |
| --- | --- | --- |
| `tid` | `br.Code` | 订单流水号 |
| `businessID` | `br.ID` | 业务单 ID |
| `country` | `c.Country` | 国家（中文） |
| `saas_country` | `c.CountryName_en` | 国家英文名 |
| `company_name_en` | `c.NameEng`（trim） | 公司英文名 |
| `company_name_cn` | `c.NameCN` | 公司中文名 |
| `regAddressEng` | `c.RegAddressEng`（trim） | 公司地址 |
| `cityEngName` | 反查结果（trim） | 城市 |
| `provinceEng` | 反查结果（trim） | 省份 |
| `register_num` | `c.RegNumber`（trim） | 公司营业执照号 |
| `status` | 固定 `0` | 0=待处理 |
| `type` | 固定 `0` | 类型 |
| `retry_count` | 固定 `0` | 重试次数 |
| `manual_type` | 固定 `0` | 手动类型 |
| `VATRegInfoId` | `vr.Id` | 注册信息表 ID |
| `created_time` / `updated_time` | `date('Y-m-d H:i:s')` | 创建/更新时间 |

**④ 写库**：已存在 → `updatePackRecord($id, $data)`（按 id 动态 UPDATE 全字段）；不存在 → `insertPackRecord($data)`（动态 INSERT）。任一失败抛异常。

---

## 5. anr3_profis_it_register.php 流程详解

### 5.1 总体流程

```
run()
 ├─ 初始化连接（vat_db + rpa）
 ├─ processGermanVatData()
 │   ├─ SQL 取数（TOP 1，PushType=110，PushTaxBureauStatus=1，JOIN VATTaxNumber）
 │   ├─ 无数据 → 直接结束
 │   └─ 单条处理（sleep(1)）：
 │       ├─ 创建记录目录 resources/anr3/{VATRegInfoId}/
 │       ├─ packFieldFormats() 校验（仅 ProfisCode 必填）
 │       ├─ TaxCertificate 为空 → 追加错误「PDF税号证书附件为空」
 │       ├─ TaxCertificate 非空 → 查 Base_AnnexesFile 取 F_FilePath：
 │       │    ├─ 路径为空 → 追加错误
 │       │    └─ updatedFilePath() 下载 PDF 到本地
 │       │         └─ extractPdfContent_python() 提取文本
 │       │              ├─ python + pdfplumber（public/ANR3.py）
 │       │              ├─ 失败 → 降级 PaddleOcrClient（百度千帆 OCR）
 │       │              └─ parsePdfContent() 正则解析 13 个字段
 │       ├─ 有错误 → updateVATRegInfo(状态=-1 + 错误信息)
 │       └─ 无错误 → processSingleRecord() 写 rpa 库
 │            ├─ updateVATRegInfo(状态=2 成功)
 │            ├─ updateBaseCustomerCompanyInfo() 反写法人姓名/城市 → Base_Customer_Company
 │            └─ LocalCountryTaxNumber 为空 → updateVATBusinessRecordInfo() 回写本国税号
 └─ finally 关闭连接
```

### 5.2 取数 SQL（与 aa7 的差异：额外 JOIN VATTaxNumber）

```sql
SELECT TOP 1
    vr.Id AS VATRegInfoId,
    br.LegalTaxNumber,          -- 法律税号
    br.ProfisCode,              -- PROFIS 注册代码（业务标识）
    br.LocalCountryTaxNumber,   -- 本国税号（已有值则不需要回写）
    br.ID AS businessID,
    c.*,
    br.Code AS tid,
    vt.TaxCertificate,          -- 税号证书附件 ID（→ Base_AnnexesFile）
    vt.LocalTaxNumber,
    br.CompanyID
FROM VATBusinessRecord br
LEFT JOIN Base_Customer_Company c ON br.CompanyID = c.ID
LEFT JOIN VATRegInfo vr ON br.ID = vr.VATBusinessRecordId
LEFT JOIN VATTaxNumber vt ON vt.ID = br.VATTaxNumberID
WHERE br.Country = 'IT'
  AND vr.PushTaxBureauStatus = 1
  AND vr.PushType = '110'
```

### 5.3 证书附件下载（updatedFilePath）

1. 按 `InfoId = TaxCertificate` 查 `Base_AnnexesFile` 得到 `F_FilePath`
2. **路径前缀替换**（内网盘路径 → 公网下载地址）：

| 常量（api_config.php） | 生产 | 测试 |
| --- | --- | --- |
| `MAIN_SITE_PATH`（被替换） | `G:/fileAnnexes` | `G:/fileAnnexesTest` |
| `FILE_SITE_PATH`（替换为） | `https://file.usaeu.com` | `http://testfile.usaeu.com` |

3. 下载到 `resources/anr3/{VATRegInfoId}/{tid}.pdf`（`downloadFile()` 内部先创建目录、用 cURL 下载）
4. 失败 → 错误信息「文件下载失败: {url}, 错误信息: {...}」

### 5.4 PDF 文本提取（双通道降级）

**第一通道：python + pdfplumber**（`extractPdfContent_python`）

- 执行 `python public/ANR3.py {绝对路径}`，ANR3.py 用 pdfplumber 逐页 `extract_text()`，空页尝试 `extract_tables()`，最终输出 JSON：`{"code":200,"msg":"success","data":{"text":"..."}}`
- PHP 侧处理：`mb_convert_encoding` 转 UTF-8 → 剔除控制字符 → 截取首 `{` 到末 `}` 之间的 JSON → `json_decode`

**第二通道：PaddleOCR**（`extractPdfContent_paddleocr`，降级触发条件）

- JSON 解析失败 / `code != 200` / `data.text` 为空
- 使用 `PaddleOcrClient($apiKey=BAIDU_OCR_FILE_API_KEY, $timeout=120)` 调用 `recognizePdf()` + `extractPlainText()`（按页输出 `--- 第 N 页 ---` 文本块）

### 5.5 正则解析明细（parsePdfContent）

对提取的全文按关键字正则匹配（均不区分大小写，`i` 修饰）：

| 目标字段 | 正则模式 | 说明 |
| --- | --- | --- |
| `ocr_company_vat_number` | `CODICE FISCALE :\s*(\S+)` | 公司税号（CF） |
| `ocr_company_name_en` | `DENOMINAZIONE :\s*(.+)` | 公司名称 |
| `ocr_register_num` | `CODICE ESTERO :\s*(\S+)` | **本国税号**（回写用） |
| `ocr_company_country` | `UFFICIO COMPETENTE DELLO STATO ESTERO :\s*([^\n]+)` | 公司国家（初值） |
| `ocr_company_address_line2_en` | `SEDE LEGALE...INDIRIZZO\s*:\s*([^\n]+)` | 公司地址 |
| `ocr_company_address_city_en` | `CITTA' :\s*(.+?)\s+STATO ESTERO :` | 公司城市 |
| `ocr_company_country`（覆盖） | 截取 `CITTA'` 到 `DATI ANAGRAFICI DEL RAPPRESENTANTE` 段内匹配 `STATO ESTERO :\s*([^\n#]+)` | 精确公司国家 |
| `ocr_legal_vat_number` | `DATI ANAGRAFICI DEL RAPPRESENTANTE.*?CODICE FISCALE :\s*(\S+)(?:\s*\(PRIMA ATTRIBUZIONE\))?` | 法人税号 |
| `ocr_legal_person_gender` | `SESSO:\s*(F\|M)` | 法人性别（转大写） |
| `ocr_legal_person_last_name` | `COGNOME :\s*(.+?)\s+NOME :` | 法人姓 |
| `ocr_legal_person_first_name` | `\bNOME :\s*([^\n]+)` | 法人名 |
| `ocr_legal_person_address_line1_en` | `RESIDENZA ESTERA...INDIRIZZO\s*:\s*([^\n]+)` | 法人地址 |
| `ocr_legal_person_address_city_en` | `RESIDENZA ESTERA...CITTA'\s*:\s*([^\n]+)` | 法人城市 |
| `ocr_legal_person_country` | `RESIDENZA ESTERA.*?STATO ESTERO :\s*([^\n#]+)` | 法人国家 |

**回退逻辑**（法人字段缺失时用公司字段兜底）：

- 法人地址为空 → 用公司地址 `ocr_company_address_line2_en`
- 法人城市为空 → 用公司城市 `ocr_company_address_city_en`
- 法人国家为空 → 用公司国家 `ocr_company_country`

**必填校验（13 项）**，缺失提示「{名称}未识别到」：

```
公司税号 / 公司名称 / 本国税号 / 公司国家 / 公司地址 / 公司城市 /
法人税号 / 法人性别 / 法人姓 / 法人名 / 法人地址 / 法人城市 / 法人国家
```

### 5.6 processSingleRecord 单条落库

**① 幂等查重**：

```sql
SELECT id, status FROM anr3_it_profis_register WHERE tid = :tid
```

> 注意：与 aa7 不同，**此处已存在的记录也会被更新**（无 status=2 跳过逻辑）。

**② 字段映射表**（写入 `anr3_it_profis_register`，**业务字段全部来自 OCR 结果**）：

| 目标字段 | 来源 | 说明 |
| --- | --- | --- |
| `tid` | `br.Code` | 订单流水号 |
| `businessID` | `br.ID` | 业务 ID |
| `VATRegInfoId` | `vr.Id` | 注册信息 ID |
| `ANR3_code` | `br.ProfisCode` | 业务代码 |
| `company_name_en` | `ocr_company_name_en`（trim） | 公司英文名（OCR） |
| `company_name_cn` | `c.NameCN`（trim） | 公司中文名（SaaS） |
| `company_country` | `ocr_company_country`（trim） | 公司注册国家（OCR） |
| `company_address_line2_en` | `ocr_company_address_line2_en`（trim） | 公司地址（OCR） |
| `company_address_city_en` | `ocr_company_address_city_en` | 公司城市（OCR） |
| `company_address_province_en` | `c.ProvinceEngName`（trim） | 公司省份（SaaS） |
| `company_vat_number` | `ocr_company_vat_number` | 公司 VAT 号码（OCR） |
| `register_num` | `ocr_register_num` | 注册号码-本国税号（OCR） |
| `legal_person_gender` | `ocr_legal_person_gender` | 法人性别（OCR） |
| `legal_vat_number` | `ocr_legal_vat_number` | 法人 VAT 号码（OCR） |
| `legal_person_last_name` | `ocr_legal_person_last_name`（trim） | 法人姓（OCR） |
| `legal_person_first_name` | `ocr_legal_person_first_name`（trim） | 法人名（OCR） |
| `legal_person_country` | `ocr_legal_person_country` | 法人国家（OCR） |
| `legal_person_address_line1_en` | `ocr_legal_person_address_line1_en` | 法人地址（OCR） |
| `legal_person_address_city_en` | `ocr_legal_person_address_city_en` | 法人城市（OCR） |
| `manual_type` | 固定 `1` | 手动类型 |
| `status` | 固定 `0` | 0=待处理 |
| `retry_count` | 固定 `0` | 重试次数 |
| `msg` | 空串 | 消息 |
| `created_time` / `updated_time` | `date('Y-m-d H:i:s')` | 创建/更新时间 |

> 代码中 `parseLegalPersonName()`（按空格拆分"名 姓"）已被注释停用，实际姓/名直接取自 OCR 的 COGNOME / NOME 字段。

### 5.7 SaaS 反写（成功路径）

**① 反写法人信息** → `Base_Customer_Company`：

```sql
UPDATE Base_Customer_Company
SET LegalPersonFullNamePinYin = :LegalPersonFullNamePinYin,   -- OCR 名（去空格）+ ' ' + OCR 姓（去空格）
    LegalPersonCityEngName    = :LegalPersonCityEngName       -- OCR 法人城市
WHERE ID = :id
```

**② 回写本国税号**（仅当 `br.LocalCountryTaxNumber` 为空时）→ `VATBusinessRecord`：

```sql
UPDATE VATBusinessRecord
SET LocalCountryTaxNumber = :LocalCountryTaxNumber   -- OCR 提取的 ocr_register_num
WHERE Code = :tid
```

---

## 6. 两个任务对比总结

| 对比项 | aa7 | anr3 |
| --- | --- | --- |
| PushType | `109` | `110` |
| 每轮处理量 | TOP 130 | TOP 1 |
| 证书 PDF 下载 | 无 | 有（Base_AnnexesFile → 前缀替换 → 本地下载） |
| OCR/文本提取 | 无 | python pdfplumber → PaddleOCR 降级 |
| 字段来源 | SaaS 公司字段 + Base_Area 反查 | OCR 解析字段（13 项必填） |
| 省份/城市反查 | Base_Area（F_QuickQuery） | 无（省份取 ProvinceEngName） |
| 幂等跳过（status=2） | 有 | 无（存在即更新） |
| SaaS 反写 | 仅 VATRegInfo 状态 | VATRegInfo 状态 + Base_Customer_Company 法人信息 + VATBusinessRecord 本国税号 |
| 香港特殊处理 | 省份/城市补 HONGKONG | 无 |
| 失败记录 | rpa 库 status=-1 + msg + SaaS 状态 | 仅 SaaS 状态（rpa 库 msg 更新方法已注释） |

## 7. 状态约定

| 位置 | 状态值 | 含义 |
| --- | --- | --- |
| `VATRegInfo.PushTaxBureauStatus` | `1` | 待推送（取数条件） |
| `VATRegInfo.PushTaxBureauStatus` | `2` | 推送成功（处理完成后回写，清空错误信息） |
| `VATRegInfo.PushTaxBureauStatus` | `-1` | 推送失败（写入 `PushTaxBureauErrorMsg` 错误原因） |
| `it_profis_register.status` | `0` | 待处理（下游 PROFIS 消费） |
| `it_profis_register.status` | `2` | 已成功（aa7 幂等跳过依据） |
| `it_profis_register.status` | `-1` | 校验失败（aa7 写入 `msg`） |
| `anr3_it_profis_register.status` | `0` | 待处理 |

## 8. 关键配置文件

| 文件 | 关键项 | 说明 |
| --- | --- | --- |
| `config/database_config.php` | `getSqlServerConfig()` / `getlocalSqlserverConfig()` | vat_db 源库 / rpa 目标库 |
| `config/api_config.php` | `MAIN_SITE_PATH`、`FILE_SITE_PATH` | 附件内网盘路径 ↔ 公网下载地址替换对（生产 `G:/fileAnnexes` ↔ `https://file.usaeu.com`；测试 `G:/fileAnnexesTest` ↔ `http://testfile.usaeu.com`） |
| `config/api_config.php` | `BAIDU_OCR_FILE_API_KEY` | 百度千帆 OCR Key（PaddleOCR 降级通道） |
| `public/ANR3.py` | pdfplumber 提取 | ANR3 证书 PDF 文本提取脚本（输出 JSON 到 stdout） |
