diff --git a/.agents/skills/reimbursement/SKILL.md b/.agents/skills/reimbursement/SKILL.md index 59dc992..88cf2a9 100644 --- a/.agents/skills/reimbursement/SKILL.md +++ b/.agents/skills/reimbursement/SKILL.md @@ -39,30 +39,54 @@ description: > 材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录, 原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程** (`make upload`),没启动时用户那条直链必然打不开;用户说"能打开但行为像旧的",多半是旧进程还在听 - `upload_base_url`(**带端口**,默认 5055),否则探出来的地址手机连不上 + 5055——`GET /api/ping` 回的是 pid 与启动时间,改完 `src/upload/` 的代码必须重启那个进程才生效。 + 重启要杀**真正持有 socket 的那个 PID**(`Get-NetTCPConnection -LocalPort 5055` 查得到),别只杀 + `uv run` 外壳:Windows 允许两个进程绑同一端口,两代收件器并存时手机命中谁全凭运气。 + 多网卡机器还要在 `scripts/data/config.json` 配 `upload_base_url`(**带端口**,默认 5055), + 否则探出来的地址手机连不上。**发链接之前先自检**:端口有没有人听、`/api/ping` 回的是哪个进程, + 再用 `Get-NetIPAddress` 确认链接里那个 IP 还在本机网卡上——别用 `ipconfig`, + 第二个 WLAN 适配器它会漏(2026-10-21 就是这么差点把链接发给错网段的手机) 3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token), 否则读它给的 `pages//p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律) 4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看 `references/invoice-fields.md`(本技能自带,不依赖仓库) 5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)。 - 贪心结果 `ok` 不等于「已核对」 + 贪心结果 `ok` 不等于「已核对」:它只按金额凑数,**等额的发票最容易配错**。实单翻过一次车—— + 同一次出差两张 338.00 的车票被分到两笔支付上,凑出「453.50 → 338.00+117.50=455.50」这种 + 多两块钱的组合,还把 115.50 那张留成未匹配。复核要看**付款时间与行程方向**(先付的款配先乘的程), + 不是看金额对不对得上;`reimbursement_details` 与 `payment_methods` 两边总额相等 + 也不等于逐笔配对正确。工具没有人工作改组的入口(只有 `tolerance`), + 所以把人工归组的结论写进对应 payment 的 `remark`,下游和人看得懂 6. `info_skeleton(info_type)` 取骨架 → 填值 → `save_info`(自动跑规则校验)。拿不准的用 - `validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。差旅与普通可能并存,按 - `suggested_invoice_type` 与明细性质决定,别只有一种 info 就以为报完了 + `validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。 + - **报销口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费**并入差旅单** + (明细「会务费培训费」栏 + 会议通知当附件),不为它另开普通报销单,也不因此放弃补助; + 只有货物类(易耗品/材料/配件)才另走 `normal_info` + 申购单。`suggested_invoice_type` + 看见 general 发票就报 `normal`,这时**以口径为准、不以它为准** + - 住宿发票一般不印入住/退房日期:按会议报到日至返程日推**实际晚数**(4 天行程 3 晚), + 别照搬参考文档 `days=1` 的保守默认——那会把 333 元/晚报成 1000 元/晚,财务必打回 + - 中转到站的多张车票合并成「去程一条、返程一条」,`bill_count` 记实际张数、金额相加; + 明细的日期列由页面按出差起止带出,填报脚本不填它 7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品); 纯差旅没有申购明细会返回失败提示,那不是错误 8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)` 9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个 - `browser_fill(step=...)` → `browser_page_state` 复查 → `browser_close`(不提交也不存草稿)。 - `browser_login` 返回 `need_human` 时让用户在**已打开的那个窗口**里完成扫码/验证码再重调; - travel_info 与 normal_info 同时存在时 `browser_new_form` 必须显式传 `kind` + `browser_fill(step=...)` → `browser_page_state` 复查。`browser_login` 返回 `need_human` 时让用户在 + **已打开的那个窗口**里完成扫码/验证码再重调;travel_info 与 normal_info 同时存在时 + `browser_new_form` 必须显式传 `kind`。`browser_close` 留到**用户提交之后**再叫: + 填完那会儿单据还不存在(没有编号、列表页查不到),窗口一关就是全丢,不是"存了草稿"。 + 收尾话术固定两句:「还没提交,别关这个窗口」+「提交后把编号发我,我用 browser_open_form 核对」 10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用 `browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`, 确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单, 在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断, 最稳的是**单据编号** 11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑; - 续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断 + 续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断。 + **唯独 `attachments` 不能重跑**:它逐个弹窗上传、没有去重,重跑一遍附件就翻倍; + 要补附件让人在附件清单页点「增加」手动传(会话目录里的原始文件路径给他)。 + 还有:差旅单几个标签共用一个页面,「基本信息」那步的截图经常仍拍在明细表格上, + 别拿它当基本信息的证据;要确认就让用户目视,或看 `browser_page_state` 的 markers 12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时 工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住), 要写清楚就把 `attachment_desc` 填进 attachments 项 @@ -72,12 +96,22 @@ description: > - **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料, 禁止说「可以提交了」 - **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜 +- **口径不自己发明**:因出差发生的会议费/会务费/培训费并入差旅单(不为它拆单、不放弃补助), + 货物类普通发票才走普通报销 + 申购单;判定表在 `src/core/expense_kind.py`,工具提示由它驱动, + `suggested_invoice_type` 只是参考。住宿实际晚数、中转车票的合并方式同理——按证据推, + 拿不准就问用户一句,别照默认值交卷 - **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认 采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系; 康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形 - **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`) - **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报 - `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束,别改规则文件 +- **备注/说明要写短**:上限按「宽度」算——汉字与全角标点记 2、字母数字记 1, + `remark` / `merchant` / `attachment_desc` ≤ **100**(约 50 个汉字),普通报销说明 ≤ **40**。 + 财务后端对超宽的答复是 `500 Internal Server Error`,页面上一个字提示都没有,弹窗停在原地, + 现场看着像「脚本没点着确定」(2026-10-21 实测:97 字/宽度 130 存进去了,101 字/宽度 171 被拒)。 + `save_info` 体检与 `browser_fill`(返回 `too_long`)两道闸都会拦,但最好一开始就别把整串发票号 + 塞进备注——发票本身就是附件,备注只写「谁、哪段、几张票」 - 工具返回的信封是扁平的:失败时 `reason` / `next_action` 在顶层,而**额外字段在 `detail` 里** (比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`) - 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**, diff --git a/.agents/skills/reimbursement/references/normal-info-fields.md b/.agents/skills/reimbursement/references/normal-info-fields.md index 586e0c0..ded63fc 100644 --- a/.agents/skills/reimbursement/references/normal-info-fields.md +++ b/.agents/skills/reimbursement/references/normal-info-fields.md @@ -82,7 +82,11 @@ | 字段 | 类型 | 说明 | 推断优先级 | |------|------|------|-----------| -| `reimbursement_description` | string | 报销说明,20 字以内 | ①发票商品/服务名称 → ②根据所有信息总结 | +| `reimbursement_description` | string | 报销说明,20 个汉字以内(宽度 40,超了财务后端静默 500) | ①发票商品/服务名称 → ②根据所有信息总结 | + +> **先判这单该不该走普通报销**:因出差发生的会议费/会务费/培训费**并入差旅单** +> (明细「会务费培训费」栏 + 会议通知当附件),不进本表;只有货物类(易耗品、材料、配件) +> 才走普通报销 + 申购单。判定在 `src/core/expense_kind.py`,`case_status`/`validate_info` 的提示由它驱动。 --- @@ -135,6 +139,10 @@ 3. **支付记录排除**:付款记录不放入 `attachments` 4. **附件全覆盖**:所有源文件(除支付记录外)都应在 `attachments` 中有一项 5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null` +6. **文本必须写短**:`reimbursement_description` 宽度 ≤ **40**(20 个汉字), + `remark` / `merchant` / `attachment_desc` 宽度 ≤ **100**(约 50 个汉字)。 + 汉字算 2、字母数字算 1。超宽不会得到「太长了」这种提示,财务后端直接回 500, + 填报时看上去像按钮没点着(2026-10-21 实测夹住的上限区间,取的是保守值) ### 语义完整性校验 diff --git a/.agents/skills/reimbursement/references/travel-info-fields.md b/.agents/skills/reimbursement/references/travel-info-fields.md index c633d79..2a2f4a8 100644 --- a/.agents/skills/reimbursement/references/travel-info-fields.md +++ b/.agents/skills/reimbursement/references/travel-info-fields.md @@ -102,7 +102,7 @@ | 字段 | 类型 | 说明 | |------|------|------| -| `vehicle_type` | string | 枚举:`train` / `car` / `ship` / `personal_car` / `official_car` / `plane` / `rental_car` / `self_drive` | +| `vehicle_type` | string | **推荐填中文**:`火车` / `汽车` / `轮船` / `自带车` / `公务车` / `飞机` / `租车` / `自驾车`。代码表与别名在 `src/core/vehicles.py`:`train`→火车、`plane`→飞机、`高铁/动车`→火车 这类写法会被归一后选中;**认不出的值现在直接报错**(以前是静默不选、下拉框留默认值,等提交才被必填拦下) | | `start_date` | string | 乘车日期,格式 `YYYY-MM-DD` | | `end_date` | string | 乘车日期,格式 `YYYY-MM-DD` | | `departure_place` | string | 出发地(通常为城市名称) | @@ -125,9 +125,12 @@ | `reimburse_amount` | number | 酒店付款记录合计总额 | | `remark` | string | 住宿人员姓名,例:`示例报销人、示例人员甲住宿` | -**日期推断优先级**:①交通工具发票日期(最高)→ ②酒店发票信息 → ③申请单时间(可能不准) +**日期推断优先级**:①交通工具发票日期(最高)→ ②会议通知的报到/会议日 → ③酒店发票信息 → ④申请单时间(可能不准) -**默认值**:单张酒店发票且无明确信息时,`days` 和 `person_count` 均默认为 1。 +**晚数要按行程推,不是照抄默认值**:住宿发票通常不印入住/退房日期,但行程是有证据的—— +16 日报到、17-18 日会议、19 日返程车票,那就是 10-16 入住、10-19 退房、**days=3**。 +只有完全推不出行程(没有车票也没有通知)时才写 `days=1`、`person_count=1`; +照抄默认值会把 333 元/晚的房价报成 1000 元/晚,超标准必被打回。 --- @@ -148,7 +151,12 @@ | `card_date` | string | 刷卡日期,格式 `YYYY-MM-DD` | | `card_amount` | number | 刷卡金额(元) | | `merchant` | string | 商户信息(高铁票统一为`中国铁路`) | -| `remark` | string | 关联的发票信息,例:`示例报销人和示例人员甲从阜阳西-合肥南高铁票` | +| `remark` | string | 关联的发票信息,例:`公务卡付去程车票2张(发票尾号55222、44418)`(宽度 ≤100,别塞整串发票号) | + +**归组按付款时间与行程方向,不按金额相等**:`match_payments` 是贪心凑数,等额的发票最容易配错 +(实单:两张 338.00 的车票被分到两笔支付上,凑出 453.50 → 338.00+117.50=455.50 这种多两块钱的组合, +还把 115.50 那张留成未匹配)。先付的款配先乘的程;两边总额相等也**不等于**逐笔配对正确。 +工具没有人工作改组入口,把人工结论写进对应 `remark`,下游与人才看得懂。 --- @@ -191,11 +199,20 @@ ### 关键规则 -1. **去回分开**:交通费的去程和返程必须分两条记录,禁止合并 +1. **去回分开**:交通费的去程和返程必须分两条记录,禁止合并。**中转到站的多张票合并成一条**: + 阜阳→长沙要在合肥换车(117.50 + 338.00),就写一条去程 `amount=455.50、bill_count=2`, + 备注里把两段车次写清;不要拆成三条(去程两段 + 返程两段会让"去程/返程"各两条,对不上行程) 2. **住宿天数**:`days = checkout_date - checkin_date`,结果必须 ≥ 0 3. **补助天数**:`days = end_date - start_date + 1` 4. **支付记录排除**:付款记录不放入 `attachments` 5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null` +6. **文本必须写短**(`remark` / `attachment_desc` / `travel_purpose` / `merchant`):宽度上限 **100** + ——汉字、全角标点算 2,字母数字算 1,也就是**约 50 个汉字**。发票号一串就 20 个宽度, + 两张票的号写进备注必然超。财务后端对超宽的回答是 `500 Internal Server Error`, + **页面一个字提示都不给**,弹窗停在原地,看上去像脚本没点着「确定」(2026-10-21 实测: + 97 字/宽度 130 存进去了,101 字/宽度 171 被拒,真实上限它没说)。 + `save_info` 体检与 `browser_fill` 提交前都会拦,但最好一开始就写短: + 备注只写「谁、哪段行程、几张票」,发票号留在发票本身(它就是附件) ### 语义完整性校验 @@ -219,6 +236,7 @@ **一定要关注的** - `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户 - 用户一定要提供出差事情申请单 +- 每个 `remark` / `attachment_desc` 的宽度是否 ≤ 100(约 50 个汉字):超了不是报错,是填报时静默 500 ### 示例 diff --git a/AGENTS.md b/AGENTS.md index 61beffe..2eeff2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,17 @@ last_reviewed: 2026-10-20 * **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、 弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`, 按表头里的「附件文件名」找 +* **自由文本超宽 = 静默 500**(2026-10-21 联机实测):差旅明细的备注写到 101 个字, + `POST /expen/travel/detailadd` 回 500,页面上一个字的提示都没有,弹窗停在原地—— + 现场看着像「脚本没点着确定」,白查一轮。同一张单里 97 字(宽度 130)的备注却存进去了, + 说明上限夹在两者之间(要么按 100 字符,要么按 131~170 之间的字节数;后端不肯说,别拿真单据试)。 + 于是 `validator.TEXT_WIDTH_LIMIT = 100`(汉字算 2、字母数字算 1 ⇒ 约 50 个汉字:宽度 ≤100 + 同时满足"100 字符"与"131 字节"两种可能口径,是两边都安全的取法), + 普通报销的说明另用更短的 `DESCRIPTION_WIDTH_LIMIT = 40`。两道闸: + `validate_extracted_info`(规则里的 `max_len_bytes`,`missing_fields` 会带上 + 「过长:宽度 N > 上限 M」的说明)与 `browser_fill` 在 POST 之前的 + `_text_limit_problems`(失败时返回 `too_long` 列出超长字段)。 + 备注不是记事本:一个发票号 20 位就吃掉 20 宽度,发票本身就是附件,备注只写"谁、哪段、几张票" * **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。 上传收件器同样会多代并存(`uv run` 的外壳/跳板/真解释器三层,杀外层会留下持有 socket 的孙进程, 而 Windows 允许重复绑同一端口):收件器启动前用 `port_busy()` 拒绝二次启动,并用 `GET /api/ping` @@ -136,7 +147,15 @@ last_reviewed: 2026-10-20 可以进日志/回显的那份,`load_config()` 里有明文密码 * **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`, 绝不要提交,也不要回显密码 -* **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;普通发票生成低值易耗品申购单 +* **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;货物类普通发票生成低值易耗品申购单。 + **口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单** + (明细「会务费培训费」栏 + 会议通知当附件),不拆成两张单、也不因此放弃差旅补助;判定只在 + `src/core/expense_kind.py` 一处(`is_conference_like`/`goods_invoices`), + `case_status` 与 `validate_info` 的提示都由它驱动——以前三处一律写"混合发票只能走普通报销", + 等于把这条合法路径说成错的,Agent 照做就会白拆一张单、还漏掉补助。 + 交通工具的代码表在 `src/core/vehicles.py`(中文选项 → `#jtgj` 的 value,含 train→火车 等别名), + 体检与填报共用;认不出的值现在**直接报错**,不再静默不选(老写法 `if vehicle in vehicle_map` + 遇上英文枚举就是不选、还返回成功) ## 技能(skill)分发 diff --git a/config/validation_rules.json b/config/validation_rules.json index b801364..df15fad 100644 --- a/config/validation_rules.json +++ b/config/validation_rules.json @@ -1,9 +1,10 @@ { - "version": "1.0", + "version": "1.1", "custom_checks": { "is_valid_date": "检查日期格式是否为 YYYY-MM-DD", "is_positive_number": "检查是否为正数(整数或浮点数)", - "is_positive_integer": "检查是否为正整数" + "is_positive_integer": "检查是否为正整数", + "is_valid_vehicle": "检查交通工具能否落到财务系统的下拉框上(含 train→火车 等别名)" }, "travel": { "description": "差旅报销校验规则", @@ -12,6 +13,7 @@ "path": ["basic_info", "travel_purpose"], "required": true, "check_empty": true, + "max_len_bytes": 100, "description": "出差事由" }, { @@ -41,14 +43,38 @@ "min_items": 1, "description": "交通费用明细", "element_fields": [ - {"path": ["vehicle_type"], "required": true, "check_empty": true, "description": "交通工具类型"}, + {"path": ["vehicle_type"], "required": true, "check_empty": true, "custom_check": "is_valid_vehicle", "description": "交通工具类型(火车/汽车/轮船/自带车/公务车/飞机/租车/自驾车,也接受 train/car/plane 等英文写法)"}, {"path": ["start_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "出发日期"}, {"path": ["end_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "到达日期"}, {"path": ["departure_place"], "required": true, "check_empty": true, "description": "出发地"}, {"path": ["arrival_place"], "required": true, "check_empty": true, "description": "目的地"}, {"path": ["amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "金额"}, {"path": ["bill_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "票据张数"}, - {"path": ["remark"], "required": true, "check_empty": false, "description": "备注说明"} + {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注说明(财务后端超宽直接 500,见 config/README 或 AGENTS.md)"} + ] + }, + { + "path": ["reimbursement_details", "hotel_fee"], + "min_items": 0, + "description": "住宿费用明细(没有住宿时可整段缺省)", + "element_fields": [ + {"path": ["checkin_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "入住日期"}, + {"path": ["checkout_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "退房日期"}, + {"path": ["days"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "住宿晚数"}, + {"path": ["person_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "住宿人数"}, + {"path": ["invoice_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "发票金额"}, + {"path": ["reimburse_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "报销金额"}, + {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注说明"} + ] + }, + { + "path": ["reimbursement_details", "conference_fee"], + "min_items": 0, + "description": "会务费/培训费明细(没有会务费时可整段缺省)", + "element_fields": [ + {"path": ["bill_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "票据张数"}, + {"path": ["amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "金额"}, + {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注说明"} ] }, { @@ -58,8 +84,8 @@ "element_fields": [ {"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"}, {"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"}, - {"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"}, - {"path": ["remark"], "required": true, "check_empty": false, "description": "备注"} + {"path": ["merchant"], "required": true, "check_empty": true, "max_len_bytes": 100, "description": "商户名称"}, + {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注"} ] }, { @@ -80,7 +106,8 @@ "description": "附件列表", "element_fields": [ {"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"}, - {"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"} + {"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"}, + {"path": ["attachment_desc"], "required": false, "check_empty": false, "max_len_bytes": 100, "description": "附件说明"} ] } ] @@ -92,7 +119,8 @@ "path": ["basic_info", "reimbursement_description"], "required": true, "check_empty": true, - "description": "报销事由" + "max_len_bytes": 40, + "description": "报销说明(财务系统里这栏很短,20 个汉字以内)" }, { "path": ["reimbursement_details", "total_invoices"], @@ -117,8 +145,8 @@ "element_fields": [ {"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"}, {"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"}, - {"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"}, - {"path": ["remark"], "required": true, "check_empty": false, "description": "备注"} + {"path": ["merchant"], "required": true, "check_empty": true, "max_len_bytes": 100, "description": "商户名称"}, + {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注"} ] }, { @@ -127,7 +155,8 @@ "description": "附件列表", "element_fields": [ {"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"}, - {"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"} + {"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"}, + {"path": ["attachment_desc"], "required": false, "check_empty": false, "max_len_bytes": 100, "description": "附件说明"} ] } ] diff --git a/docs/MCP.md b/docs/MCP.md index d799cd1..7b8db60 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -283,6 +283,11 @@ cases// info_type 取 travel|normal。完整性以返回的 validation 为准, 不要自己在 data 里塞 can_submit。 + + 规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1): + 备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到, + 所以在这里就拦住,missing_fields 会写成 + "payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。 | 参数 | 类型 | 必填 | 默认 | 说明 | |------|------|------|------|------| @@ -381,6 +386,10 @@ cases// 执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。 + 提交前会量这一步要 POST 的自由文本宽度(汉字算 2、字母数字算 1,上限 100, + 普通报销说明 40):财务后端对超宽只回 500、页面上任何提示都没有,弹窗停在原地 + 看着像"按钮没点着",所以宁可在这里拦下来,失败时 too_long 会列出超长字段。 + | 参数 | 类型 | 必填 | 默认 | 说明 | |------|------|------|------|------| | `case_id` | string | 是 | — | — | diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index c49fcf9..573892d 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -82,7 +82,13 @@ last_reviewed: 2026-10-19 - 浏览器默认**有头**:提交不可逆,得看得见在点什么;期间别手动点页面 - `browser_login` 返回 `need_human` 时,人在已经打开的那个窗口里扫码或输验证码,然后**重调 `browser_login`** ——不要另开浏览器。`cases//.browser.lock` 记着谁拥有它,返回 `owned_by_other_process` 就等对方释放 -- `travel_info` 与 `normal_info` 同时存在时,`browser_new_form` 必须显式传 `kind`(混合发票只能走普通报销) +- `travel_info` 与 `normal_info` 同时存在时,`browser_new_form` 必须显式传 `kind`,两张单分别走差旅/日常列表页 +- 口径:**因出差发生的会议费/会务费/培训费并入差旅单**(明细「会务费培训费」栏,会议通知当附件), + 不为此另开一张普通报销单;只有货物类(易耗品、材料)才走普通报销 + 申购单。判定写在 + `src/core/expense_kind.py`,`case_status` 与 `validate_info` 的提示由它驱动 +- 明细页顶部的红字把「公务卡刷卡pos单」也列进了附件清单要求里:支付记录默认**不**当附件 + (它们体现在「支付方式」页),要不要补传由你决定——要补就在附件清单页点「增加」手动传, + 别让工具重跑 attachments 那一步(会重复上传已有的附件) - 步骤不能跳:travel 是 basic/details/payment/subsidy/attachments,normal 没有 subsidy - 每步截图按 `001-步骤名.png` 落在 `cases//screenshots/`;失败时截图路径在返回的 `detail.screenshot` - **`browser_close` 不提交也不存草稿**,填了一半会丢;**提交永远由人做**——本项目不提供点提交的工具, diff --git a/src/cases.py b/src/cases.py index ca3932e..addc9d5 100644 --- a/src/cases.py +++ b/src/cases.py @@ -252,8 +252,13 @@ class CaseStore: warnings: list[str] = [] if has_travel and has_normal: warnings.append( - "travel_info 与 normal_info 同时存在:browser_new_form 必须显式传 kind;" - "混合发票按财务口径只能走普通报销" + "travel_info 与 normal_info 同时存在:browser_new_form 必须显式传 kind,两张单各自走差旅/日常列表页" + ) + goods = self.goods_general_items(d) + if goods: + warnings.append( + "有 " + str(len(goods)) + " 张货物类普通发票(" + "、".join(goods[:3]) + "):" + "这类不能并入差旅单,走普通报销并生成申购单" ) if stale: warnings.append("源文件比缓存新,这些票据要重抽:" + "、".join(stale)) @@ -290,6 +295,26 @@ class CaseStore: "warnings": warnings, } + def goods_general_items(self, d: Path) -> list[str]: + """货物类普通发票的物品名(会议/会务/培训类不算——那些并入差旅单的会务费栏)。 + + 口径只在 src/core/expense_kind.py 一处,别在这里另写一份关键词表。 + """ + path = d / "invoice_groups.json" + if not path.exists(): + return [] + try: + groups = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: # 派生产物读坏了不该拖垮状态查询 + log.warning("读 invoice_groups.json 失败(%s):%s", path, exc) + return [] + general = groups.get("general") + if not isinstance(general, list): + return [] + from .core.expense_kind import describe_invoice, goods_invoices + + return [describe_invoice(g) for g in goods_invoices(general)] + def browser_lock_state(self, d: Path) -> dict[str, Any] | None: """读 cases//.browser.lock;没有或损坏返回 None。不依赖 browser 包,避免循环导入。""" lock = d / BROWSER_LOCK_FILENAME diff --git a/src/core/expense_kind.py b/src/core/expense_kind.py new file mode 100644 index 0000000..e21007d --- /dev/null +++ b/src/core/expense_kind.py @@ -0,0 +1,42 @@ +"""费用的报销口径:哪些普通发票能并进差旅单,哪些必须另走普通报销 + +用户 2026-10-21 定案:**差旅 + 会议费这类因出差发生的普通发票,走差旅单**—— +明细里有「会务费培训费」这一栏,会议通知作为附件即可,不用为一张 1500 的会议费 +另开一张普通报销单。反过来,货物类(易耗品、材料、配件)是普通报销 + 申购单那条路, +塞进差旅单会被打回。 + +之前 `case_status`、`validate_info`、`browser_new_form` 三处的话都说成 +「混合发票按财务口径只能走普通报销」,等于把上面这条合法路径也说成了错的—— +Agent 听了会去拆单、还会漏掉差旅补助。判定只留在这一个地方。 +""" + +from __future__ import annotations + +from typing import Any + +# 因出差而发生的费用名目(发票项目名称/备注里出现即算) +CONFERENCE_ITEM_KEYWORDS = ("会议", "会务", "培训", "论坛", "研讨", "报名", "注册", "展位", "场地费") + + +def item_text(invoice: dict[str, Any]) -> str: + """发票上可供判断的文本:项目名称 + 备注(抽取阶段会把说明写进这两处)。""" + return f"{invoice.get('item_name') or ''} {invoice.get('remark') or ''}" + + +def is_conference_like(invoice: dict[str, Any]) -> bool: + """是否为「因出差发生」的会议/会务/培训类费用(可并入差旅单的会务费栏)。""" + text = item_text(invoice) + return any(k in text for k in CONFERENCE_ITEM_KEYWORDS) + + +def goods_invoices(general: list[dict[str, Any]]) -> list[dict[str, Any]]: + """普通发票里属于货物类的(不能并入差旅单,要走普通报销 + 申购单)。""" + return [g for g in general if isinstance(g, dict) and not is_conference_like(g)] + + +def describe_invoice(invoice: dict[str, Any]) -> str: + """报给人看的发票标识:优先物品名,其次来源文件名。""" + name = str(invoice.get("item_name") or "").strip() + if name: + return name[:30] + return str(invoice.get("_source_file") or invoice.get("invoice_number") or "?") diff --git a/src/core/validation/validator.py b/src/core/validation/validator.py index b37d3b8..7dc68cc 100644 --- a/src/core/validation/validator.py +++ b/src/core/validation/validator.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any, NotRequired, TypedDict from ... import get_logger +from ..vehicles import is_valid_vehicle log = get_logger("validator") @@ -48,8 +49,42 @@ _CUSTOM_CHECKS: dict[str, Callable[[Any], bool]] = { "is_valid_date": _is_valid_date, "is_positive_number": _is_positive_number, "is_positive_integer": _is_positive_integer, + # 交通工具必须能落到财务系统下拉框上(含 train→火车 之类别名), + # 填报层以前对认不出的值是静默跳过,先在这里拦住 + "is_valid_vehicle": is_valid_vehicle, } +# ------------------------------------------------------------------ +# 文本宽度上限(财务后端的「备注/说明」列没有公开上限,只能实测) +# ------------------------------------------------------------------ + +# 2026-10-21 联机踩坑:差旅明细里「会务费培训费」那行备注 101 字(宽度 171), +# POST /expen/travel/detailadd 直接 500 Internal Server Error,页面一个字提示都不给, +# 弹窗停在原地,看上去像脚本没点着「确定」;同一张单里 97 字(宽度 130)的交通费备注却存进去了。 +# 结论:上限落在两者之间,而财务系统不肯说具体数字——所以别拿真单据去试长度, +# 在体检阶段就按宽度 100(约 50 个汉字)卡死:比已知会 500 的那条留了余量, +# 也比侥幸存进去的那条更短。宁可让 Agent 把话写短,也不要用户去浏览器里撞 500。 +TEXT_WIDTH_LIMIT = 100 + +# 普通报销的「报销说明」在系统里更短(参考口径:20 字以内) +DESCRIPTION_WIDTH_LIMIT = 40 + +# 校验不通过时,可读原因用这个分隔符跟在字段路径后面(missing_fields 是字符串列表, +# 加个并列结构会牵动所有调用方,带内提示最省) +HINT_SEP = "|" + + +def text_width(value: str) -> int: + """按财务后端的计宽方式估算文本宽度:汉字/全角算 2,字母数字/半角算 1。 + + 用 GBK 编码长度近似(财务系统跑在中文 Windows + GBK 库上)。 + GBK 编不出的字符(生僻字、emoji)退回 UTF-8 长度——宁可高估,不要低估后放过去撞 500。 + """ + try: + return len(value.encode("gbk")) + except UnicodeEncodeError: + return len(value.encode("utf-8", "replace")) + # ------------------------------------------------------------------ # 规则定义 @@ -64,9 +99,10 @@ class FieldRule(TypedDict, total=False): """ path: list[str] # 字段路径(统一定位方式) - required: NotRequired[bool] # 是否必填(读取端默认 True) + required: NotRequired[bool] # 是否必填(读取端默认 True;false 表示键不存在就跳过) check_empty: NotRequired[bool] # 是否检查空字符串(读取端默认 True) custom_check: NotRequired[str | Callable[[Any], bool]] # 自定义校验函数(名称或已解析函数) + max_len_bytes: NotRequired[int] # 文本宽度上限(汉字算 2,见 text_width) description: NotRequired[str] # 字段描述(用于生成友好提示) @@ -175,14 +211,23 @@ def _load_default_rules() -> ValidationConfig: "path": ["reimbursement_details", "transport_fee"], "min_items": 1, "element_fields": [ - {"path": ["vehicle_type"], "description": "交通工具类型"}, + { + "path": ["vehicle_type"], + "custom_check": is_valid_vehicle, + "description": "交通工具类型", + }, {"path": ["start_date"], "custom_check": _is_valid_date, "description": "出发日期"}, {"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"}, {"path": ["departure_place"], "description": "出发地"}, {"path": ["arrival_place"], "description": "目的地"}, {"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"}, {"path": ["bill_count"], "custom_check": _is_positive_integer, "description": "票据张数"}, - {"path": ["remark"], "check_empty": False, "description": "备注说明"}, + { + "path": ["remark"], + "check_empty": False, + "max_len_bytes": TEXT_WIDTH_LIMIT, + "description": "备注说明", + }, ], "description": "交通费用明细", }, @@ -193,7 +238,12 @@ def _load_default_rules() -> ValidationConfig: {"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"}, {"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"}, {"path": ["merchant"], "description": "商户名称"}, - {"path": ["remark"], "check_empty": False, "description": "备注"}, + { + "path": ["remark"], + "check_empty": False, + "max_len_bytes": TEXT_WIDTH_LIMIT, + "description": "备注", + }, ], "description": "支付方式记录", }, @@ -215,6 +265,13 @@ def _load_default_rules() -> ValidationConfig: "element_fields": [ {"path": ["filename"], "description": "文件名"}, {"path": ["attachment_type"], "description": "附件类型"}, + { + "path": ["attachment_desc"], + "required": False, + "check_empty": False, + "max_len_bytes": TEXT_WIDTH_LIMIT, + "description": "附件说明", + }, ], "description": "附件列表", }, @@ -242,7 +299,12 @@ def _load_default_rules() -> ValidationConfig: {"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"}, {"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"}, {"path": ["merchant"], "description": "商户名称"}, - {"path": ["remark"], "check_empty": False, "description": "备注"}, + { + "path": ["remark"], + "check_empty": False, + "max_len_bytes": TEXT_WIDTH_LIMIT, + "description": "备注", + }, ], "description": "支付方式记录", }, @@ -252,6 +314,13 @@ def _load_default_rules() -> ValidationConfig: "element_fields": [ {"path": ["filename"], "description": "文件名"}, {"path": ["attachment_type"], "description": "附件类型"}, + { + "path": ["attachment_desc"], + "required": False, + "check_empty": False, + "max_len_bytes": TEXT_WIDTH_LIMIT, + "description": "附件说明", + }, ], "description": "附件列表", }, @@ -298,30 +367,49 @@ def _check_field( data: dict[str, Any], rule: FieldRule, ) -> tuple[bool, str]: - """根据字段规则检查字段。""" + """根据字段规则检查字段。 + + Returns: + (是否通过, 字段路径)。不通过且原因可说清时(如文本超宽),路径后拼 + 「{HINT_SEP}说明」,_check_array 会把说明转接到 payment_methods[0].remark|… 上, + 让 Agent 一眼看出是「缺字段」还是「话写太长了」。 + """ path = rule["path"] + required = rule.get("required", True) check_empty = rule.get("check_empty", True) custom_check = rule.get("custom_check") + max_len_bytes = rule.get("max_len_bytes") + path_str = ".".join(path) current = data for key in path: - if not isinstance(current, dict): - return (False, ".".join(path)) - if key not in current: - return (False, ".".join(path)) + if not isinstance(current, dict) or key not in current: + # required=false 的字段(如 attachment_desc)不存在就跳过: + # 填报端有自己的兜底(附件说明留空会写成「发票:文件名」) + return (True, path_str) if not required else (False, path_str) current = current[key] if check_empty and isinstance(current, str) and not current.strip(): - return (False, ".".join(path)) + return (False, path_str) if custom_check is not None: if isinstance(custom_check, str): # 规则名未在 _resolve_custom_checks 里解析成函数,属于配置/调用错误 raise TypeError(f"custom_check 仍为规则名 {custom_check!r},未经 _resolve_custom_checks 解析") if not custom_check(current): - return (False, ".".join(path)) + return (False, path_str) - return (True, ".".join(path)) + # 文本超宽:财务后端不会告诉你为什么,只会回 500,所以在这里拦住 + if max_len_bytes is not None and isinstance(current, str): + width = text_width(current) + if width > max_len_bytes: + desc = rule.get("description") or path_str + return ( + False, + f"{path_str}{HINT_SEP}{desc}过长:宽度 {width} > 上限 {max_len_bytes}(约 {max_len_bytes // 2} 个汉字),请改短", + ) + + return (True, path_str) def _check_array( @@ -346,11 +434,16 @@ def _check_array( current = data for key in path: if not isinstance(current, dict) or key not in current: + # min_items=0 的明细段允许整段缺省:没有住宿/会议费的差旅单不该报缺失 + if min_items == 0: + return ([], total_checks, 1) return ([path_str], total_checks, passed_checks) current = current[key] # 检查数组是否满足最小数量要求 if not isinstance(current, list) or len(current) < min_items: + if min_items == 0 and current is None: + return ([], total_checks, 1) return ([path_str], total_checks, passed_checks) passed_checks += 1 # 数组检查通过 @@ -371,13 +464,15 @@ def _check_array( else: field_rule_dict = field_rule - # 复用 _check_field 函数检查元素字段 - ok, _ = _check_field(item, field_rule_dict) + # 复用 _check_field 函数检查元素字段(超宽之类的说明转接到条目上) + ok, msg = _check_field(item, field_rule_dict) if ok: passed_checks += 1 else: field_path_str = ".".join(field_rule_dict["path"]) - missing.append(f"{path_str}[{i}].{field_path_str}") + hint = msg.split(HINT_SEP, 1)[1] if HINT_SEP in msg else "" + tail = f"{HINT_SEP}{hint}" if hint else "" + missing.append(f"{path_str}[{i}].{field_path_str}{tail}") return missing, total_checks, passed_checks diff --git a/src/core/vehicles.py b/src/core/vehicles.py new file mode 100644 index 0000000..4457cf6 --- /dev/null +++ b/src/core/vehicles.py @@ -0,0 +1,90 @@ +"""交通工具口径:财务系统下拉框的代码表与别名归一 + +财务系统的差旅明细「交通工具」下拉框(`#jtgj`)只认中文选项,而 Agent 侧的判读参考历来 +写英文枚举(train/car/...)。老写法 `if vehicle in vehicle_map:` 在英文枚举下是**静默跳过**: +下拉框停在默认值,填报工具照样返回成功,直到人去点提交才被必填挡回来——排查方向还指着 +「脚本没点着按钮」(2026-10-21 联机踩过)。 + +所以这张表从填报层挪到 core,一处三用:填报层取 value、体检层判合法、报错层给候选清单。 +认不出来的值一律显式报错,不再静默。 +""" + +from __future__ import annotations + +# 财务系统 #jtgj 下拉框:选项显示文字 → option value(顺序即下拉框顺序,别按字母排) +VEHICLE_CODES: dict[str, str] = { + "火车": "01", + "汽车": "02", + "轮船": "03", + "自带车": "04", + "公务车": "05", + "飞机": "06", + "租车": "07", + "自驾车": "08", +} + +# 别名(英文枚举、口语写法、繁体)→ 财务系统的中文选项 +VEHICLE_ALIASES: dict[str, str] = { + "train": "火车", + "rail": "火车", + "railway": "火车", + "train_ticket": "火车", + "high_speed_rail": "火车", + "高铁": "火车", + "动车": "火车", + "火車": "火车", + "car": "汽车", + "bus": "汽车", + "coach": "汽车", + "大巴": "汽车", + "长途汽车": "汽车", + "客车": "汽车", + "ship": "轮船", + "boat": "轮船", + "船": "轮船", + "personal_car": "自带车", + "own_car": "自带车", + "私家车": "自带车", + "official_car": "公务车", + "公车": "公务车", + "plane": "飞机", + "airplane": "飞机", + "flight": "飞机", + "航班": "飞机", + "rental_car": "租车", + "self_drive": "自驾车", + "selfdrive": "自驾车", + "自驾": "自驾车", +} + + +def _norm(value: object) -> str: + text = value.strip() if isinstance(value, str) else "" + return text.lower().replace("-", "_").replace(" ", "_") + + +def canonical_vehicle(value: object) -> str: + """归一成财务系统的中文选项;认不出来返回 ""(调用方决定是报错还是忽略)。""" + text = value.strip() if isinstance(value, str) else "" + if text in VEHICLE_CODES: + return text + return VEHICLE_ALIASES.get(_norm(value), "") + + +def vehicle_code(value: object) -> str: + """取 `#jtgj` 的 option value;认不出来返回 ""。""" + name = canonical_vehicle(value) + return VEHICLE_CODES.get(name, "") + + +def is_valid_vehicle(value: object) -> bool: + """体检用:这个 vehicle_type 能不能落到财务系统的下拉框上。""" + return bool(canonical_vehicle(value)) + + +def vehicle_error(value: object) -> str: + """给 Agent 的报错文案:带上能填什么,别让人去猜下拉框。""" + return ( + f"交通工具 {value!r} 财务系统认不出来,也不会替你默认选一个。" + f"可填:{'、'.join(VEHICLE_CODES)}(也接受 train/car/plane 等英文写法)" + ) diff --git a/src/infra/browser/travel.py b/src/infra/browser/travel.py index 5f32fde..7f4dda5 100644 --- a/src/infra/browser/travel.py +++ b/src/infra/browser/travel.py @@ -7,6 +7,7 @@ from typing import Any from ... import get_logger +from ...core.vehicles import VEHICLE_CODES, canonical_vehicle, vehicle_error from .base import BaseBot, format_date log = get_logger("bot.travel") @@ -93,17 +94,10 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None: details: 报销明细字典(travel_info["reimbursement_details"]),包含 transport_fee、hotel_fee、conference_fee 等子字段。 """ - vehicle_map = { - "火车": "01", - "汽车": "02", - "轮船": "03", - "自带车": "04", - "公务车": "05", - "飞机": "06", - "租车": "07", - "自驾车": "08", - } - + # 交通工具的代码表与别名归一搬到了 src/core/vehicles.py: + # 填报层取 value、体检层判合法、报错层给候选,三处共用一张表。 + # 原来这里 `if vehicle in vehicle_map:` 是静默跳过——英文枚举(train)匹配不上, + # 下拉框停在默认值而本步照样返回成功,等人提交时才被必填拦下(2026-10-21 踩过)。 try: if not bot.open_tab("details"): raise RuntimeError("切不到差旅明细标签页") @@ -113,9 +107,14 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None: bot._wait_for('text="增加明细"', timeout=5000) bot.page.select_option("#cost", "1") bot.page.wait_for_timeout(500) - vehicle = item.get("vehicle_type", "") - if vehicle in vehicle_map: - bot.page.select_option("#jtgj", vehicle_map[vehicle]) + raw_vehicle = item.get("vehicle_type", "") + vehicle = canonical_vehicle(raw_vehicle) + if not vehicle: + # 宁可在这一行停下(截图会跟着截到本步错误),也不要留一张缺交通工具的单子给人提交 + raise ValueError(vehicle_error(raw_vehicle)) + if vehicle != str(raw_vehicle).strip(): + log.info("交通工具 %r 归一为 %s 后选中", raw_vehicle, vehicle) + bot.page.select_option("#jtgj", VEHICLE_CODES[vehicle]) bot.page.fill("#ksdd", item.get("departure_place", "")) bot.page.fill("#jsdd", item.get("arrival_place", "")) diff --git a/src/mcp/server.py b/src/mcp/server.py index af78290..8bc3ae7 100644 --- a/src/mcp/server.py +++ b/src/mcp/server.py @@ -32,6 +32,12 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 5. 依据 totals 与 suggested_invoice_type 综合缓存和匹配结果,产出 travel_info 或 normal_info, 用 mcp__arb__save_info 落盘(会自动跑规则校验);字段拿不准先 mcp__arb__rules_get 对照规则, 或用 mcp__arb__validate_info(draft=...) 不落盘反复试。不完整就向用户要补充材料,禁止自己宣布可提交。 + 报销口径:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单**(明细的会务费培训费栏、 + 会议通知当附件),不为它另开普通报销单,也不因此放弃差旅补助;只有货物类(易耗品/材料/配件) + 才另走普通报销 + 申购单。住宿发票没印入住退房日期时,按会议报到日至返程日推实际晚数。 + 备注/说明类文本要写短:上限按"宽度"算(汉字与全角标点记 2、字母数字记 1), + remark/merchant/attachment_desc ≤100(约 50 个汉字),普通报销说明 ≤40。写超了财务后端不会说"太长了", + 而是在填报时回一记没有提示的 500;save_info 与 browser_fill 都会拦,别拿真单据去试它的上限。 拼 JSON 前先 mcp__arb__info_skeleton(info_type) 取骨架:required 为 true 的键必须存在(含值为空串的备注类 字段),骨架与 validation_rules.json 同源,别凭记忆造结构。 6. 普通发票用 mcp__arb__doc_requisition 生成 Excel 申购单(COM 自动化,需本机装有 Excel; @@ -46,6 +52,7 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。 浏览器归属写在 cases//.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口, 让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases//screenshots/。 + 某步返回 too_long 就是那段文本超宽(见第 5 条),改短后重跑同一 step,别去浏览器里手动点确定。 **提交与提交前后的核对都由用户本人在财务系统里做**:submit_* 这一类工具(代提交、提交前预览) 已被用户整体否决,本项目不提供、也不会计划提供。填报完成就是终点——告诉用户单据编号与 diff --git a/src/mcp/tools/analysis.py b/src/mcp/tools/analysis.py index c566e90..27681de 100644 --- a/src/mcp/tools/analysis.py +++ b/src/mcp/tools/analysis.py @@ -45,6 +45,11 @@ def save_info(case_id: str, info_type: str, data: dict[str, Any]) -> dict[str, A info_type 取 travel|normal。完整性以返回的 validation 为准, 不要自己在 data 里塞 can_submit。 + + 规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1): + 备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到, + 所以在这里就拦住,missing_fields 会写成 + "payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。 """ d = _store.resolve(case_id) if d is None: @@ -89,7 +94,19 @@ def validate_info(case_id: str, info_type: str | None = None, draft: dict[str, A report = validate_extracted_info(data, invoice_type=info_type) payload = _report_dict(report) if groups_path.exists() and info_type == "travel": - payload["note"] = "已存在 invoice_groups.json:混合发票(差旅+普通)只能走普通报销,别按纯差旅提交" + # 财务口径(用户 2026-10-21 定案):因出差发生的会议费/会务费/培训费并入差旅单, + # 只有货物类普通发票才必须另走普通报销——别把前者也说成"只能走普通报销" + goods = _store.goods_general_items(d) + if goods: + payload["note"] = ( + "有货物类普通发票(" + "、".join(goods[:3]) + ")不能并入差旅单:" + "另建 normal_info 走普通报销并生成申购单;差旅单只留车票/住宿/会务费" + ) + else: + payload["note"] = ( + "发票分组已就绪:会议费/会务费/培训费这类因出差发生的普通发票" + "并入差旅单的「会务费培训费」栏即可,会议通知作为附件" + ) return ok(case_id=case_id, info_type=info_type, **payload) diff --git a/src/mcp/tools/browser.py b/src/mcp/tools/browser.py index 096d0a4..d0abb4e 100644 --- a/src/mcp/tools/browser.py +++ b/src/mcp/tools/browser.py @@ -38,6 +38,91 @@ STEP_LABEL: dict[str, str] = { PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"} INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"} +# 附件原始文件名要保住证据原样(不重命名),所以只按一个很宽的口径拦; +# 真被财务系统的列宽截断的话,报的是「文件不存在/上传失败」,和备注那种静默 500 不同。 +FILENAME_WIDTH_LIMIT = 200 + + +def _step_text_items(kind: str, step: str, info: dict[str, Any]) -> list[tuple[str, str, int]]: + """列出这一步会 POST 给财务系统的自由文本:(路径, 内容, 宽度上限)。 + + 为什么要预先量长度:财务后端对「备注/说明」这类列超宽的回答是一记 + 500 Internal Server Error,页面一个字的提示都不给,弹窗停在原地——看上去就像 + 脚本没点着「确定」,排查成本极高(2026-10-21 实测:会务费那行备注 101 字/宽度 171 + 被拒,同一张单 97 字/宽度 130 的交通费备注却存进去了)。本地拦一道,别拿真单据试。 + """ + from ...core.validation.validator import DESCRIPTION_WIDTH_LIMIT, TEXT_WIDTH_LIMIT + + items: list[tuple[str, str, int]] = [] + + def add(path: str, value: Any, limit: int = TEXT_WIDTH_LIMIT) -> None: + if isinstance(value, str) and value.strip(): + items.append((path, value, limit)) + + def sub(key: str) -> dict[str, Any]: + raw = info.get(key) + return raw if isinstance(raw, dict) else {} + + if step == "basic": + basic = sub("basic_info") + if kind == "travel": + add("basic_info.travel_purpose", basic.get("travel_purpose")) + add("basic_info.travel_location", basic.get("travel_location")) + else: + add("basic_info.reimbursement_description", basic.get("reimbursement_description"), DESCRIPTION_WIDTH_LIMIT) + return items + + if step == "details" and kind == "travel": + details = sub("reimbursement_details") + for key in ("transport_fee", "hotel_fee", "conference_fee"): + rows = details.get(key) or [] + if isinstance(rows, list): + for i, row in enumerate(rows): + if isinstance(row, dict): + add(f"reimbursement_details.{key}[{i}].remark", row.get("remark")) + return items + + if step == "payment": + rows = info.get("payment_methods") or [] + if isinstance(rows, list): + for i, row in enumerate(rows): + if isinstance(row, dict): + add(f"payment_methods[{i}].merchant", row.get("merchant")) + add(f"payment_methods[{i}].remark", row.get("remark")) + return items + + if step == "attachments": + rows = info.get("attachments") or [] + if isinstance(rows, list): + for i, row in enumerate(rows): + if not isinstance(row, dict): + continue + desc = str(row.get("attachment_desc") or "").strip() + if not desc: + # 说明留空时 upload_attachment 会兜底成「类型:文件名」,兜底文案同样要量长度 + label = {"invoice": "发票", "payment": "支付凭证", "apply": "事前申请单"}.get( + str(row.get("attachment_type")), "附件" + ) + desc = f"{label}:{row.get('filename', '')}" + add(f"attachments[{i}].attachment_desc", desc) + add(f"attachments[{i}].filename", row.get("filename"), FILENAME_WIDTH_LIMIT) + return items + + return items + + +def _text_limit_problems(kind: str, step: str, info: dict[str, Any]) -> list[str]: + """这一步要提交、但宽度超过财务系统上限的文本(返回可直接给人看的描述)。""" + from ...core.validation.validator import text_width + + problems: list[str] = [] + for path, value, limit in _step_text_items(kind, step, info): + width = text_width(value) + if width > limit: + preview = value[:14] + ("…" if len(value) > 14 else "") + problems.append(f"{path}:宽度 {width} > 上限 {limit}(约 {limit // 2} 个汉字)——「{preview}」") + return problems + def _case_dir(case_id: str) -> Path | None: return _store.resolve(case_id) @@ -169,7 +254,9 @@ def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]: if both: return fail( "会话里同时有 travel_info 与 normal_info,无法判断单据类型", - next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)", + next_action=( + "显式传 kind='travel' 或 'normal'(因出差发生的会议费并入差旅单;货物类普通发票才另走普通报销)" + ), ) if kind is None: return fail( @@ -211,7 +298,12 @@ def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]: def browser_fill(case_id: str, step: str) -> dict[str, Any]: - """执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。""" + """执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。 + + 提交前会量这一步要 POST 的自由文本宽度(汉字算 2、字母数字算 1,上限 100, + 普通报销说明 40):财务后端对超宽只回 500、页面上任何提示都没有,弹窗停在原地 + 看着像"按钮没点着",所以宁可在这里拦下来,失败时 too_long 会列出超长字段。 + """ d = _case_dir(case_id) ctx = _require_open(case_id) if d is None or ctx is None: @@ -242,6 +334,17 @@ def browser_fill(case_id: str, step: str) -> dict[str, Any]: if info is None: return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息") + # 超宽文本先在这里拦住:财务后端只会回 500,页面上什么提示都没有, + # 等到那时候看上去就是「脚本没点着按钮」,得开 F12 才看得懂(见 _step_text_items 注释) + too_long = _text_limit_problems(kind, step, info) + if too_long: + return fail( + f"{STEP_LABEL[step]}有文本超宽,提交上去财务后端会 500 且不报错,已本地拦下", + next_action="把下列字段改短(备注≈50 个汉字以内)后重新 save_info,再重跑本 step", + step=step, + too_long=too_long, + ) + def _fill() -> dict[str, Any]: bot = ctx["bot"] filled = _apply_step(bot, kind, step, info) diff --git a/tests/test_text_limits.py b/tests/test_text_limits.py new file mode 100644 index 0000000..2d9aa72 --- /dev/null +++ b/tests/test_text_limits.py @@ -0,0 +1,266 @@ +"""财务系统文本宽度上限的回归锁 + +踩过的坑(2026-10-21 联机):差旅明细「会务费培训费」那行备注写了 101 个字(GBK 宽度 171), +POST /expen/travel/detailadd 直接 500 Internal Server Error,页面上一个字的提示都没有, +弹窗停在原地——看上去完全像脚本没点着「确定」。同一张单里 97 字(宽度 130)的交通费备注 +却存了进去,说明上限夹在两者之间,而财务后端不肯告诉我们具体数字。 + +这里锁三件事: +1. text_width 的计宽口径(汉字 2、字母数字 1)——上限是按它写的,口径变了数字就没意义; +2. 体检(validate_extracted_info)在落盘阶段就拦住超宽文本,并给出可读原因; +3. 填报(browser_fill)在真的 POST 之前再拦一道,免得拿真单据去撞 500。 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.core.validation.validator import ( + DESCRIPTION_WIDTH_LIMIT, + TEXT_WIDTH_LIMIT, + reload_validation_rules, + text_width, + validate_extracted_info, +) +from src.mcp.tools import browser + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# 与实单同样形状、曾经真的把后端打挂的那句话 +LONG_REMARK = "2026年全国大学生电子设计竞赛赛区赛及TI杯模拟电子系统设计专题赛赛题解析与技术交流研讨会会议费1500元/人" + + +def _travel_info(**overrides: Any) -> dict[str, Any]: + info: dict[str, Any] = { + "basic_info": { + "travel_purpose": "参加电子设计竞赛研讨会", + "travel_location": "长沙", + "start_date": "2026-10-16", + "end_date": "2026-10-19", + }, + "reimbursement_details": { + "transport_fee": [ + { + "vehicle_type": "火车", + "start_date": "2026-10-16", + "end_date": "2026-10-16", + "departure_place": "阜阳", + "arrival_place": "长沙", + "amount": 455.5, + "bill_count": 2, + "remark": "王建锋赴长沙参会车票2张", + } + ], + "hotel_fee": [ + { + "checkin_date": "2026-10-16", + "checkout_date": "2026-10-19", + "days": 3, + "person_count": 1, + "invoice_amount": 1000.56, + "reimburse_amount": 1000.56, + "remark": "王建锋长沙住宿3晚", + } + ], + "conference_fee": [{"bill_count": 1, "amount": 1500.0, "remark": "研讨会会议费1500元/人"}], + }, + "payment_methods": [ + { + "card_date": "2026-10-16", + "card_amount": 1500.0, + "merchant": "天津市易科捷科技有限公司", + "remark": "财付通付会议费", + } + ], + "subsidy_list": [ + { + "person_id": "202407021", + "person_name": "王建锋", + "start_date": "2026-10-16", + "end_date": "2026-10-19", + "days": 4, + } + ], + "attachments": [ + {"filename": "出差事前申请.pdf", "attachment_type": "other", "attachment_desc": "出差事前申请单"} + ], + } + info.update(overrides) + return info + + +def _normal_info(desc: str = "元器件采购") -> dict[str, Any]: + return { + "basic_info": {"reimbursement_description": desc}, + "reimbursement_details": {"total_invoices": 1, "total_amount": 100.0}, + "payment_methods": [ + {"card_date": "2026-10-16", "card_amount": 100.0, "merchant": "某公司", "remark": "采购付款"} + ], + "attachments": [{"filename": "发票.pdf", "attachment_type": "invoice"}], + } + + +# ------------------------------------------------------------------ +# 计宽口径 +# ------------------------------------------------------------------ + + +def test_text_width_counts_cjk_as_two() -> None: + assert text_width("汉字") == 4 + assert text_width("ab12") == 4 + assert text_width("票1500元") == 2 + 4 + 2 + assert text_width(",") == 2 # 全角标点也占两个宽度 + assert text_width("😀") >= 2 # GBK 编不出的按 UTF-8 估,宁可高估 + + +# ------------------------------------------------------------------ +# 体检阶段就要拦住 +# ------------------------------------------------------------------ + + +def test_travel_info_passes_with_short_remarks() -> None: + report = validate_extracted_info(_travel_info(), invoice_type="travel") + assert report.valid, report.missing_fields + + +def test_overlong_conference_remark_is_rejected_with_reason() -> None: + info = _travel_info() + assert text_width(info["reimbursement_details"]["conference_fee"][0]["remark"]) < TEXT_WIDTH_LIMIT + info["reimbursement_details"]["conference_fee"][0]["remark"] = LONG_REMARK + report = validate_extracted_info(info, invoice_type="travel") + assert not report.valid + hit = [m for m in report.missing_fields if "conference_fee" in m and "remark" in m] + assert hit, report.missing_fields + # 只报「缺字段」不够:超宽不是缺失,得说清是太长、上限多少 + assert "过长" in hit[0] and str(TEXT_WIDTH_LIMIT) in hit[0] + + +def test_overlong_payment_and_transport_remarks_are_rejected() -> None: + info = _travel_info() + info["payment_methods"][0]["remark"] = LONG_REMARK + info["reimbursement_details"]["transport_fee"][0]["remark"] = LONG_REMARK + report = validate_extracted_info(info, invoice_type="travel") + assert not report.valid + joined = "|".join(report.missing_fields) + assert "payment_methods[0].remark" in joined + assert "transport_fee[0].remark" in joined + + +def test_normal_description_limit_is_stricter_than_remark() -> None: + assert DESCRIPTION_WIDTH_LIMIT == 40 + assert validate_extracted_info(_normal_info("元器件采购"), invoice_type="normal").valid + # 21 个汉字 = 宽度 42 超上限;20 个汉字刚好能过 + assert not validate_extracted_info(_normal_info("件" * 21), invoice_type="normal").valid + assert validate_extracted_info(_normal_info("件" * 20), invoice_type="normal").valid + + +def test_optional_arrays_and_desc_may_be_absent() -> None: + """min_items=0 / required=false 的段落整段缺省不该报缺失(纯车票差旅是合法单据)。""" + info = _travel_info() + del info["reimbursement_details"]["hotel_fee"] + del info["reimbursement_details"]["conference_fee"] + del info["attachments"][0]["attachment_desc"] + report = validate_extracted_info(info, invoice_type="travel") + assert report.valid, report.missing_fields + + +def test_overlong_attachment_desc_is_rejected() -> None: + info = _travel_info() + info["attachments"][0]["attachment_desc"] = LONG_REMARK + report = validate_extracted_info(info, invoice_type="travel") + assert not report.valid + assert any("attachment_desc" in m and "过长" in m for m in report.missing_fields) + + +# ------------------------------------------------------------------ +# 规则文件本身的锁 +# ------------------------------------------------------------------ + + +def test_rules_file_declares_limits_for_every_posted_text() -> None: + """填报时要 POST 的每个自由文本,规则文件里都得有 max_len_bytes。 + + 少一个就等于把那个字段裸奔交给财务后端——它的答复是 500,不带原因。 + """ + reload_validation_rules() + rules = json.loads((REPO_ROOT / "config" / "validation_rules.json").read_text(encoding="utf-8")) + + def limits_of(kind: str) -> dict[str, int]: + out: dict[str, int] = {} + section = rules[kind] + for rule in section.get("fields", []): + if rule.get("max_len_bytes"): + out[".".join(rule["path"])] = int(rule["max_len_bytes"]) + for arr in section.get("arrays", []): + for ef in arr.get("element_fields", []): + if isinstance(ef, dict) and ef.get("max_len_bytes"): + out[f"{'.'.join(arr['path'])}[].{'.'.join(ef['path'])}"] = int(ef["max_len_bytes"]) + return out + + travel = limits_of("travel") + normal = limits_of("normal") + for key in ( + "basic_info.travel_purpose", + "reimbursement_details.transport_fee[].remark", + "reimbursement_details.hotel_fee[].remark", + "reimbursement_details.conference_fee[].remark", + "payment_methods[].remark", + "payment_methods[].merchant", + "attachments[].attachment_desc", + ): + assert key in travel, f"travel 规则里 {key} 没有长度上限" + for key in ( + "basic_info.reimbursement_description", + "payment_methods[].remark", + "payment_methods[].merchant", + "attachments[].attachment_desc", + ): + assert key in normal, f"normal 规则里 {key} 没有长度上限" + # 住宿/会务费明细必须在规则里(否则那两栏的备注没人管),且允许整段缺省 + arrays = {tuple(a["path"]): a.get("min_items") for a in rules["travel"]["arrays"]} + assert arrays[("reimbursement_details", "hotel_fee")] == 0 + assert arrays[("reimbursement_details", "conference_fee")] == 0 + + +# ------------------------------------------------------------------ +# 填报前的第二道闸(真的 POST 之前) +# ------------------------------------------------------------------ + + +def test_browser_preflight_blocks_overlong_detail_remark() -> None: + info = _travel_info() + assert browser._text_limit_problems("travel", "details", info) == [] + info["reimbursement_details"]["conference_fee"][0]["remark"] = LONG_REMARK + problems = browser._text_limit_problems("travel", "details", info) + assert len(problems) == 1 + assert "conference_fee[0].remark" in problems[0] + assert str(TEXT_WIDTH_LIMIT) in problems[0] + + +def test_browser_preflight_covers_each_step_text() -> None: + long_basic = {"basic_info": {"travel_purpose": LONG_REMARK, "travel_location": "长沙"}} + assert any("travel_purpose" in p for p in browser._text_limit_problems("travel", "basic", long_basic)) + + pay = { + "payment_methods": [ + {"card_date": "2026-10-16", "card_amount": 1.0, "merchant": "小商户", "remark": LONG_REMARK} + ] + } + assert any("payment_methods[0].remark" in p for p in browser._text_limit_problems("travel", "payment", pay)) + assert any("payment_methods[0].remark" in p for p in browser._text_limit_problems("normal", "payment", pay)) + + # 说明留空时兜底成「发票:文件名」,兜底文案超宽一样拦 + att = {"attachments": [{"filename": "关于举办" + "赛" * 60 + ".pdf", "attachment_type": "invoice"}]} + problems = browser._text_limit_problems("travel", "attachments", att) + assert any("attachment_desc" in p for p in problems), problems + + # 普通报销的说明口径更短 + desc = {"basic_info": {"reimbursement_description": "件" * 21}} + assert any("reimbursement_description" in p for p in browser._text_limit_problems("normal", "basic", desc)) + + +def test_browser_preflight_ignores_steps_without_text() -> None: + assert browser._text_limit_problems("travel", "subsidy", _travel_info()) == [] + assert browser._text_limit_problems("normal", "details", _normal_info()) == [] diff --git a/tests/test_vehicles_and_scope.py b/tests/test_vehicles_and_scope.py new file mode 100644 index 0000000..d634196 --- /dev/null +++ b/tests/test_vehicles_and_scope.py @@ -0,0 +1,213 @@ +"""交通工具代码表与「哪些普通发票并入差旅单」的回归锁 + +两条都是 2026-10-21 实单踩出来的: + +1. `vehicle_type` 填英文枚举(参考文档历来写 `train`)时,老写法 `if vehicle in vehicle_map:` + 会**静默不选**下拉框,本步还返回成功——人提交时才被必填拦下,看着像"脚本没点着按钮"。 + 现在别名归一(train→火车),认不出的值直接报错。 +2. `case_status`/`validate_info`/`browser_new_form` 三处以前一律说"混合发票只能走普通报销", + 把「会议费并入差旅单」这条合法路径说成错的(用户 2026-10-21 定案:差旅 + 会议费走差旅单)。 + 口径判定收进 `src/core/expense_kind.py` 一处。 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from src.cases import CaseStore +from src.core.expense_kind import goods_invoices, is_conference_like +from src.core.validation.validator import validate_extracted_info +from src.core.vehicles import VEHICLE_CODES, canonical_vehicle, is_valid_vehicle, vehicle_code, vehicle_error +from src.infra.browser import travel as travel_flow + +# ------------------------------------------------------------------ +# 假 bot:只记录 select_option / fill,不开真浏览器 +# ------------------------------------------------------------------ + + +class FakePage: + def __init__(self) -> None: + self.options: list[tuple[str, str]] = [] + self.fills: list[tuple[str, str]] = [] + + def click(self, selector: str, timeout: int | None = None) -> None: + return None + + def select_option(self, selector: str, value: str) -> None: + self.options.append((selector, value)) + + def fill(self, selector: str, value: str) -> None: + self.fills.append((selector, value)) + + def wait_for_timeout(self, ms: int) -> None: + return None + + +class FakeBot: + def __init__(self) -> None: + self.page = FakePage() + + def open_tab(self, name: str) -> bool: + return True + + def _wait_for(self, selector: str, timeout: int | None = None) -> None: + return None + + def _screenshot(self, name: str = "") -> str: + return "" + + +def _details(vehicle_type: str) -> dict[str, Any]: + return { + "transport_fee": [ + { + "vehicle_type": vehicle_type, + "start_date": "2026-10-16", + "end_date": "2026-10-16", + "departure_place": "阜阳", + "arrival_place": "长沙", + "amount": 455.5, + "bill_count": 2, + "remark": "去程车票2张", + } + ], + "hotel_fee": [], + "conference_fee": [], + } + + +# ------------------------------------------------------------------ +# 代码表与别名 +# ------------------------------------------------------------------ + + +def test_canonical_vehicle_accepts_chinese_and_aliases() -> None: + assert canonical_vehicle("火车") == "火车" + assert canonical_vehicle(" 火车 ") == "火车" # 手滑多敲的空格不该变成"认不出" + assert canonical_vehicle("train") == "火车" + assert canonical_vehicle("TRAIN") == "火车" + assert canonical_vehicle("self-drive") == "自驾车" + assert canonical_vehicle("plane") == "飞机" + assert canonical_vehicle("高铁") == "火车" + assert canonical_vehicle("official_car") == "公务车" + + +def test_canonical_vehicle_rejects_unknown() -> None: + assert canonical_vehicle("地铁") == "" + assert canonical_vehicle("") == "" + assert canonical_vehicle(None) == "" + assert not is_valid_vehicle("地铁") + assert not is_valid_vehicle("自行车") # 财务系统没这个选项,别臆造 + assert "自行车" in vehicle_error("自行车") + assert "火车" in vehicle_error("自行车") # 报错要带上能填什么 + + +def test_vehicle_codes_are_the_finance_dropdown() -> None: + """下拉框 value 别顺手重排:01..08 是财务系统页面上的顺序。""" + assert VEHICLE_CODES == { + "火车": "01", + "汽车": "02", + "轮船": "03", + "自带车": "04", + "公务车": "05", + "飞机": "06", + "租车": "07", + "自驾车": "08", + } + assert vehicle_code("train") == "01" + assert vehicle_code("地铁") == "" + + +def test_filler_selects_dropdown_for_alias_and_raises_for_unknown() -> None: + bot = FakeBot() + travel_flow.add_travel_items(bot, _details("train")) + assert ("#jtgj", "01") in bot.page.options, bot.page.options # 英文枚举也得真的选中 + + bot2 = FakeBot() + travel_flow.add_travel_items(bot2, _details("火车")) + assert ("#jtgj", "01") in bot2.page.options + + bot3 = FakeBot() + with pytest.raises(ValueError, match="火车"): # 认不出就停下,别静默留默认值 + travel_flow.add_travel_items(bot3, _details("地铁")) + + +def test_validator_rejects_vehicle_that_cannot_be_selected() -> None: + info: dict[str, Any] = { + "basic_info": { + "travel_purpose": "参加研讨会", + "travel_location": "长沙", + "start_date": "2026-10-16", + "end_date": "2026-10-19", + }, + "reimbursement_details": {"transport_fee": _details("train")["transport_fee"]}, + "payment_methods": [ + {"card_date": "2026-10-11", "card_amount": 455.5, "merchant": "中国铁路", "remark": "去程车票2张"} + ], + "subsidy_list": [ + { + "person_id": "202407021", + "person_name": "王建锋", + "start_date": "2026-10-16", + "end_date": "2026-10-19", + "days": 4, + } + ], + "attachments": [], + } + assert validate_extracted_info(info, invoice_type="travel").valid, "train 是合法写法(会被归一成火车)" + info["reimbursement_details"]["transport_fee"][0]["vehicle_type"] = "地铁" + report = validate_extracted_info(info, invoice_type="travel") + assert not report.valid + assert any("vehicle_type" in m for m in report.missing_fields) + + +# ------------------------------------------------------------------ +# 报销口径:会议费并入差旅单 +# ------------------------------------------------------------------ + + +def test_conference_like_invoices_merge_into_travel() -> None: + assert is_conference_like({"item_name": "会议费(*生产生活服务*会议费)"}) + assert is_conference_like({"item_name": "培训费"}) + assert is_conference_like({"item_name": "住宿服务", "remark": "会务费由易科捷代收"}) + assert not is_conference_like({"item_name": "电子元器件(*生产生活服务*)"}) + assert not is_conference_like({"item_name": "低值易耗品"}) + + +def test_goods_invoices_are_the_only_ones_needing_own_form() -> None: + general = [ + {"item_name": "会议费", "_source_file": "a.pdf"}, + {"item_name": "五金配件", "_source_file": "b.pdf"}, + ] + goods = goods_invoices(general) + assert len(goods) == 1 + assert goods[0]["_source_file"] == "b.pdf" + + +def test_case_status_lists_only_goods_invoices(tmp_path: Path) -> None: + """case_status 的提醒只针对货物类——别再把会议费说成"只能走普通报销"。""" + store = CaseStore() + groups = { + "travel": [{"invoice_type": "train"}], + "general": [{"item_name": "会议费(*生产生活服务*会议费)", "_source_file": "hui.pdf"}], + } + (tmp_path / "invoice_groups.json").write_text(json.dumps(groups, ensure_ascii=False), encoding="utf-8") + assert store.goods_general_items(tmp_path) == [] + + groups["general"].append({"item_name": "低值易耗品", "_source_file": "huo.pdf"}) + (tmp_path / "invoice_groups.json").write_text(json.dumps(groups, ensure_ascii=False), encoding="utf-8") + assert store.goods_general_items(tmp_path) == ["低值易耗品"] + + +def test_case_status_survives_broken_groups_file(tmp_path: Path) -> None: + """派生产物读坏了不能把状态查询拖崩(它只该少一条提示)。""" + broken = "{不是 JSON" + (tmp_path / "invoice_groups.json").write_text(broken, encoding="utf-8") + assert CaseStore().goods_general_items(tmp_path) == [] + # 只读不写:坏了的派生产物留给 case_reset 处理,校验路径不去动它 + assert (tmp_path / "invoice_groups.json").read_text(encoding="utf-8") == broken