# CLAUDE.md - FR EPR Reg

Laravel 11 service: poll source SQL Server -> enrich via INSEE -> generate DOCX/XLSX from templates -> DOCX->PDF -> upload Tencent COS -> save target DB -> update source status -> WeChat notify.

**Reference**: [README.md](README.md) (commands, services, env vars) · [docs/architecture.md](docs/architecture.md) (pipeline, data models, mail monitors, APIs, retry/log/conventions) · [docs/runbook.md](docs/runbook.md) (setup, troubleshooting, Windows) · `docs/*-api.md` (API specs) · `E:\ou\meiou-app\app_withdrawn\docs\` (new-system integration contract + LEKO/CITEO sample payloads)

## Commands

- `php artisan epr:process` - polling daemon (1 record/cycle, 8s sleep), **LEKO only**. `--dry-run` = query only.
- `php artisan epr:mail-monitor` - **recommended** combined IMAP daemon (Léko + CITEO, one connection/cycle).
- `php artisan epr:leko-monitor` / `epr:citeo-monitor` - standalone folder monitors (prefer `mail-monitor`).
- `vendor/bin/phpunit` - tests (template-dependent tests auto-skip).

## Database & Polling

Two DBs on one SQL Server: `sqlsrv_source` (EPRBusinessRecord, EPRRegInfo, Base_AnnexesFile, AgencyBills) and `sqlsrv_target` (fr_epr_reg, fr_epr_mail, fr_epr_citeo).

Polling filter (`SourceRepository::fetchPendingRecords`): `PushTaxBureauStatus=5 AND PushType=301 AND Country='FR' AND ServiceItemName='包装法注册' AND SupplierName='LEKO'` (joins in that method). **CITEO excluded from polling** - on-demand via `generate-citeo-poa` (processor hardcodes LEKO templates, so CITEO in polling would mis-process). **Refashion (纺织法 / ECO TLC) also API-only** via `generate-refashion-poa` (text-only fill, no signature image).

## Status Values

- Target `fr_epr_reg.status`: 0=pending, 1=processing, 2=success, 3=failed.
- Source `EPRRegInfo.PushTaxBureauStatus`: 5=pending, 6=files generated (awaiting registration mail), 3=push succeeded, 7=failure (Remarks=error). **4=UIN issued (下号完成)** - written by the Léko RPA scripts in `python/` together with `RegBackNumber`.

## Critical Gotchas

- **DOCX templates**: single body-level `<w:sectPr>` only (no paragraph-level section breaks) or LibreOffice makes multi-page PDFs with content loss. Templates/fonts in `storage/` (full list in [README](README.md#template-files)).
- **`LIBREOFFICE_PATH=`** empty in .env bypasses auto-detection (`env()` returns `''` not `null`); AppServiceProvider converts empty env values to `null`.
- **Mail monitors run manually in a terminal, NOT as NSSM services** - SYSTEM account causes IMAP hangs. Only `FrEprProcess` (`epr:process`) is NSSM.
- **API 流程与 source 流程写不同的 COS 桶**：统一 API 家族（`/api/epr/fr/file-generation`、`/api/epr/fr/merge-xlsx`、`/api/epr/fr/registration-mail`）走 `OSS_API_BUCKET`（默认 `usaeu-1259285998`），key 为 `{OSS_API_PREFIX}{年}/{OSS_API_MODULE_DIR}/{业务线}/{唯一段}/…`（业务线 = `leko` / `citeo`；合并产物在 `.../merged/`）；其余流程（`epr:process` 轮询、邮件监控、旧 `/api/epr/*`）仍走 `OSS_BUCKET`（`vat-1259285998`），目录不变。**合并 / 注册的入参是 OSS key（相对路径），由 `FrenchApiOssKeyValidator` 强制落在本流程目录内**（拒绝绝对 URL / 前导 `/` / `\` / `.`/`..` 段（含 `%2e%2e`）/ 控制字符；缺失 fail-closed），并限单文件 20 MB、合计 200 MB。**usaeu 是共享私有桶（匿名直连 403）**：上传保持对象私有——disk `visibility` 必须为 `private`（设 `public` 会让 flysystem 带 `ACL:public-read`，POA/XLSX 对全世界可读）；消费方（SaaS）按需签名取文件，参照实现 es_haiya_epr 同款。
- **`post_max_size` ≥ 128M** in php.ini (send-registration-mail / send-refashion-mail `pdf_zip_base64` can exceed 64M). **GD extension** required (signature images).

## Conventions

- Gender '1'=MR, '2'=MRS. Name split: first word=first name, rest=last.
- XLSX starts row 14; DOCX date `Y.m.d`; French decimal comma (2.5->2,5); phone hyphen->space.
- Non-CN/non-HK empty AreaName -> `strtoupper(CountryTwoCode)`; HK empty -> '香港'.
- **API response `msg` must be English** (Windows encoding issues).
- **Filenames**: company names entering filenames/OSS keys always go through `FileNameSanitizer::sanitize()` (strips `\ / : * ? " ' < > | # %`, control chars->space; keeps `&`). `OssUploader` decodes `%XX` in the returned COS URL path so filenames are readable (`&`, spaces, non-ASCII raw); callers must NOT `urldecode` the URL again (`+` would become a space).

## Where Things Live

- Services: `app/Services/EprReg/` (table in [README](README.md#services)). Logs: `storage/logs/{epr,mail,citeo,api}/` (in [architecture](docs/architecture.md)).
- Templates/fonts: `storage/`. Python PDF fallback: `storage/python/normalize_pdf.py`.
- RPA scripts (Léko 下号/证书, step1 取任务/step2 传 COS/step3 回写 `RegBackNumber`+status 4): `python/rpa_leko_step*.py` (pymssql + COS SDK; **source 流程专用**，按 `PushTaxBureauStatus=3` 认领，`/api/epr/fr/*` 家族从不写该字段)。**API 流程用独立的一套** `python/rpa_leko_api_step*.py`：经 `/api/epr/leko-rpa/claim|result` 认领/回执（取数只返回 `data_source='api'` 且注册成功的行），任务行由 registration-mail 发信成功后落目标库，回执成功后由服务端调用 SaaS delivery 异步通知（契约 §13）。

## APIs (quick reference)

All `internal.network` (172.16.x.x) except the `/api/epr/fr/*` family (new-system facing: `fr.api.transport` + optional Bearer + per-IP rate limit; the three endpoints **share one per-IP quota**), English `msg`.

| Endpoint | Body | Spec |
|---|---|---|
| POST `/api/epr/merge-xlsx` | `{AttachmentIDs: string[]}` (≥2) | `docs/merge-xlsx-api.md` |
| POST `/api/epr/send-registration-mail` | `{xlsx_url, pdf_zip_base64, epr_reg_info_ids[]}` (1–100) | `docs/send-registration-mail-api.md` |
| POST `/api/epr/send-refashion-mail` | `{pdf_zip_base64, epr_reg_info_ids[]}` (1–100, ECO TLC) | `docs/refashion-mail-api.md` |
| POST `/api/epr/generate-citeo-poa` | `{Id}` (EPRRegInfo UUID) | `docs/citeo-poa-api.md` |
| POST `/api/epr/generate-refashion-poa` | `{Id}` (EPRRegInfo UUID, ECO TLC textile) | `docs/refashion-poa-api.md` |
| POST `/api/epr/generate-refashion-uin-certificate` | `{NameEng, CountryReg, BusinessLicenseNo, UIN, CompanyCnName}` (all required; date server-side) | `docs/refashion-uin-certificate-api.md` |
| POST `/api/epr/fr/file-generation` | 统一信封 `{PushType, Country, Data, bizParam}`；`FR_EPR_REGISTER_LEKO_FILE`/`FR_EPR_REGISTER_CITEO_FILE`，同步返回 `data.files[]`（§5.1 统一形状，url 为 API 桶相对路径）；零 source 读写，LEKO 法国公司走 INSEE | `docs/french-file-generation-api.md`（对接契约见 `E:\ou\meiou-app\app_withdrawn\docs\UNIFIED_API_DESIGN.md` §4.4.21（LEKO）/ §4.4.7（CITEO）） |
| POST `/api/epr/fr/merge-xlsx` | 统一信封 `FR_EPR_REGISTER_LEKO_FILE_MERGE`；`Data.files[]` = 2–50 个生成接口返回的 XLSX key（API 桶相对路径），合并后回写 API 桶 `.../merged/` | `docs/french-merge-xlsx-api.md`（契约 §4.4.22） |
| POST `/api/epr/fr/registration-mail` | 统一信封 `FR_EPR_REGISTER_LEKO`；`Data.files[]` = 1 个合并 XLSX + 1–100 个 POA key，校验「XLSX 行数 == POA 数」后发注册邮件给 Léko（零 source 库访问；发信成功后按公司落下号任务行到目标库 `fr_epr_reg`，`data_source='api'`） | `docs/french-registration-mail-api.md`（契约 §4.4.23） |
| POST `/api/epr/leko-rpa/claim` | 无请求体；`internal.network`；为 API 流程 RPA 原子认领一条下号任务（**只取 `data_source='api'` 且 `status=2` 的注册成功数据**），返回 source 对齐字段 + 文件 key/签名下载 URL + bizParam | `docs/french-rpa-leko-api.md` |
| POST `/api/epr/leko-rpa/result` | `{task_id, status, reg_code?, cert_key?, cert_name?, error_message?}`；`internal.network`；回执写 `reg_code`/`cert_oss_url`，成功后由服务端调用 SaaS delivery 异步通知（契约 §13） | `docs/french-rpa-leko-api.md` |

> Refashion POA returns only `{pdf_url}` - customer-manager notifier **deliberately disabled** (`RefashionPoaNotifier` retained; `REFASHION_SMTP_*` still used by `send-refashion-mail`).
