# CLAUDE.md

本文件为在此仓库中工作的 Claude Code（claude.ai/code）提供指引。

## 项目概述

英国 VAT 注册数据同步系统。从源 SQL Server 数据库（RPA 系统）读取待处理的 VAT 业务记录（source 流程），同时提供对外接收接口 `api.php` 供外部系统直接推送（API 流程，不读 source 库），两条流程共用校验/转换逻辑，校验并转换后同步到目标数据库（`uk_vat_register` 表）。通过外部 API 创建注册邮箱并回写邮箱信息到源库。

系统作为常驻 PHP 守护进程运行（5 秒轮询间隔），默认**持续模式**（`config/app.php` 中 `max_cycles = 0`）：心跳文件跟踪状态、文件锁防止多实例、连续 10 次错误后重新初始化 `DataSyncService`。（旧版限次模式 `max_cycles > 0` 曾依赖 `auto-restart.bat` 自动重启，该脚本已移除。）

## 命令

```bash
# 运行同步守护进程
php bin/sync.php env=test        # 测试环境
php bin/sync.php env=prod        # 生产环境

# 持续模式（max_cycles=0，当前默认）：直接运行，Ctrl+C 优雅停止
# 旧版限次模式（max_cycles>0）的 auto-restart.bat 已移除，仅支持持续模式

# 安装依赖
composer install

# 测试脚本（无 PHPUnit——独立脚本逐个执行，直连真实数据库）
php test/test-basic.php
php test/test-database.php
php test/test-country.php
php test/test-email.php
php test/test-submission-month.php
php test/test-fields.php
php test/test_phone_formatting.php
php test/test_phone_validation.php
php test/test_uk_fields.php
php test/test-api-validator.php  # API 纯逻辑（国家映射/转换工具/校验器，无 DB 写入）
php test/test-api.php            # API 集成（直连 rpa_test，数据自动清理）

# 工具脚本
php bin/update-sales-contact.php  # 批量更新销售联系人
php bin/align-email-sequence.php  # 校准 test 库邮箱序号表（与共享邮件服务器高水位脱节时）

# RPA Python 脚本（由 RPA runner 调用，不常驻）
python -m py_compile python/rpa_step1_get.py python/rpa_step1_save.py python/rpa_step2_mtdbind_save.py python/rpa_step3_eori_save.py python/rpa_step1_reg_download.py python/rpa_step1_upload.py python/rpa_step2_upload.py python/rpa_step3_upload.py  # 语法检查

# 本地调试对外接口
php -S 127.0.0.1:8099 api.php
```

## 架构

命名空间：`VatSync\`（PSR-4 自动加载，根目录为 `src/`）

### 数据流

```
Source DB (vat_db / vat_db_test)          Target DB (rpa / rpa_test)
┌─────────────────────┐                   ┌──────────────────────┐
│ VATBusinessRecord   │──fetch──>          │ uk_vat_register      │
│ VATRegInfo          │                   │ email_sequence_manager│
│ VATTaxNumber        │──writeback──>      │                      │
│ GBVAT_Register      │                   └──────────────────────┘
│ Base_Customer_Company│                          │
│ Base_AnnexesFile    │                          │
│ Country             │                          │
└─────────────────────┘                          │
        │                                        │
        └ Email API (mail.usaeu.com) ──────────>│
                                                 │
        RPA（python/rpa_step1_get.py → rpa_step1_reg_download.py → rpa_step1_save.py → step2/step3）──>│ 处理 uk_vat_register
                                                 │
        api 行结果 ── delivery 统一回调 ──> 调用方（{code,msg,ProcessMode,data,bizParam}）
