完善差旅补助规定与费用类型(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-29 20:04:35 +08:00
父节点 5df5f12ad6
当前提交 13a3f4b57d
共修改 10 个文件,包含 201 行新增和 15 行删除
@@ -383,6 +383,96 @@ node scripts/qa/check-ocr-provider-samples.mjs \
The caller records stdout before applying the exit-code gate; it never uses a
`set -e` assignment whose non-zero status exits before the JSON is emitted.
## Scenario: Travel Allowance Expense-Type Boundary
### 1. Scope / Trigger
- Trigger: adding or changing travel reimbursement types, travel-request
transport mappings, allowance policies or expense standards.
- Purpose: keep intercity transport reimbursed by evidence separate from the
two daily lump-sum allowances, and avoid false over-limit results until the
reimbursement item model supports a day count.
### 2. Signatures
- Seed contract in `server/src/scripts/seed-config.js`:
- `travel_bus` -> `大巴车票`.
- `travel_meal` -> `伙食补助费`.
- `travel_local_transport_allowance` -> `市内交通补助费`.
- Travel conversion contract in `server/src/routes/reimbursements.js`:
`TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES.bus === "travel_bus"`.
- Current item data boundary: `ReimbursementItem` stores expense type, date,
amount and description, but no quantity or eligible-day count.
### 3. Contracts
- `travel_bus` always means an intercity bus/coach ticket created from or used
with a travel segment. It must not represent local transport allowance.
- Meal allowance is CNY 100 per eligible person-day; local transport allowance
is CNY 80 per eligible person-day. Both are lump-sum items without an invoice.
- Employees create one item per allowance, set the amount to eligible days
multiplied by the rate, and write the date range, day count, rate and formula
in the description. Supervisor and finance review that calculation.
- Departure and return dates each count as one natural day; same-day return is
one day. Personal extensions do not count. Meals or vehicles paid directly
by the company or another organization exclude the corresponding allowance.
- Claiming meal allowance excludes separate personal meal reimbursement;
claiming local transport allowance excludes bus, metro, taxi and ride-hailing
reimbursement during the trip. Intercity transport, accommodation and other
approved necessary expenses remain evidence-based actual reimbursement.
- `ExpenseStandard.per_unit=day` currently does not multiply `max_amount` by a
day count. Do not seed or configure CNY 100/CNY 80 warn/block standards until
item quantity/day multiplication is implemented end to end.
### 4. Validation & Error Matrix
- Travel segment `bus` maps to an allowance type -> block the change; preserve
`travel_bus` and add/use the separate allowance code.
- CNY 100 or CNY 80 daily rate configured as the current item-level maximum ->
remove/disable it because a multi-day allowance would be falsely over limit.
- Allowance item lacks date range, eligible-day count, rate or formula -> return
for completion during supervisor/finance review; do not invent a day count.
- Company/other party directly bore the corresponding cost -> exclude those
days from the applicable allowance and record the reason.
### 5. Good/Base/Bad Cases
- Good: a three-day eligible trip uses separate meal and local-transport items
whose descriptions show `3 天 × 单价`, while rail/hotel items carry PDFs and
required payment evidence.
- Base: intercity coach travel uses `travel_bus` / `大巴车票` and remains an
actual-expense item with valid evidence.
- Bad: rename `travel_bus` to local transport allowance, or configure a CNY 80
item maximum and thereby reject a valid multi-day allowance.
### 6. Tests Required
- Seed-default unit test asserts all three codes and Chinese names are distinct.
- Travel-request conversion test asserts `transport_type=bus` produces an item
whose expense type code is `travel_bus` and description identifies `大巴`.
- When automatic day calculation is later implemented, add service and route
tests for same-day, multi-day, personal-extension and directly-borne cases
before enabling any daily standard.
- Documentation checks must keep the employee guide, reimbursement fact source
and production expense-type names aligned.
### 7. Wrong vs Correct
#### Wrong
```js
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_local_transport_allowance" };
// Current fee control compares the whole multi-day item directly with 80.
```
#### Correct
```js
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_bus" };
// Local transport allowance is a separate manually calculated item until the
// model and fee-control service both support eligible-day multiplication.
```
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger
@@ -2,7 +2,7 @@
## 边界
本任务不开发新的 OCR provider,也不开放图片发票或改变审批流。在现有腾讯 provider 内把统一入口从 `VatInvoiceOCR` 改为 `RecognizeGeneralInvoice`,增加票种感知的安全归一化和上传大小门禁,再完成生产配置恢复、真实链路验证及两份既有业务文档更新。
本任务不开发新的 OCR provider,也不开放图片发票或改变审批流。在现有腾讯 provider 内把统一入口从 `VatInvoiceOCR` 改为 `RecognizeGeneralInvoice`,增加票种感知的安全归一化和上传大小门禁,再完成生产配置恢复、真实链路验证、差旅补助费用类型收口及两份既有业务文档更新。
生产配置写入、容器重建、腾讯 API 调用和真实样票上传均属于单独受控步骤;每一步只在前一步通过后继续。
@@ -60,6 +60,14 @@
- `docs/报销发票附件规则.md` 固化支付证明必交、敏感字段遮挡、退款/代付/优惠差异和无截图例外口径。
- OCR 结果仅辅助识别和查重;财务仍核对明细、PDF 原件和个人支付证明。
## 差旅补助与费用类型
- `travel_bus` 已由差旅行程 `transport_type=bus` 自动映射,业务语义是城际“大巴”;将其显示名收口为“大巴车票”,不得复用为市内交通补助。
- `travel_meal` 保留代码并将显示名收口为“伙食补助费”;新增 `travel_local_transport_allowance`,显示名为“市内交通补助费”。生产当前两类既有费用均无历史报销明细,种子脚本按代码幂等更新名称和新增类型。
- 伙食补助费为 100 元/人/天,市内交通补助费为 80 元/人/天,均按自然日包干且不要求发票;直接承担、重复报销和个人延长天数按 PRD 规则处理。
- 当前 `ReimbursementItem` 没有天数或数量字段,`ExpenseStandard.per_unit=day` 也不会在费用控制中乘以天数。因此本轮不创建 100/80 元 block 或 warn 标准,员工分别新增补助明细,以“天数 × 单价”人工算出总额并在说明写明计算式,由主管和财务复核。
- 自动带出差旅起止日、计算符合条件的补助天数以及按天校验属于后续产品能力,不阻塞本次真实报销。
## 失败与回退
- adapter 聚焦测试、配置 smoke、腾讯调用或任一真实样票验证失败:停止开放真实票据,不继续制造 pending/retry 数据。
@@ -62,6 +62,14 @@
- [x] 更新 `docs/报销发票附件规则.md` 的支付截图、实报实销差异、退款/代付和人工复核规则。
- [x] 不新建平行员工指南,不写入真实票据、支付信息、账号或环境路径。
## 5.1 差旅补助规定落地
- [x] 修正 `travel_bus` 显示名为“大巴车票”,修正 `travel_meal` 显示名为“伙食补助费”,新增 `travel_local_transport_allowance` / “市内交通补助费”。
- [x] 增加聚焦测试,锁定城际大巴映射和两项补助费用类型不混用。
- [x] 更新员工指南和报销事实源,写明 100 元/天伙食补助、80 元/天市内交通补助、自然日、包干、直接承担、个人延长和其他费用实报实销规则。
- [x] 明确当前由员工按“天数 × 单价”人工填写总额;不配置会误判多日补助的单条费用标准。
- [x] 重新生成并检查一页版差旅报销 PDF。
## 6. 验证与审查
- [x] 静态核对页面/API 上传格式、明细绑定和员工/财务字段边界。
@@ -115,6 +123,13 @@
- 仅在铁路通过后运行住宿样票并明确 `PASS`:票种为数电发票,号码、日期、金额、税额、价税合计、销售方和购买方字段命中,无缺失必填字段。
- 两次授权腾讯 OCR 调用已全部用完。输出仅保留匿名 alias、票种和字段命中布尔值;两次探针均未创建 OA 发票或 OCR 数据,最近 30 分钟对应新增记录为 `0/0`,无需业务数据清理。
## 2026-07-29 差旅补助规定实施验证
- 已把城际大巴、伙食补助和市内交通补助收口为三个独立稳定代码;种子聚焦测试证明按代码幂等更新/创建,不配置 100/80 元单条费用标准。
- 员工指南与报销事实源已写明两项补助单价、自然日、包干、直接承担不重复领取、个人延长不计补助和人工算式要求;单页 PDF 已重新生成并完成视觉检查。
- 聚焦测试 3 个文件/17 项、后端全量 168 个文件/1077 项、Prisma validate、流程合同、文档审计、Trellis governance、敏感内容、task validate、Node 语法和 `git diff --check` 均通过;文档审计仅保留 22 个既有未注册文档 warning。
- 本地实施不等于生产费用类型已变更。提交、推送、部署和生产 `seed:config` 仍等待对应授权;在生产只读复核三个费用类型且确认未新增错误费用标准前,不通知杨俊按补助新口径提交。
## 风险文件与操作
- 生产环境文件:包含 Secret,只允许用户现场静默输入;不得由补丁、聊天或命令参数传入。
@@ -2,7 +2,7 @@
## Goal
先把生产发票识别从 mock 切换到真实腾讯 OCR 并取得授权样票证据,再让杨俊及后续员工一次看懂差旅报销需要提交什么、分别上传到哪里,以及财务如何按“明细—发票—个人支付证明”复核。
先把生产发票识别从 mock 切换到真实腾讯 OCR 并取得授权样票证据,再让杨俊及后续员工一次看懂差旅报销需要提交什么、分别上传到哪里、差旅补助如何计算,以及财务如何按“明细—发票—个人支付证明”复核。
## Background
@@ -26,6 +26,7 @@
- 更新 `docs/报销发票附件规则.md`,固化火车票/住宿费支付证明、实报实销和 OCR 关闭期间人工核对规则。
- 配置并正式启用生产腾讯 OCR,完成配置门禁、受控发布、公网检查和授权 PDF 样票验证。
- 在现有腾讯 provider 内改用通用票据识别高级版,补齐住宿电子普通发票和铁路电子客票的安全归一化、质量门禁、大小限制和测试。
- 固化公司差旅补助规定,补齐不与城际大巴费用混淆的费用类型,并同步员工指南和报销事实源。
## Requirements
@@ -47,6 +48,12 @@
- 当前无独立 worker 且 server 自身运行 jobs,配置变更只重建实际加载配置的 server;不得额外启动第二个 OCR job executor。
- 真实能力必须使用已授权 PDF 样票验证;缺少授权样票只能证明配置齐全,不能宣称真实 OCR 已通过。
- 指南明确审批链:员工提交、直属主管审批、财务复核;财务付款和回单不由员工预填。
- 伙食补助费按 100 元/人/天包干,市内交通补助费按 80 元/人/天包干;两项补助均不要求发票。
- 补助按出差自然日计算,出发日和返回日均计 1 天,当天往返计 1 天;因个人原因延长的天数不计补助。
- 领取伙食补助后个人餐费不得另报;领取市内交通补助后,出差期间公交、地铁、出租车、网约车等不得另报。
- 公司或其他单位已直接承担餐食或车辆费用的,不重复领取相应补助;城际交通、住宿及其他经批准的必要差旅费用凭有效材料实报实销。
- 当前报销明细没有天数字段,两项补助分别作为独立明细人工填写总额和计算式,例如“伙食补助费:3 天 × 100 元”;不得把现有 `per_unit=day` 费用标准直接配置为 100/80 元,因为当前校验不会按天数乘算,会误判多日补助。
- `travel_bus` 保留为城际大巴车票费用;市内交通补助使用独立费用类型,不得共用代码或混同展示。
- 文档不放真实票据、支付截图、账号、订单号、银行信息或完整业务数据。
## Acceptance Criteria
@@ -59,6 +66,9 @@
- [ ] 生产实际 provider 为 `tencent`,只授予 `ocr:RecognizeGeneralInvoice`,两个凭证字段已配置但不泄露,external deps smoke、`/ready` 和运行 job 拓扑通过。
- [ ] 住宿电子普通发票和铁路电子客票各一份已授权 PDF 经真实腾讯 OCR 进入可解释终态,并证明没有调用 mock;样票和原始识别 payload 不进入仓库或聊天。
- [ ] 明确 OCR 只辅助字段识别/查重,财务仍执行明细—PDF 原件—支付证明复核。
- [ ] 员工指南和报销事实源完整写明两项补助的单价、自然日口径、包干不另报、直接承担不重复领取和个人延长不计补助规则。
- [ ] 生产种子数据中“伙食补助费”“市内交通补助费”均为独立 enabled 费用类型,`travel_bus` 仍明确表示城际大巴车票。
- [ ] 未配置会把多日补助误判为超标的 100/80 元单条费用标准;指南要求员工按天数人工计算并在说明中写出算式。
- [ ] 在真实 OCR 验收完成前不得仅因指南已完成就通知杨俊可以提交。
- [ ] 全程不把真实票据、支付截图、完整账号、银行资料、Secret 或原始 payload 写入仓库、任务或聊天。
@@ -66,9 +76,9 @@
- 新增第二个 OCR provider、开放图片发票、自动验真或增加人工发票字段。
- 本轮不做通用发票平台或覆盖全部票种;仅支持现有住宿 VAT/电子普通发票和电子发票(铁路电子客票)主路径。
- 修改报销页面、上传格式、审批规则、费用标准或费用类型。
- 修改报销页面、上传格式、审批规则或自动按天计算;本轮只调整差旅相关费用类型名称并新增独立的市内交通补助类型,不配置 100/80 元费用标准。
- 代替杨俊上传票据、创建报销、提交审批,或代替主管/财务处理。
- 制定覆盖所有费用类别的完整财务制度;本轮只收口首笔差旅的火车票和住宿费。
- 制定覆盖所有费用类别的完整财务制度;本轮只收口首笔差旅的实报实销材料和两项包干补助。
## Test Plan
+8 -5
查看文件
@@ -49,18 +49,21 @@
- OAOPS1 期间请用真实业务数据完成报销、采购/费用、合同台账和项目关联;遇到提交、审批、付款、导出或权限阻断时,记录页面、步骤和影响等级,让管理员当天建修复合同或暂停对应流程。
- OALAUNCH1 收口期内,请把真实业务问题按“阻断 / 非阻断 / 建议”告诉管理员;不要只在聊天里说“系统有问题”。阻断包括无法提交、无法审批、付款/AP 无法闭环、越权可见、合同或资产敏感信息泄露、备份恢复或导出证据缺失。
### 差旅报销一页指南(火车票 + 住宿费)
### 差旅报销一页指南
1. 从企业微信工作台进入海音达工作台,打开 `报销`,新建报销单。
2. 把费用拆成两条明细,不要合并填写:
2. 把费用拆成独立明细,不要合并填写:
- 火车票:费用类型选“火车票”,填写乘车日期、个人实际支付的合规金额和事由,例如“项目出差往返高铁”。
- 住宿费:费用类型选“住宿费”,填写实际住宿日期、个人实际支付的合规金额和事由,例如“项目出差住宿”。
- 伙食补助费:按符合条件的出差自然日 `N 天 × 100 元` 计算总额,说明中写明天数、日期范围和算式,不要求发票。
- 市内交通补助费:按符合条件的出差自然日 `N 天 × 80 元` 计算总额,说明中写明天数、日期范围和算式,不要求发票。
3. 在每条明细的“添加发票”入口上传对应的官方 PDF:火车电子票据 PDF 绑定火车票明细,住宿电子普通发票 PDF 绑定住宿费明细。每个 PDF 只放一张票据、只识别第一页,单个文件不能超过 7 MiB;图片、照片和支付截图不能当作正式发票上传。
4. 火车票和住宿费都必须另附个人支付截图。支付截图通过整张报销单的“上传附件 / 补充材料”入口上传,并在文件名或说明中写清对应“火车票”还是“住宿费”。
5. 支付截图应看得出商户、金额、支付时间和支付成功状态;银行卡号、支付账号、订单号等与复核无关的信息先遮挡。
6. 明细金额填写个人实际支付的合规金额,原则上不得超过有效发票金额。使用优惠券、多人合并开票、部分自费或发票金额与实际支付金额不一致时,必须在明细说明中写清原因。
7. 已退款、公司直接支付或他人承担的金额不能再次报销。确实无法提供支付截图时,上传情况说明,由财务决定退回还是例外接受,不得补造凭证。
8. 提交前逐项检查:两条明细、两份对应 PDF、两类支付证明和金额说明都已齐全。提交后由直属主管审批、财务复核;实际付款和付款回单由财务处理,员工不预填。
6. 补助按自然日计算:出发日、返回日各计 1 天,当天往返计 1 天;因个人原因延长的天数不计。公司或其他单位已直接承担餐食或车辆费用的,对应补助不得重复领取。
7. 伙食补助和市内交通补助均为包干:领取伙食补助后个人餐费不得另报;领取市内交通补助后,出差期间公交、地铁、出租车、网约车等不得另报。城际交通、住宿及其他经批准的必要差旅费用凭有效材料实报实销。
8. 实报实销明细填写个人实际支付的合规金额,原则上不得超过有效发票金额。优惠券、多人合并开票、部分自费或发票金额与实付金额不一致时,必须说明;已退款、公司直接支付或他人承担的金额不能再次报销。无法提供支付截图时上传情况说明,由财务决定是否例外接受,不得补造凭证。
9. 提交前检查各项明细、对应 PDF、火车票和住宿费支付证明以及补助算式。提交后由直属主管审批、财务复核;实际付款和付款回单由财务处理,员工不预填。
OCR 会辅助识别发票字段和查重,但不代表系统已经确认费用真实合规。财务仍会同时核对明细金额、PDF 原件和个人支付证明;三者不一致时会退回补充或更正。
+11 -1
查看文件
@@ -133,7 +133,7 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
## 差旅实报实销与支付证明
首笔差旅报销按“明细—正式发票—个人支付证明”三方对应复核,当前火车票和住宿费执行以下规则:
首笔差旅报销按“实报实销明细—正式票据—个人支付证明”和“包干补助—天数—单价”分别复核。
- 火车票和住宿费必须拆成不同明细,分别选择生产已有的“火车票”和“住宿费”费用类型,填写发生日期、个人实际支付的合规金额和事由。
- 火车电子票据、住宿电子普通发票等正式票据必须使用官方 PDF,从对应明细的发票入口上传并绑定;发票截图、支付截图或拍照图片不得冒充正式发票。
@@ -145,6 +145,16 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。
- 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。
### 差旅补助规定
- 伙食补助费为 100 元/人/天,市内交通补助费为 80 元/人/天,均按符合条件的出差自然日包干发放,不要求发票。
- 出发日和返回日均计 1 天,当天往返计 1 天;因个人原因延长的行程天数不计补助。
- 领取伙食补助后,个人餐费不得另行报销;领取市内交通补助后,出差期间公交、地铁、出租车、网约车等市内交通费用不得另行报销。
- 公司或其他单位已直接承担相应餐食或车辆费用的,不重复领取对应补助。
- 城际交通、住宿以及其他经批准的必要差旅费用凭有效材料实报实销。城际大巴费用使用“大巴车票”费用类型,不得填写为“市内交通补助费”。
- 当前系统不自动计算补助天数。员工应为两项补助分别建立明细,金额填写 `符合条件的天数 × 对应单价` 的总额,并在说明中写明日期范围、天数、单价、算式及不计补助的情况;主管和财务据此复核。
- 在系统具备数量/天数乘算能力前,不配置 100 元或 80 元的单条费用上限。现有按日单位只作展示,直接配置会把多日补助误判为超标。
## PC 与手机体验
PC 端:
+9 -2
查看文件
@@ -7,8 +7,13 @@ const ENABLED_EXPENSE_TYPES = [
{ code: "travel_train", name: "火车票", category: "travel" },
{ code: "travel_taxi", name: "打车费", category: "travel" },
{ code: "travel_hotel", name: "住宿费", category: "travel" },
{ code: "travel_bus", name: "市内交通", category: "travel" },
{ code: "travel_meal", name: "差旅餐补", category: "travel" },
{ code: "travel_bus", name: "大巴车票", category: "travel" },
{ code: "travel_meal", name: "伙食补助费", category: "travel" },
{
code: "travel_local_transport_allowance",
name: "市内交通补助费",
category: "travel",
},
{ code: "travel_fuel", name: "油费", category: "travel" },
{ code: "travel_toll", name: "过路费", category: "travel" },
{ code: "travel_parking", name: "停车费", category: "travel" },
@@ -1088,5 +1093,7 @@ if (require.main === module) {
module.exports = {
DEFAULT_APPROVAL_RULES,
ENABLED_EXPENSE_TYPES,
seedApprovalRules,
seedExpenseTypes,
};
@@ -21,7 +21,7 @@ function buildTemplate(overrides = {}) {
return {
id: TEMPLATE_ID,
name: "国内差旅报销",
description: "机票、火车票与市内交通占位模板",
description: "机票、火车票与大巴车票占位模板",
items: [
{
expenseTypeId: EXPENSE_TYPE_ID,
@@ -60,7 +60,7 @@ function buildTemplate(overrides = {}) {
},
{
expenseTypeId: BUS_TYPE_ID,
description: "市内交通占位",
description: "大巴车票占位",
},
],
...overrides,
@@ -127,7 +127,7 @@ async function buildApp(options = {}) {
expenseTypes: options.expenseTypes ?? [
{ id: AIRFARE_TYPE_ID, code: "travel_airfare", name: "机票", category: "travel", enabled: true },
{ id: TRAIN_TYPE_ID, code: "travel_train", name: "火车票", category: "travel", enabled: true },
{ id: BUS_TYPE_ID, code: "travel_bus", name: "市内交通", category: "travel", enabled: true },
{ id: BUS_TYPE_ID, code: "travel_bus", name: "大巴车票", category: "travel", enabled: true },
{ id: TAXI_TYPE_ID, code: "travel_taxi", name: "打车费", category: "travel", enabled: true },
{ id: TRANSPORT_TYPE_ID, code: "transport", name: "legacy-交通费", category: "travel", enabled: true },
],
@@ -1,8 +1,51 @@
const {
DEFAULT_APPROVAL_RULES,
ENABLED_EXPENSE_TYPES,
seedApprovalRules,
seedExpenseTypes,
} = require("../../../src/scripts/seed-config");
describe("default travel expense types", () => {
it("keeps intercity bus tickets separate from the two travel allowances", () => {
expect(ENABLED_EXPENSE_TYPES).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "travel_bus", name: "大巴车票", category: "travel" }),
expect.objectContaining({ code: "travel_meal", name: "伙食补助费", category: "travel" }),
expect.objectContaining({
code: "travel_local_transport_allowance",
name: "市内交通补助费",
category: "travel",
}),
]),
);
const travelCodes = ENABLED_EXPENSE_TYPES.filter((item) =>
["travel_bus", "travel_meal", "travel_local_transport_allowance"].includes(item.code),
);
expect(travelCodes).toHaveLength(3);
});
it("upserts the distinct travel types by stable code", async () => {
const upsert = vi.fn().mockResolvedValue({});
await seedExpenseTypes({ expenseType: { upsert } });
for (const [code, name] of [
["travel_bus", "大巴车票"],
["travel_meal", "伙食补助费"],
["travel_local_transport_allowance", "市内交通补助费"],
]) {
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { code },
update: expect.objectContaining({ name, enabled: true }),
create: expect.objectContaining({ code, name, enabled: true }),
}),
);
}
});
});
describe("small-team default reimbursement approval rules", () => {
it("uses finance for every owner fallback and finance review node", () => {
expect(DEFAULT_APPROVAL_RULES).toEqual([