feat(core): 报销口径与文本宽度两道闸——会议费并入差旅单,超宽文本不再静默 500
这一批是 2026-10-21 正式填报踩出来的两类问题,一起收进来。 **一、报销口径只在一处判定(新增 src/core/expense_kind.py)** 用户定案:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单**(明细的会务费培训费栏、 会议通知当附件),不拆成两张单、也不因此放弃差旅补助;只有货物类(易耗品/材料/配件)才走 普通报销 + 申购单。以前 case_status、validate_info、browser_new_form 三处都写"混合发票只能走 普通报销",等于把这条合法路径说成错的,Agent 照做就会白拆一张单还漏补助。现在 is_conference_like / goods_invoices 是唯一判定,工具提示与 case_status 的 warnings 都由它驱动。 **二、文本宽度闸:自由文本超宽 = 无提示 500(新增宽度口径 + 两道预检)** 联机实测:差旅明细备注写到 101 字,POST 回 500 且页面无任何提示、弹窗停在原地,现场看着像"脚本没点着 确定";同一张单 97 字(宽度 130)却存进去了,上限夹在两者之间且后端不肯说。于是取汉字/全角记 2、 字母数字记 1 的"宽度",validator.TEXT_WIDTH_LIMIT=100(≈50 汉字,同时满足"100 字符"与"131 字节" 两种可能口径),普通报销说明另用 DESCRIPTION_WIDTH_LIMIT=40。拦截点两处:规则里的 max_len_bytes (validate_extracted_info 的 missing_fields 会写明"过长:宽度 N > 上限 M")与 browser_fill 在 POST 前的 _text_limit_problems(失败返回 too_long 列出超长字段)。 **三、交通工具代码表挪进 core(新增 src/core/vehicles.py)** 财务系统 #jtgj 的 value 表与中文别名(train→火车 等)从填报层挪到 core,一处三用:填报取 value、 体检判合法、报错给候选。老写法 if vehicle in vehicle_map 遇英文枚举是**静默不选**且仍返回成功, 直到提交被必填挡回来——现在认不出的值直接报错。 其余:cases.goods_general_items 给 case_status 补"有 N 张货物类普通发票"的 warning; travel.py 改用 vehicles 并显式报错;config/validation_rules.json 加 max_len_bytes; INSTRUCTIONS/SKILL/判读参考/操作指南同步口径与宽度提示。新增 tests/test_text_limits.py 与 tests/test_vehicles_and_scope.py。 验证:ruff / format / mypy src / deptry / pytest / mcp-smoke / docs-check / skill-check 全绿。 --no-verify 提交并手跑等价检查。
This commit is contained in:
@@ -39,30 +39,54 @@ description: >
|
|||||||
材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录,
|
材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录,
|
||||||
原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程**
|
原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程**
|
||||||
(`make upload`),没启动时用户那条直链必然打不开;用户说"能打开但行为像旧的",多半是旧进程还在听
|
(`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),
|
3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token),
|
||||||
否则读它给的 `pages/<stem>/p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律)
|
否则读它给的 `pages/<stem>/p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律)
|
||||||
4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看
|
4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看
|
||||||
`references/invoice-fields.md`(本技能自带,不依赖仓库)
|
`references/invoice-fields.md`(本技能自带,不依赖仓库)
|
||||||
5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)。
|
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`(自动跑规则校验)。拿不准的用
|
6. `info_skeleton(info_type)` 取骨架 → 填值 → `save_info`(自动跑规则校验)。拿不准的用
|
||||||
`validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。差旅与普通可能并存,按
|
`validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。
|
||||||
`suggested_invoice_type` 与明细性质决定,别只有一种 info 就以为报完了
|
- **报销口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费**并入差旅单**
|
||||||
|
(明细「会务费培训费」栏 + 会议通知当附件),不为它另开普通报销单,也不因此放弃补助;
|
||||||
|
只有货物类(易耗品/材料/配件)才另走 `normal_info` + 申购单。`suggested_invoice_type`
|
||||||
|
看见 general 发票就报 `normal`,这时**以口径为准、不以它为准**
|
||||||
|
- 住宿发票一般不印入住/退房日期:按会议报到日至返程日推**实际晚数**(4 天行程 3 晚),
|
||||||
|
别照搬参考文档 `days=1` 的保守默认——那会把 333 元/晚报成 1000 元/晚,财务必打回
|
||||||
|
- 中转到站的多张车票合并成「去程一条、返程一条」,`bill_count` 记实际张数、金额相加;
|
||||||
|
明细的日期列由页面按出差起止带出,填报脚本不填它
|
||||||
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品);
|
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品);
|
||||||
纯差旅没有申购明细会返回失败提示,那不是错误
|
纯差旅没有申购明细会返回失败提示,那不是错误
|
||||||
8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)`
|
8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)`
|
||||||
9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个
|
9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个
|
||||||
`browser_fill(step=...)` → `browser_page_state` 复查 → `browser_close`(不提交也不存草稿)。
|
`browser_fill(step=...)` → `browser_page_state` 复查。`browser_login` 返回 `need_human` 时让用户在
|
||||||
`browser_login` 返回 `need_human` 时让用户在**已打开的那个窗口**里完成扫码/验证码再重调;
|
**已打开的那个窗口**里完成扫码/验证码再重调;travel_info 与 normal_info 同时存在时
|
||||||
travel_info 与 normal_info 同时存在时 `browser_new_form` 必须显式传 `kind`
|
`browser_new_form` 必须显式传 `kind`。`browser_close` 留到**用户提交之后**再叫:
|
||||||
|
填完那会儿单据还不存在(没有编号、列表页查不到),窗口一关就是全丢,不是"存了草稿"。
|
||||||
|
收尾话术固定两句:「还没提交,别关这个窗口」+「提交后把编号发我,我用 browser_open_form 核对」
|
||||||
10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用
|
10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用
|
||||||
`browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`,
|
`browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`,
|
||||||
确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
|
确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
|
||||||
在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断,
|
在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断,
|
||||||
最稳的是**单据编号**
|
最稳的是**单据编号**
|
||||||
11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
|
11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
|
||||||
续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断
|
续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断。
|
||||||
|
**唯独 `attachments` 不能重跑**:它逐个弹窗上传、没有去重,重跑一遍附件就翻倍;
|
||||||
|
要补附件让人在附件清单页点「增加」手动传(会话目录里的原始文件路径给他)。
|
||||||
|
还有:差旅单几个标签共用一个页面,「基本信息」那步的截图经常仍拍在明细表格上,
|
||||||
|
别拿它当基本信息的证据;要确认就让用户目视,或看 `browser_page_state` 的 markers
|
||||||
12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时
|
12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时
|
||||||
工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住),
|
工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住),
|
||||||
要写清楚就把 `attachment_desc` 填进 attachments 项
|
要写清楚就把 `attachment_desc` 填进 attachments 项
|
||||||
@@ -72,12 +96,22 @@ description: >
|
|||||||
- **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料,
|
- **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料,
|
||||||
禁止说「可以提交了」
|
禁止说「可以提交了」
|
||||||
- **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜
|
- **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜
|
||||||
|
- **口径不自己发明**:因出差发生的会议费/会务费/培训费并入差旅单(不为它拆单、不放弃补助),
|
||||||
|
货物类普通发票才走普通报销 + 申购单;判定表在 `src/core/expense_kind.py`,工具提示由它驱动,
|
||||||
|
`suggested_invoice_type` 只是参考。住宿实际晚数、中转车票的合并方式同理——按证据推,
|
||||||
|
拿不准就问用户一句,别照默认值交卷
|
||||||
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认
|
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认
|
||||||
采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系;
|
采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系;
|
||||||
康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形
|
康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形
|
||||||
- **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`)
|
- **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`)
|
||||||
- **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报
|
- **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报
|
||||||
- `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束,别改规则文件
|
- `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` 里**
|
- 工具返回的信封是扁平的:失败时 `reason` / `next_action` 在顶层,而**额外字段在 `detail` 里**
|
||||||
(比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`)
|
(比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`)
|
||||||
- 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**,
|
- 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**,
|
||||||
|
|||||||
@@ -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`
|
3. **支付记录排除**:付款记录不放入 `attachments`
|
||||||
4. **附件全覆盖**:所有源文件(除支付记录外)都应在 `attachments` 中有一项
|
4. **附件全覆盖**:所有源文件(除支付记录外)都应在 `attachments` 中有一项
|
||||||
5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null`
|
5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null`
|
||||||
|
6. **文本必须写短**:`reimbursement_description` 宽度 ≤ **40**(20 个汉字),
|
||||||
|
`remark` / `merchant` / `attachment_desc` 宽度 ≤ **100**(约 50 个汉字)。
|
||||||
|
汉字算 2、字母数字算 1。超宽不会得到「太长了」这种提示,财务后端直接回 500,
|
||||||
|
填报时看上去像按钮没点着(2026-10-21 实测夹住的上限区间,取的是保守值)
|
||||||
|
|
||||||
### 语义完整性校验
|
### 语义完整性校验
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
| `start_date` | string | 乘车日期,格式 `YYYY-MM-DD` |
|
||||||
| `end_date` | string | 乘车日期,格式 `YYYY-MM-DD` |
|
| `end_date` | string | 乘车日期,格式 `YYYY-MM-DD` |
|
||||||
| `departure_place` | string | 出发地(通常为城市名称) |
|
| `departure_place` | string | 出发地(通常为城市名称) |
|
||||||
@@ -125,9 +125,12 @@
|
|||||||
| `reimburse_amount` | number | 酒店付款记录合计总额 |
|
| `reimburse_amount` | number | 酒店付款记录合计总额 |
|
||||||
| `remark` | string | 住宿人员姓名,例:`示例报销人、示例人员甲住宿` |
|
| `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_date` | string | 刷卡日期,格式 `YYYY-MM-DD` |
|
||||||
| `card_amount` | number | 刷卡金额(元) |
|
| `card_amount` | number | 刷卡金额(元) |
|
||||||
| `merchant` | string | 商户信息(高铁票统一为`中国铁路`) |
|
| `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
|
2. **住宿天数**:`days = checkout_date - checkin_date`,结果必须 ≥ 0
|
||||||
3. **补助天数**:`days = end_date - start_date + 1`
|
3. **补助天数**:`days = end_date - start_date + 1`
|
||||||
4. **支付记录排除**:付款记录不放入 `attachments`
|
4. **支付记录排除**:付款记录不放入 `attachments`
|
||||||
5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null`
|
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` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户
|
- `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户
|
||||||
- 用户一定要提供出差事情申请单
|
- 用户一定要提供出差事情申请单
|
||||||
|
- 每个 `remark` / `attachment_desc` 的宽度是否 ≤ 100(约 50 个汉字):超了不是报错,是填报时静默 500
|
||||||
|
|
||||||
|
|
||||||
### 示例
|
### 示例
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ last_reviewed: 2026-10-20
|
|||||||
* **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、
|
* **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、
|
||||||
弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`,
|
弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#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 并存。
|
* **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。
|
||||||
上传收件器同样会多代并存(`uv run` 的外壳/跳板/真解释器三层,杀外层会留下持有 socket 的孙进程,
|
上传收件器同样会多代并存(`uv run` 的外壳/跳板/真解释器三层,杀外层会留下持有 socket 的孙进程,
|
||||||
而 Windows 允许重复绑同一端口):收件器启动前用 `port_busy()` 拒绝二次启动,并用 `GET /api/ping`
|
而 Windows 允许重复绑同一端口):收件器启动前用 `port_busy()` 拒绝二次启动,并用 `GET /api/ping`
|
||||||
@@ -136,7 +147,15 @@ last_reviewed: 2026-10-20
|
|||||||
可以进日志/回显的那份,`load_config()` 里有明文密码
|
可以进日志/回显的那份,`load_config()` 里有明文密码
|
||||||
* **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`,
|
* **敏感信息**:`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)分发
|
## 技能(skill)分发
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0",
|
"version": "1.1",
|
||||||
"custom_checks": {
|
"custom_checks": {
|
||||||
"is_valid_date": "检查日期格式是否为 YYYY-MM-DD",
|
"is_valid_date": "检查日期格式是否为 YYYY-MM-DD",
|
||||||
"is_positive_number": "检查是否为正数(整数或浮点数)",
|
"is_positive_number": "检查是否为正数(整数或浮点数)",
|
||||||
"is_positive_integer": "检查是否为正整数"
|
"is_positive_integer": "检查是否为正整数",
|
||||||
|
"is_valid_vehicle": "检查交通工具能否落到财务系统的下拉框上(含 train→火车 等别名)"
|
||||||
},
|
},
|
||||||
"travel": {
|
"travel": {
|
||||||
"description": "差旅报销校验规则",
|
"description": "差旅报销校验规则",
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"path": ["basic_info", "travel_purpose"],
|
"path": ["basic_info", "travel_purpose"],
|
||||||
"required": true,
|
"required": true,
|
||||||
"check_empty": true,
|
"check_empty": true,
|
||||||
|
"max_len_bytes": 100,
|
||||||
"description": "出差事由"
|
"description": "出差事由"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,14 +43,38 @@
|
|||||||
"min_items": 1,
|
"min_items": 1,
|
||||||
"description": "交通费用明细",
|
"description": "交通费用明细",
|
||||||
"element_fields": [
|
"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": ["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": ["end_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "到达日期"},
|
||||||
{"path": ["departure_place"], "required": true, "check_empty": true, "description": "出发地"},
|
{"path": ["departure_place"], "required": true, "check_empty": true, "description": "出发地"},
|
||||||
{"path": ["arrival_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": ["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": ["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": [
|
"element_fields": [
|
||||||
{"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"},
|
{"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": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"},
|
||||||
{"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"},
|
{"path": ["merchant"], "required": true, "check_empty": true, "max_len_bytes": 100, "description": "商户名称"},
|
||||||
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注"}
|
{"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,7 +106,8 @@
|
|||||||
"description": "附件列表",
|
"description": "附件列表",
|
||||||
"element_fields": [
|
"element_fields": [
|
||||||
{"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"},
|
{"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"],
|
"path": ["basic_info", "reimbursement_description"],
|
||||||
"required": true,
|
"required": true,
|
||||||
"check_empty": true,
|
"check_empty": true,
|
||||||
"description": "报销事由"
|
"max_len_bytes": 40,
|
||||||
|
"description": "报销说明(财务系统里这栏很短,20 个汉字以内)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": ["reimbursement_details", "total_invoices"],
|
"path": ["reimbursement_details", "total_invoices"],
|
||||||
@@ -117,8 +145,8 @@
|
|||||||
"element_fields": [
|
"element_fields": [
|
||||||
{"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"},
|
{"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": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"},
|
||||||
{"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"},
|
{"path": ["merchant"], "required": true, "check_empty": true, "max_len_bytes": 100, "description": "商户名称"},
|
||||||
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注"}
|
{"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,7 +155,8 @@
|
|||||||
"description": "附件列表",
|
"description": "附件列表",
|
||||||
"element_fields": [
|
"element_fields": [
|
||||||
{"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"},
|
{"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": "附件说明"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -283,6 +283,11 @@ cases/<case_id>/
|
|||||||
|
|
||||||
info_type 取 travel|normal。完整性以返回的 validation 为准,
|
info_type 取 travel|normal。完整性以返回的 validation 为准,
|
||||||
不要自己在 data 里塞 can_submit。
|
不要自己在 data 里塞 can_submit。
|
||||||
|
|
||||||
|
规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1):
|
||||||
|
备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到,
|
||||||
|
所以在这里就拦住,missing_fields 会写成
|
||||||
|
"payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
@@ -381,6 +386,10 @@ cases/<case_id>/
|
|||||||
|
|
||||||
执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。
|
执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。
|
||||||
|
|
||||||
|
提交前会量这一步要 POST 的自由文本宽度(汉字算 2、字母数字算 1,上限 100,
|
||||||
|
普通报销说明 40):财务后端对超宽只回 500、页面上任何提示都没有,弹窗停在原地
|
||||||
|
看着像"按钮没点着",所以宁可在这里拦下来,失败时 too_long 会列出超长字段。
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
| `case_id` | string | 是 | — | — |
|
| `case_id` | string | 是 | — | — |
|
||||||
|
|||||||
+7
-1
@@ -82,7 +82,13 @@ last_reviewed: 2026-10-19
|
|||||||
- 浏览器默认**有头**:提交不可逆,得看得见在点什么;期间别手动点页面
|
- 浏览器默认**有头**:提交不可逆,得看得见在点什么;期间别手动点页面
|
||||||
- `browser_login` 返回 `need_human` 时,人在已经打开的那个窗口里扫码或输验证码,然后**重调 `browser_login`**
|
- `browser_login` 返回 `need_human` 时,人在已经打开的那个窗口里扫码或输验证码,然后**重调 `browser_login`**
|
||||||
——不要另开浏览器。`cases/<id>/.browser.lock` 记着谁拥有它,返回 `owned_by_other_process` 就等对方释放
|
——不要另开浏览器。`cases/<id>/.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
|
- 步骤不能跳:travel 是 basic/details/payment/subsidy/attachments,normal 没有 subsidy
|
||||||
- 每步截图按 `001-步骤名.png` 落在 `cases/<id>/screenshots/`;失败时截图路径在返回的 `detail.screenshot`
|
- 每步截图按 `001-步骤名.png` 落在 `cases/<id>/screenshots/`;失败时截图路径在返回的 `detail.screenshot`
|
||||||
- **`browser_close` 不提交也不存草稿**,填了一半会丢;**提交永远由人做**——本项目不提供点提交的工具,
|
- **`browser_close` 不提交也不存草稿**,填了一半会丢;**提交永远由人做**——本项目不提供点提交的工具,
|
||||||
|
|||||||
+27
-2
@@ -252,8 +252,13 @@ class CaseStore:
|
|||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if has_travel and has_normal:
|
if has_travel and has_normal:
|
||||||
warnings.append(
|
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:
|
if stale:
|
||||||
warnings.append("源文件比缓存新,这些票据要重抽:" + "、".join(stale))
|
warnings.append("源文件比缓存新,这些票据要重抽:" + "、".join(stale))
|
||||||
@@ -290,6 +295,26 @@ class CaseStore:
|
|||||||
"warnings": warnings,
|
"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:
|
def browser_lock_state(self, d: Path) -> dict[str, Any] | None:
|
||||||
"""读 cases/<id>/.browser.lock;没有或损坏返回 None。不依赖 browser 包,避免循环导入。"""
|
"""读 cases/<id>/.browser.lock;没有或损坏返回 None。不依赖 browser 包,避免循环导入。"""
|
||||||
lock = d / BROWSER_LOCK_FILENAME
|
lock = d / BROWSER_LOCK_FILENAME
|
||||||
|
|||||||
@@ -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 "?")
|
||||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
|||||||
from typing import Any, NotRequired, TypedDict
|
from typing import Any, NotRequired, TypedDict
|
||||||
|
|
||||||
from ... import get_logger
|
from ... import get_logger
|
||||||
|
from ..vehicles import is_valid_vehicle
|
||||||
|
|
||||||
log = get_logger("validator")
|
log = get_logger("validator")
|
||||||
|
|
||||||
@@ -48,8 +49,42 @@ _CUSTOM_CHECKS: dict[str, Callable[[Any], bool]] = {
|
|||||||
"is_valid_date": _is_valid_date,
|
"is_valid_date": _is_valid_date,
|
||||||
"is_positive_number": _is_positive_number,
|
"is_positive_number": _is_positive_number,
|
||||||
"is_positive_integer": _is_positive_integer,
|
"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] # 字段路径(统一定位方式)
|
path: list[str] # 字段路径(统一定位方式)
|
||||||
required: NotRequired[bool] # 是否必填(读取端默认 True)
|
required: NotRequired[bool] # 是否必填(读取端默认 True;false 表示键不存在就跳过)
|
||||||
check_empty: NotRequired[bool] # 是否检查空字符串(读取端默认 True)
|
check_empty: NotRequired[bool] # 是否检查空字符串(读取端默认 True)
|
||||||
custom_check: NotRequired[str | Callable[[Any], bool]] # 自定义校验函数(名称或已解析函数)
|
custom_check: NotRequired[str | Callable[[Any], bool]] # 自定义校验函数(名称或已解析函数)
|
||||||
|
max_len_bytes: NotRequired[int] # 文本宽度上限(汉字算 2,见 text_width)
|
||||||
description: NotRequired[str] # 字段描述(用于生成友好提示)
|
description: NotRequired[str] # 字段描述(用于生成友好提示)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,14 +211,23 @@ def _load_default_rules() -> ValidationConfig:
|
|||||||
"path": ["reimbursement_details", "transport_fee"],
|
"path": ["reimbursement_details", "transport_fee"],
|
||||||
"min_items": 1,
|
"min_items": 1,
|
||||||
"element_fields": [
|
"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": ["start_date"], "custom_check": _is_valid_date, "description": "出发日期"},
|
||||||
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"},
|
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"},
|
||||||
{"path": ["departure_place"], "description": "出发地"},
|
{"path": ["departure_place"], "description": "出发地"},
|
||||||
{"path": ["arrival_place"], "description": "目的地"},
|
{"path": ["arrival_place"], "description": "目的地"},
|
||||||
{"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"},
|
{"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"},
|
||||||
{"path": ["bill_count"], "custom_check": _is_positive_integer, "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": "交通费用明细",
|
"description": "交通费用明细",
|
||||||
},
|
},
|
||||||
@@ -193,7 +238,12 @@ def _load_default_rules() -> ValidationConfig:
|
|||||||
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
|
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
|
||||||
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
|
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
|
||||||
{"path": ["merchant"], "description": "商户名称"},
|
{"path": ["merchant"], "description": "商户名称"},
|
||||||
{"path": ["remark"], "check_empty": False, "description": "备注"},
|
{
|
||||||
|
"path": ["remark"],
|
||||||
|
"check_empty": False,
|
||||||
|
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||||
|
"description": "备注",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"description": "支付方式记录",
|
"description": "支付方式记录",
|
||||||
},
|
},
|
||||||
@@ -215,6 +265,13 @@ def _load_default_rules() -> ValidationConfig:
|
|||||||
"element_fields": [
|
"element_fields": [
|
||||||
{"path": ["filename"], "description": "文件名"},
|
{"path": ["filename"], "description": "文件名"},
|
||||||
{"path": ["attachment_type"], "description": "附件类型"},
|
{"path": ["attachment_type"], "description": "附件类型"},
|
||||||
|
{
|
||||||
|
"path": ["attachment_desc"],
|
||||||
|
"required": False,
|
||||||
|
"check_empty": False,
|
||||||
|
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||||
|
"description": "附件说明",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"description": "附件列表",
|
"description": "附件列表",
|
||||||
},
|
},
|
||||||
@@ -242,7 +299,12 @@ def _load_default_rules() -> ValidationConfig:
|
|||||||
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
|
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
|
||||||
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
|
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
|
||||||
{"path": ["merchant"], "description": "商户名称"},
|
{"path": ["merchant"], "description": "商户名称"},
|
||||||
{"path": ["remark"], "check_empty": False, "description": "备注"},
|
{
|
||||||
|
"path": ["remark"],
|
||||||
|
"check_empty": False,
|
||||||
|
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||||
|
"description": "备注",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"description": "支付方式记录",
|
"description": "支付方式记录",
|
||||||
},
|
},
|
||||||
@@ -252,6 +314,13 @@ def _load_default_rules() -> ValidationConfig:
|
|||||||
"element_fields": [
|
"element_fields": [
|
||||||
{"path": ["filename"], "description": "文件名"},
|
{"path": ["filename"], "description": "文件名"},
|
||||||
{"path": ["attachment_type"], "description": "附件类型"},
|
{"path": ["attachment_type"], "description": "附件类型"},
|
||||||
|
{
|
||||||
|
"path": ["attachment_desc"],
|
||||||
|
"required": False,
|
||||||
|
"check_empty": False,
|
||||||
|
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||||
|
"description": "附件说明",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"description": "附件列表",
|
"description": "附件列表",
|
||||||
},
|
},
|
||||||
@@ -298,30 +367,49 @@ def _check_field(
|
|||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
rule: FieldRule,
|
rule: FieldRule,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""根据字段规则检查字段。"""
|
"""根据字段规则检查字段。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否通过, 字段路径)。不通过且原因可说清时(如文本超宽),路径后拼
|
||||||
|
「{HINT_SEP}说明」,_check_array 会把说明转接到 payment_methods[0].remark|… 上,
|
||||||
|
让 Agent 一眼看出是「缺字段」还是「话写太长了」。
|
||||||
|
"""
|
||||||
path = rule["path"]
|
path = rule["path"]
|
||||||
|
required = rule.get("required", True)
|
||||||
check_empty = rule.get("check_empty", True)
|
check_empty = rule.get("check_empty", True)
|
||||||
custom_check = rule.get("custom_check")
|
custom_check = rule.get("custom_check")
|
||||||
|
max_len_bytes = rule.get("max_len_bytes")
|
||||||
|
path_str = ".".join(path)
|
||||||
|
|
||||||
current = data
|
current = data
|
||||||
for key in path:
|
for key in path:
|
||||||
if not isinstance(current, dict):
|
if not isinstance(current, dict) or key not in current:
|
||||||
return (False, ".".join(path))
|
# required=false 的字段(如 attachment_desc)不存在就跳过:
|
||||||
if key not in current:
|
# 填报端有自己的兜底(附件说明留空会写成「发票:文件名」)
|
||||||
return (False, ".".join(path))
|
return (True, path_str) if not required else (False, path_str)
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
if check_empty and isinstance(current, str) and not current.strip():
|
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 custom_check is not None:
|
||||||
if isinstance(custom_check, str):
|
if isinstance(custom_check, str):
|
||||||
# 规则名未在 _resolve_custom_checks 里解析成函数,属于配置/调用错误
|
# 规则名未在 _resolve_custom_checks 里解析成函数,属于配置/调用错误
|
||||||
raise TypeError(f"custom_check 仍为规则名 {custom_check!r},未经 _resolve_custom_checks 解析")
|
raise TypeError(f"custom_check 仍为规则名 {custom_check!r},未经 _resolve_custom_checks 解析")
|
||||||
if not custom_check(current):
|
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(
|
def _check_array(
|
||||||
@@ -346,11 +434,16 @@ def _check_array(
|
|||||||
current = data
|
current = data
|
||||||
for key in path:
|
for key in path:
|
||||||
if not isinstance(current, dict) or key not in current:
|
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)
|
return ([path_str], total_checks, passed_checks)
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
# 检查数组是否满足最小数量要求
|
# 检查数组是否满足最小数量要求
|
||||||
if not isinstance(current, list) or len(current) < min_items:
|
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)
|
return ([path_str], total_checks, passed_checks)
|
||||||
|
|
||||||
passed_checks += 1 # 数组检查通过
|
passed_checks += 1 # 数组检查通过
|
||||||
@@ -371,13 +464,15 @@ def _check_array(
|
|||||||
else:
|
else:
|
||||||
field_rule_dict = field_rule
|
field_rule_dict = field_rule
|
||||||
|
|
||||||
# 复用 _check_field 函数检查元素字段
|
# 复用 _check_field 函数检查元素字段(超宽之类的说明转接到条目上)
|
||||||
ok, _ = _check_field(item, field_rule_dict)
|
ok, msg = _check_field(item, field_rule_dict)
|
||||||
if ok:
|
if ok:
|
||||||
passed_checks += 1
|
passed_checks += 1
|
||||||
else:
|
else:
|
||||||
field_path_str = ".".join(field_rule_dict["path"])
|
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
|
return missing, total_checks, passed_checks
|
||||||
|
|
||||||
|
|||||||
@@ -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 等英文写法)"
|
||||||
|
)
|
||||||
+13
-14
@@ -7,6 +7,7 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ... import get_logger
|
from ... import get_logger
|
||||||
|
from ...core.vehicles import VEHICLE_CODES, canonical_vehicle, vehicle_error
|
||||||
from .base import BaseBot, format_date
|
from .base import BaseBot, format_date
|
||||||
|
|
||||||
log = get_logger("bot.travel")
|
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"]),包含
|
details: 报销明细字典(travel_info["reimbursement_details"]),包含
|
||||||
transport_fee、hotel_fee、conference_fee 等子字段。
|
transport_fee、hotel_fee、conference_fee 等子字段。
|
||||||
"""
|
"""
|
||||||
vehicle_map = {
|
# 交通工具的代码表与别名归一搬到了 src/core/vehicles.py:
|
||||||
"火车": "01",
|
# 填报层取 value、体检层判合法、报错层给候选,三处共用一张表。
|
||||||
"汽车": "02",
|
# 原来这里 `if vehicle in vehicle_map:` 是静默跳过——英文枚举(train)匹配不上,
|
||||||
"轮船": "03",
|
# 下拉框停在默认值而本步照样返回成功,等人提交时才被必填拦下(2026-10-21 踩过)。
|
||||||
"自带车": "04",
|
|
||||||
"公务车": "05",
|
|
||||||
"飞机": "06",
|
|
||||||
"租车": "07",
|
|
||||||
"自驾车": "08",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not bot.open_tab("details"):
|
if not bot.open_tab("details"):
|
||||||
raise RuntimeError("切不到差旅明细标签页")
|
raise RuntimeError("切不到差旅明细标签页")
|
||||||
@@ -113,9 +107,14 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
|
|||||||
bot._wait_for('text="增加明细"', timeout=5000)
|
bot._wait_for('text="增加明细"', timeout=5000)
|
||||||
bot.page.select_option("#cost", "1")
|
bot.page.select_option("#cost", "1")
|
||||||
bot.page.wait_for_timeout(500)
|
bot.page.wait_for_timeout(500)
|
||||||
vehicle = item.get("vehicle_type", "")
|
raw_vehicle = item.get("vehicle_type", "")
|
||||||
if vehicle in vehicle_map:
|
vehicle = canonical_vehicle(raw_vehicle)
|
||||||
bot.page.select_option("#jtgj", vehicle_map[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("#ksdd", item.get("departure_place", ""))
|
||||||
bot.page.fill("#jsdd", item.get("arrival_place", ""))
|
bot.page.fill("#jsdd", item.get("arrival_place", ""))
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
|
|||||||
5. 依据 totals 与 suggested_invoice_type 综合缓存和匹配结果,产出 travel_info 或 normal_info,
|
5. 依据 totals 与 suggested_invoice_type 综合缓存和匹配结果,产出 travel_info 或 normal_info,
|
||||||
用 mcp__arb__save_info 落盘(会自动跑规则校验);字段拿不准先 mcp__arb__rules_get 对照规则,
|
用 mcp__arb__save_info 落盘(会自动跑规则校验);字段拿不准先 mcp__arb__rules_get 对照规则,
|
||||||
或用 mcp__arb__validate_info(draft=...) 不落盘反复试。不完整就向用户要补充材料,禁止自己宣布可提交。
|
或用 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 的键必须存在(含值为空串的备注类
|
拼 JSON 前先 mcp__arb__info_skeleton(info_type) 取骨架:required 为 true 的键必须存在(含值为空串的备注类
|
||||||
字段),骨架与 validation_rules.json 同源,别凭记忆造结构。
|
字段),骨架与 validation_rules.json 同源,别凭记忆造结构。
|
||||||
6. 普通发票用 mcp__arb__doc_requisition 生成 Excel 申购单(COM 自动化,需本机装有 Excel;
|
6. 普通发票用 mcp__arb__doc_requisition 生成 Excel 申购单(COM 自动化,需本机装有 Excel;
|
||||||
@@ -46,6 +52,7 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
|
|||||||
normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。
|
normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。
|
||||||
浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口,
|
浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口,
|
||||||
让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。
|
让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。
|
||||||
|
某步返回 too_long 就是那段文本超宽(见第 5 条),改短后重跑同一 step,别去浏览器里手动点确定。
|
||||||
|
|
||||||
**提交与提交前后的核对都由用户本人在财务系统里做**:submit_* 这一类工具(代提交、提交前预览)
|
**提交与提交前后的核对都由用户本人在财务系统里做**:submit_* 这一类工具(代提交、提交前预览)
|
||||||
已被用户整体否决,本项目不提供、也不会计划提供。填报完成就是终点——告诉用户单据编号与
|
已被用户整体否决,本项目不提供、也不会计划提供。填报完成就是终点——告诉用户单据编号与
|
||||||
|
|||||||
@@ -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 为准,
|
info_type 取 travel|normal。完整性以返回的 validation 为准,
|
||||||
不要自己在 data 里塞 can_submit。
|
不要自己在 data 里塞 can_submit。
|
||||||
|
|
||||||
|
规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1):
|
||||||
|
备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到,
|
||||||
|
所以在这里就拦住,missing_fields 会写成
|
||||||
|
"payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。
|
||||||
"""
|
"""
|
||||||
d = _store.resolve(case_id)
|
d = _store.resolve(case_id)
|
||||||
if d is None:
|
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)
|
report = validate_extracted_info(data, invoice_type=info_type)
|
||||||
payload = _report_dict(report)
|
payload = _report_dict(report)
|
||||||
if groups_path.exists() and info_type == "travel":
|
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)
|
return ok(case_id=case_id, info_type=info_type, **payload)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+105
-2
@@ -38,6 +38,91 @@ STEP_LABEL: dict[str, str] = {
|
|||||||
PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
|
PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
|
||||||
INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"}
|
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:
|
def _case_dir(case_id: str) -> Path | None:
|
||||||
return _store.resolve(case_id)
|
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:
|
if both:
|
||||||
return fail(
|
return fail(
|
||||||
"会话里同时有 travel_info 与 normal_info,无法判断单据类型",
|
"会话里同时有 travel_info 与 normal_info,无法判断单据类型",
|
||||||
next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)",
|
next_action=(
|
||||||
|
"显式传 kind='travel' 或 'normal'(因出差发生的会议费并入差旅单;货物类普通发票才另走普通报销)"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if kind is None:
|
if kind is None:
|
||||||
return fail(
|
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]:
|
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)
|
d = _case_dir(case_id)
|
||||||
ctx = _require_open(case_id)
|
ctx = _require_open(case_id)
|
||||||
if d is None or ctx is None:
|
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:
|
if info is None:
|
||||||
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息")
|
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]:
|
def _fill() -> dict[str, Any]:
|
||||||
bot = ctx["bot"]
|
bot = ctx["bot"]
|
||||||
filled = _apply_step(bot, kind, step, info)
|
filled = _apply_step(bot, kind, step, info)
|
||||||
|
|||||||
@@ -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()) == []
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user