```

### 核心组件

- **`bin/sync.php`** — 守护进程入口：主循环、心跳文件、进程锁、连续 10 次错误后重新初始化 `DataSyncService`
- **`api.php`** — 对外接收接口（HTTP POST）：不读 source 库，校验并落库 `uk_vat_register`；部署配置在文件顶部常量（`API_ENVIRONMENT`：test/prod；`API_KEY`：非空则校验 `X-API-Key` 请求头，留空关闭鉴权），请求不能切换环境；请求体上限 2MB；统一信封 `{code, msg, ProcessMode, data, bizParam}`；**所有响应（含 200 成功）`data` 统一为 `null`**；契约见 app_withdrawn `UNIFIED_API_DESIGN.md` §4.4.8 与本仓库 `docs/API.md`
- **`DataSyncService`** — source 流程编排器。每轮：取 1 条待处理记录（PushTaxBureauStatus=1, PushType='101', Country='GB'）→ 校验 → 建邮箱 → 写目标库 → 回写邮箱 → 更新状态
- **`ApiDataSyncService`** — API 流程编排器（api.php 服务层）：信封/字段类型校验（GB_VAT_REGISTER）→ 字段校验 → 流水号 `sp_getapplock`（冲突 409）→ 查重键 `bizParam.BusinessId` 存入 `source_record_id=API:{BusinessId}`（缺省降级 `API:{流水号}`）等值查重、过滤唯一索引兜底（未命中再经 biz_param JSON 兼容旧行）：status 0/1/2 → 409 报错（防重复提交，不更新不建邮箱）、3/4 → UPDATE（复用邮箱、状态重置 0）→ INSERT/UPDATE（写 `DataSource='api'`、`push_type='101'`、`code` 列写 `bizParam.BusinessSerialNumber`（`Data.Code` 不再写入 code 列，RPA 四脚本 rpa_step1_get/rpa_step1_save/rpa_step2_mtdbind_save/rpa_step3_eori_save 的 `parse_biz_param` 兜底按 code 列回取流水号）；need_eori 缺省 0；文件 URL 仅 https 且防私网 SSRF；绝对 https 地址做 HEAD 探测（5s 超时）确认文件存在，相对路径跳过）。字段契约：国家字段只收二字码（`cache/countries.json` 字典解析为规范英文名）；法人姓名必填名/姓两字段 `LegalPersonNamePinyin`+`LegalPersonSurnamePinyin`（旧 `LegalPersonFullNamePinYin` 已废弃不再接受）；上传文件字段 `UploadFile`（值 `[{fileUrl,fileName}]` 可多条，全部文件放同一字段，参与 source 流程 `FileService::classifyFiles` 同规则关键词分类：法人证件 passport/identity card/drive license → `upload_file_path1`，支持文件 bank statement/... → `upload_file_path2`/`path3`，匹配多个则多个一并递交；未命中关键词、条目缺 fileUrl、超容量（证件>1 或支持文件>2）、旧字段名（`UploadFile1/2/3` 及更早 `LegalPersonIdDocumentPic`/`SupportingDocumentPic1/2`，均视为 `UploadFile` 未传）均报数据错误；第三个支持文件不强制）。注册邮箱：test 环境不新建（邮箱服务器无 test/prod 之分，测试新建会消耗正式邮箱），固定 `registrationvat19413@usaeu.com`；prod 环境经 `EmailService` 创建。响应：200 成功 `data=null`（与所有非 200 响应一致——统一信封不携带任何数据，仅 `code`/`msg`/`bizParam`）
- **`DataValidator`** — 校验全部字段（公司名、VAT 生效日期 ±3 个月、法人姓名/年龄/地址、电话 7-13 位、国家 ≠ UK、产品范围、附件、年度 GMV）；错误收集进数组；`validate()`=source 流程（要求附件 ID），`validateApiRecord()`=API 流程（跳过附件 ID）。文件校验：`upload_file_path1`（法人证件）与 `path2`（支持文件）必填，`path3`（第二个支持文件，关键词多匹配才递交）可选
- **`VatRecordTransform`** — 两条流程共用的纯静态转换：日期拆分、姓名拆分（末词为姓）、电话清洗、提交月份（+2 月）、英国公司判定、文件字段解析（`[{fileUrl,fileName}]`：`parseFileFieldList` 全部项 / `parseFileField` 取第一项）
- **`FileService`** — source 流程按附件 ID 查 `Base_AnnexesFile`、按环境将内部路径转可访问 URL；关键词分类抽为**source / API 共用静态方法**：文件名小写包含 `passport`/`identity card`/`drive license` → `upload_file_path1`（法人证件，第一个命中）；包含支持文件关键词（`bank statement`/`credit card statement`/`electricity bill`/`water bill`/`property fee bill`/`telephone bill`/`gas bill`/`broadband bill`/`property ownership certificate`/`birth certificate`/`social security certificate`）→ 依次 `upload_file_path2`/`path3`（匹配多个则多个一并递交）；`classifyFiles()` 分类、`matchFileType()` 单文件命中判定、`overflowErrors()` 超容量（证件>1 或支持文件>2）
- **`CountryService`** — 中文名 / 二字码 / 英文名 → 规范英文名；提供区号。文件缓存每日刷新（`CACHE_TTL=86400`，`country_cache_{env}.json`）；依赖 source 库
- **`StaticCountryService`** — 国家字典解析服务：加载 `cache/countries.json`（与 app_withdrawn 共享，215 项），API 流程严格只接受国家二字码（中文名/英文名输入已废弃）；字典原始 name_en 归一为目标库规范名（GB→`United Kingdom`、HK→`Hong Kong` 等），字典缺失码回退内置 ISO 3166 表，业务表规范名优先；公开方法与 CountryService 对齐
- **`EmailService`** — 经外部 API 创建 `registrationvat{N}@usaeu.com`；`email_sequence_manager` 表并发安全生成序号；"已存在"时递归重试
- **`Connection`** — 静态惰性 PDO 工厂；源/目标库按环境分开连接
- **`python/rpa_step1_get.py` + `python/rpa_step1_save.py` / `python/rpa_step2_mtdbind_save.py` / `python/rpa_step3_eori_save.py` / `python/rpa_step1_reg_download.py` / `python/rpa_step{1,2,3}_upload.py`** — RPA 后处理脚本（独立运行，runner 调用，不互相 import）：领取（状态 0/1/3 → 1，重试达上限置 4）→ 下载上传文件 → 三步保存（step1 注册完成 / step2 绑定 MTD / step3 申请 EORI；各步结果文件先经 `rpa_step{1,2,3}_upload.py` 上传，返回地址由 runner 传参给保存脚本）。**source 与 api 两套处理完全不同、无回退兼容**：source 行仅流程**最后一步**推 Redis 队列（`factory:meiou:queue:VatRegOver`，`{Id: source_record_id}`，`REDIS_*` 文件头常量——分流同 api 行轮2：need_eori=0 → step2 推、=1 → step3 推；step1 source 行走老 open-token 回调（runner 传 `callback_url`/`callback_token`/`vat_business_record_id`，HTTP 200 且 `succeeded=true` 判注册状态 2、否则 3，成功才写库，`submit_backup_data` 存完整原始请求 JSON），不 import redis）；api 行调用 delivery 统一异步结果通知（地址 `delivery_callback_url`，受理 `bizParam.callback_url` 优先、缺省 `DEFAULT_RESULT_CALLBACK_URL`，退避重试最多 3 次：网络错误/超时/5xx 重试、4xx 不重试；每轮请求/响应写入 `uk_vat_register` 的 `Notify1*`（轮1）/`Notify2*`（轮2）两套各 5 列、互不覆盖，并继续写旧 JSON 列 `delivery_notify_log`，迁移见 `sql/add_notify_fields.sql`）；老 open-token 回调保留，与 delivery 两套系统无回退、无兼容。**结果文件上传分流**（`rpa_step{1,2,3}_upload.py` 三文件同一 `upload_to_tencent_cos`，`data_source` 参数分流）：api 行上传桶固定 `usaeu-1259285998`（SaaS 共享桶，密钥/地域仍用传入值），对象键 `common-test/generatefile/{年}/uk_vat_register/{record_id}/{文件名}`（record_id 为 `uk_vat_register` 行主键子目录——文件名来自 HMRC 页面标题、每条记录固定一致，不加记录子目录会跨记录同名互盖；`upload_to_tencent_cos` 新增 `record_id` 参数，runner 必须传当前行主键，空值降级旧模板并告警；前缀按部署环境写死常量，正式环境 common-prod，无时间戳子文件夹），返回 OSS 相对路径（不带域名、不 URL 编码）——入库 `submit_backup_data` 与通知 `files[].url` 均为相对路径，域名由 delivery 接收方拼接（与 ES 海牙 2026-08-31 契约一致）；source 行完全原样（传入桶/路径、返回完整 URL）。通知两轮：轮1 `REGISTER_INFO`（step1，注册信息：vrsReceiptCode/mtd* 等 + files type=`REGISTRATION_RECEIPT_FILE`/`REGISTRATION_CONFIRMATION_FILE`）、轮2 `ISSUED_INFO`（下号信息：vatNumber/申报周期/eoriNumber=`GB`+税号+`000` + files type=`VAT_CERTIFICATE_FILE`/`EORI_APPLICATION_RESULT_SCREENSHOT`）；轮2 发送者按 `need_eori` 分流：=0 → step2 发、=1 → step3 发（step2 不发）；通知信封：成功（200）`data` 为对应轮次载荷对象（REGISTER_INFO/ISSUED_INFO 字段结构），失败（500）`data=null`、仅 code/msg/bizParam。api 行注册结果以提交税局成功为判定标准：到达 step1 必已拿到 VRS 回执编号，注册状态恒为 2（成功）；source 行注册状态由老回调响应 `succeeded` 判定（2 成功 / 3 失败）。三文件主函数**同名 `send_callback_request`**（step1 参数：callback_url/callback_token/vat_business_record_id + mtd_*/reg_receipt{,_number}/other_file；step2/step3 参数一致，仅 step3 多 `eori_screenshot_file`）；通知字段与表列对应：轮1 `mtdRegisterEmail`←`customer_email` 列、`vrsReceiptCode`←`reg_receipt_number`；轮2 `vatNumber`/`declarationDeadline`/`firstDeclarationPeriodStart`/`firstDeclarationPeriodEnd`/`vatEffectiveDate` ← `vat_number`/`declaration_deadline`/`declaration_start_date`/`declaration_end_date`/`vatnumber_registration_date` 列（写库后重新读取，保证通知=库值）；`submit_backup_data`：step1 source 行存完整原始请求 JSON（vatBusinessRecordId/mtdInfo/regReceipt/regReceiptNumber/otherFile）、api 行保持简化结构（mtd_account/reg_receipt/reg_receipt_number/other_file），step2/3 写本步提交数据快照。领取时**仅对 DataSource='api' 行**（bucket 与 source 流程不同，私有桶）的 `upload_file_path1/2/3`（COS 相对路径）生成带签名临时下载 URL（`SAAS_COS_*` 固定常量凭证——RPA 每个函数都是独立运行一次的程序，不读环境变量；算法同 qcloud/cos-sdk-v5，仅标准库实现）；source 行（file.usaeu.com 完整 URL）一律原样返回不签名（source 桶公开可读）。rpa_step1_get 达上限时：source 行更新 SaaS 源库 + 企业微信，api 行发 delivery 500 失败通知。
- **`python/rpa_step1_reg_download.py`** — 注册资料文件下载（独立运行，runner 调用）：`download_files(upload_file_path1..3, data_source, download_path1..3)`——三个地址与三个含文件名的本地路径一一对应；source 行（完整 URL）直接下载，api 行（COS 相对路径）拼 `https://{COS_BUCKET}.cos.{COS_REGION}.myqcloud.com/` 前缀并生成带签名临时 URL 后下载（已是完整 URL 的不重新签名）；空值跳过对应文件、单个失败不阻断其余，全部成功时打印三个本地地址；COS 常量 `COS_SECRET_ID/KEY/BUCKET/REGION` 固定定义在文件头（与 rpa_step1_get.py 凭证一致）。

### 状态流转

- `PushTaxBureauStatus = 1` → 待处理（被 sync 拉取）
- `PushTaxBureauStatus = 2` → 处理中（同步成功后设置）
- `PushTaxBureauStatus = -1` → 失败（校验或同步错误）
- `PushTaxBureauStatus = 3` → 跳过（目标库中该记录已完成注册，registration_status=2）
- 目标库 `registration_status = 2` → 已完成，不可更新
- API 流程（api.php）：不写源库状态；目标库 `DataSource='api'`、`source_record_id = API:{BusinessId}`（bizParam.BusinessId 缺省时降级 `API:{流水号}`）、`push_type = '101'`；重复提交按 `source_record_id` 等值查重（未命中再经 biz_param 内 BusinessId JSON 兜底，兼容升级前旧行）：0/1/2 → 409 报错（防重复提交，不更新不建邮箱），3/4 → UPDATE（重置为 0，复用邮箱）
- 目标库 `registration_status = 4` → 重试达上限（rpa_step1_get 置位；api 行同时经 delivery 回调发失败通知）
- **部署顺序（硬性）**：先执行 `sql/add_data_source_and_api_fields.sql`（rpa，含 DataSource/biz_param 列）再部署/重启 sync.php 与 api.php——SQL 已引用这两列，旧库直接运行会报错；RPA 三步保存脚本依赖 `Notify1*`/`Notify2*` 两套列（`sql/add_notify_fields.sql`），交付前需先在正式库与测试库执行（增量迁移脚本 `sql/add_*.sql` 守卫允许 rpa 与 rpa_test 双库直接执行、幂等）

### 环境配置

- `config/app.php` — sync_interval、max_cycles、timezone、文件路径映射、PHP 可执行文件
- `config/database.php` — 各环境（test/prod）源/目标库凭据
- `config.bat` — 批处理脚本的 PROJECT_ROOT 路径

## 关键模式

- 所有 SQL 使用 PDO 预处理语句，查询后 `closeCursor()` 释放 SQL Server 游标
- 日期拆分成年/月/日整数存储到目标库
- 姓名拆分：最后一个词是姓，其余是名
- 电话格式化：去掉空格/+/-/非数字，校验结果为 7-13 位
- 提交月份 = VAT 生效日期 + 2 个月（英文月份名）
- 国家转换：存储前所有国家字段统一转英文名——source 流程经 `CountryService`（源库 Country 表），API 流程经 `StaticCountryService`（`cache/countries.json` 字典；API 契约国家字段只收二字码，字典原始名归一为目标库规范名，与 source 流程写入值一致）
- 英国公司判定：`office_country === 'United Kingdom'` → `is_uk_company=1` 并单独存邮编

## 开发说明

- 需要 PHP 8.3+ 及 `pdo_sqlsrv` 扩展；项目根目录自带 `php/php.exe`
- 无 PHPUnit — `test/*.php` 为独立脚本，直连真实数据库
- **联调边界（2026-09-12）**：UK RPA 与「新系统」的联调**只到 step1**（`rpa_step1_get.py` / `rpa_step1_reg_download.py` / `rpa_step1_save.py`）；**step2（绑定 MTD）/ step3（申请 EORI）从未联调验证过**。未联调 = 无任何真实调用覆盖，其中的错误不会在测试中暴露——2026-09-12 即发现这两个脚本的 `DEFAULT_RESULT_CALLBACK_URL` 漏了 `/delivery/` 路径段（`.../prod-api/rpa/autoRegisterCallback`，已修）。改 step2/step3 时按「从未跑通」对待，改完需真机联调。
- **RPA 脚本禁模块级执行代码（2026-09-20 实测）**：runner 加载 python/ 脚本时执行上下文 `__name__ == "__main__"` 成立——`if __name__ == "__main__":` 块会随每次调用**真实执行**（rpa_step1_reg_download.py 曾留自测块被实测发现，已删）。python/ 下脚本必须保持纯函数库（文件头字面量常量 + 函数定义），自测代码放 test/ 临时脚本，不得写进 RPA 脚本
- `country_cache_*.json` 是工作缓存（每日刷新，退出时经 `cleanup()` 删除），不是 fixture
- `heartbeat_{env}.txt` = 运行状态 JSON；`sync_{env}.lock` = PID 锁（经 `tasklist` 检测过期锁）
