# M009 Manual Invoice 手工开票生成 V1 | 项目 | 内容 | | --- | --- | | 文档状态 | CP3 前端 V1 已完成,后续继续推进订单 / 任务预填、客户目录接口和历史查询 | | 适用范围 | 无订单 / 无任务场景下的手工 Invoice 创建,以及后续从任务或订单预填 Invoice | | 当前目标 | 第一阶段已基于现有 `invoice.html` 原型落地 Manual Invoice Mode;前端页面已接入正式业务接口并按原型三段式结构展示 | | 依赖能力 | M008 Excel 转 PDF 平台转换能力、阿里云 OSS、登录权限和酒店上下文 | ## 1. 背景 当前系统的主业务数据来自 AgentBus、SuperAgent、订单和任务。但实际运营中可能出现极端情况: - 邮件还没有被 SuperAgent 正确识别成订单 / 任务。 - 订单或任务尚未入库,用户仍需要先出一张 Proforma Invoice。 - 历史、线下、电话或临时沟通产生的开票需求没有可关联的任务。 - SuperAgent 或主链路故障时,需要人工兜底生成 PDF。 因此第一阶段需要支持一个独立的 `Manual Invoice Mode`:用户可以不选择订单、不选择任务,直接填写或选择 Invoice 字段,由后端生成 Excel,再通过 LibreOffice 转为 PDF 并上传 OSS。 ## 2. 第一阶段定位 第一阶段不是完整财务发票系统,而是酒店预订业务中的 Proforma Invoice 生成能力。 第一阶段应做到: - 页面可从独立入口进入,例如 `/reservation/invoices/new` 或 `/invoices/new`。 - 不要求 `task_id` 或 `order_id`。 - 用户手工填写 / 选择字段后,后端生成 PDF。 - 后端重新计算金额、税额和合计,不信任前端提交的计算结果。 - 酒店、法人、税号、银行账户、Logo 和固定文案优先来自模板或酒店发票配置。 - 生成行为必须有权限、酒店隔离和审计记录。 第一阶段不做: - 不接真实 OPERA / OHIP。 - 不要求必须挂靠订单或任务。 - 不做应收账款、收款核销、发票税务编号或财务系统入账。 - 不把 SuperAgent AI 原始 JSON 直接渲染成 Invoice 表单。 - 不允许前端直接调用 M008 调试上传接口来伪造业务开票。 ## 3. 与现有流程关系 ### 3.1 手工模式 ```text 用户打开手工 Invoice 页面 -> 前端读取发票表单配置和默认值 -> 用户填写 / 选择字段 -> 前端提交 invoice_payload -> 后端校验、计算金额和税额 -> 后端填充 Excel 模板 -> 调用 M008 内部转换能力生成 PDF -> PDF 上传 OSS -> 写入 Invoice 生成记录和审计 -> 返回 PDF URL 和生成摘要 ``` ### 3.2 任务或订单预填模式 后续可以支持从任务或订单进入: ```text 任务详情 / 订单详情 -> 打开 Invoice 页面并带 task_id 或 order_id -> 后端按 task_id / order_id 查询可用上下文 -> 返回同一套 invoice_payload 默认值 -> 用户确认或修改 -> 后续生成流程与手工模式相同 ``` 中文说明: - `task_id` 和 `order_id` 只是可选上下文,不是第一阶段生成 Invoice 的必要条件。 - 如果从任务进入,应优先使用任务详情 `fields[]`、`draft_payload_json` 或 `confirmed_payload_json` 中已确认的数据。 - 如果从订单进入,应由用户选择具体任务或由页面显式展示“仅使用订单摘要生成”,避免一个订单多任务时字段来源不清。 ## 4. 字段来源和控件分类 基于现有 `invoice.html` 原型和 Excel 模板,字段分为四类。 | 分类 | 中文说明 | 示例字段 | 第一阶段控件建议 | | --- | --- | --- | --- | | 用户输入字段 | 每张 Invoice 都可能不同,需要用户填写或修改 | Group Name、Arrival Date、Departure Date、Due Date、Room Rate Note、明细 Description | `input` / `date` / `textarea` | | 选择 + 可手填字段 | 有标准目录,但业务上允许人工覆盖 | Company、Attention、Address、Tel、Email、Booking Date、Room Type、Room Rate、Extra Bed | `select + Manual` 或后续 combobox | | 模板 / 配置字段 | 不是每张 Invoice 的业务输入,随酒店、法人或模板版本变化 | 酒店名称、Tax ID、法人公司、银行账号、Logo、固定付款文案、页脚 | 第一阶段可在模板或酒店发票配置中维护 | | 系统计算字段 | 不应由用户手填,后端或 Excel 公式计算 | Amount、Sub-Total、VAT、Total、Night(s) 汇总、Room(s) 汇总 | 前端只读预览,后端重新计算 | ### 4.1 Basic Information | 字段 | 控件 | 后续来源 | | --- | --- | --- | | Company | 选择 + 可手填 | 客户 / 旅行社目录,第一阶段可使用后端配置或静态种子 | | Attention | 选择 + 可手填 | Company 下联系人目录 | | Address | 选择 + 可手填 | 联系人或公司账单地址 | | Tel | 选择 + 可手填 | 联系人电话 | | Email | 选择 + 可手填 | 联系人邮箱 | | Booking Date | 选择 + 可手填 | 默认使用当前酒店本地日期,也可用户调整 | ### 4.1.1 Recipient Directory 第一阶段种子数据 `Company`、`Attention`、`Address`、`Tel`、`Email` 不是五个互相独立的普通字段,而是一组收件方 / 旅行社联系人档案。第一阶段页面可以允许 Manual 覆盖,但默认选择逻辑必须保持关联关系: - 选择 `Company` 后,刷新该公司下可选 `Attention` 列表。 - 选择 `Attention` 后,同步带出该联系人的 `Address`、`Tel`、`Email`。 - 公司只有一个联系人时,可以默认选中该联系人并自动带出地址、电话和邮箱。 - `Address` 可在公司级和联系人级之间复用;如果同一公司多个联系人共用地址,不应在每个联系人手工重复维护多份无关数据。 - 用户选择 `Manual` 时,才允许脱离目录手工填写;提交时仍保存最终文本值,目录 code 仅用于审计和后续追溯。 第一阶段确认的种子数据: | company_code | Company | contact_id | Attention | Address | Tel | Email | | --- | --- | --- | --- | --- | --- | --- | | `LIAN_TAI` | `LIAN TAI TRAVEL (THAILAND) CO., LTD.` | `LIAN_TAI_KHUN_ANN` | `Khun Ann` | `2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240` | `061-397-2675` | `op.liantaitravel@gmail.com` | | `QBD` | `Q.B.D. TRAVEL GROUP CO., LTD` | `QBD_JITDANUN_PANAPHUCHONG` | `Jitdanun Panaphuchong` | `2/90 Rajpattana,Rajpattana,Sapansoong, Bangkok, TH, 10240` | `089-032 0176` | `op.qbdtravel@gmail.com` | | `HANATOUR` | `HANATOUR TD CO., LTD.` | `HANATOUR_WICHIENPRAKARN` | `Wichienprakarn, Nuanphae, Khun.` | `HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR` | `066 124 - 7297` | `HI219@hanatour.com` | | `HANATOUR` | `HANATOUR TD CO., LTD.` | `HANATOUR_PHONGBUPPA` | `Phongbuppa, Buppachat` | `HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR` | `096 051 3587` | `HI223@hanatour.com` | | `HANATOUR` | `HANATOUR TD CO., LTD.` | `HANATOUR_KANG_SUNG_HWA` | `Kang,Sung Hwa` | `HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR` | `82 051 804 0707` | `k8040707@nave.com` | | `HANATOUR` | `HANATOUR TD CO., LTD.` | `HANATOUR_MINYOUNG_KIM` | `Minyoung Kim` | `HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR` | `82 010 6638 3345` | `mykim1220@hanayour.com` | | `HANATOUR` | `HANATOUR TD CO., LTD.` | `HANATOUR_BOLAM_JO` | `Bolam Jo` | `HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR` | `82 010 4182 4615` | `melissa0609@hanatour.com` | | `HANATOUR` | `HANATOUR TD CO., LTD.` | `HANATOUR_HWANG_SEONGSEOP` | `Hwang Seongseop` | `HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR` | `82 010 3167 8648` | `hwangpilot59@naver.com` | | `HANATOUR` | `HANATOUR TD CO., LTD.` | `HANATOUR_SONG_SAE_HWA` | `Song Sae Hwa` | `HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR` | `82 010 2400 6801` | `sh-tour2016@naver.com` | 中文说明: - 以上数据第一阶段可以作为后端种子配置、数据库初始化数据或前端原型静态数据,但正式业务接口生成 PDF 时应以请求中的最终文本值为准。 - 后续如果接客户 / 联系人维护后台,`company_code` 和 `contact_id` 应保持稳定,不使用 Company 或 Attention 展示文案做业务主键。 - Booking Date 虽然展示在 Basic Information 区域,但它不是联系人档案的一部分,应独立保存在 `invoice_payload.document.booking_date`。 ### 4.2 Reservation | 字段 | 控件 | 后续来源 | | --- | --- | --- | | Group Name / Group Code | 输入框 | 手工填写;任务预填时来自 `case_keys.group_code` 或订单 `group_code` | | Arrival Date | 日期输入 | 手工填写;任务预填时来自任务字段 | | Departure Date | 日期输入 | 手工填写;任务预填时来自任务字段 | | Due Date | 日期输入 | 手工填写;可按酒店付款规则默认生成 | | Room Rate | 选择 + 可手填 | 第一阶段可手填;后续接 Rate Code / 房价配置 | | Room Rate Note | 输入框 | 例如 `includingBF` | | Extra bed | 选择 + 可手填 | 第一阶段可手填;后续接加床价格配置 | | No. of Room(s) | 输入框 / 只读派生 | 可由第一条明细同步,也允许手工覆盖 | | No. of Night(s) | 输入框 / 日期派生 | 可由 Arrival / Departure 派生,也允许手工覆盖 | ### 4.3 Description & Amount | 字段 | 控件 | 后续来源 | | --- | --- | --- | | Description | 输入框 | 手工填写;默认可使用 Group Name 或 Booking No | | Room Types | 选择 + 可手填 | 第一阶段可手填;后续接 PMS 房型目录 | | Quantity(s) | 数字输入 | 后端要求正数 | | Rate | 数字输入 | 后端要求正数 | | Night(s) | 数字输入 | 后端要求正数 | | Amount | 只读 | `quantity * rate * nights` | | Sub-Total | 只读 | 后端按 VAT 规则计算 | | VAT 7% | 只读 | 后端按配置税率计算 | | Total amount | 只读 | 明细金额合计 | ## 5. 建议请求模型 第一阶段建议新增业务接口,不复用 M008 的调试上传接口: ```text POST /api/reservation/invoices/manual-generations Authorization: Bearer Content-Type: application/json ``` 请求体草案: ```json { "hotel_id": "HOTEL-TEST", "source_type": "MANUAL", "task_id": null, "order_id": null, "template_code": "PROFORMA_INVOICE_V1", "invoice_payload": { "document": { "invoice_date": "2026-07-17", "booking_date": "2026-07-12", "due_date": "2026-07-22" }, "recipient": { "company_code": "LIAN_TAI", "contact_id": "LIAN_TAI_KHUN_ANN", "company": "LIAN TAI TRAVEL (THAILAND) CO., LTD.", "attention": "Khun Ann", "address": "2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240", "telephone": "061-397-2675", "email": "op.liantaitravel@gmail.com" }, "booking": { "group_name": "GRP-DEMO-0802", "arrival_date": "2026-08-02", "departure_date": "2026-08-05", "room_rate_note": "includingBF", "extra_bed_rate": 1200 }, "charges": [ { "description": "GRP-DEMO-0802", "room_type": "Deluxe Room", "quantity": 2, "rate": 3000, "nights": 3 } ] } } ``` 中文说明: - `source_type=MANUAL` 表示无订单 / 无任务的手工开票。 - `task_id`、`order_id` 第一阶段允许为空。 - 如果同时传入 `task_id` 和 `order_id`,后端会校验该任务必须挂靠在该订单下;不一致时返回 `RESERVATION_INVOICE_CONTEXT_MISMATCH`。 - `invoice_payload` 中的日期是酒店本地业务日期,使用 `yyyy-MM-dd`。 - 前端可以传金额预览,但后端不得信任;最终金额以服务端计算和模板公式为准。 - 客户目录选择码和手填文本可以同时传;后端第一阶段以文本值生成 PDF,`company_code`、`contact_id` 用于审计和后续配置追溯。 成功响应草案: ```json { "invoice_generation_id": "2080000000000000001", "generation_status": "SUCCEEDED", "source_type": "MANUAL", "hotel_id": "HOTEL-TEST", "template_code": "PROFORMA_INVOICE_V1", "pdf_url": "https://oss.example.test/reservation-invoices/HOTEL-TEST/2026-07-17/.../proforma-invoice.pdf", "pdf_object_key": "reservation-invoices/HOTEL-TEST/2026-07-17/.../proforma-invoice.pdf", "generated_excel_object_key": "reservation-invoices/HOTEL-TEST/2026-07-17/.../proforma-invoice.xlsx", "totals": { "subtotal": 16822.43, "vat": 1177.57, "total": 18000.00, "currency": "THB" }, "created_at": "2026-07-17T03:30:00Z" } ``` ## 6. 建议数据模型 第一阶段建议持久化生成记录,便于审计、排查和后续列表查询。 建议新增表: ```text workflow_reservation_invoice_generation ``` 核心字段: | 字段 | 中文含义 | | --- | --- | | `id` | Invoice 生成记录 ID | | `hotel_id` | 酒店 ID | | `source_type` | 来源类型:`MANUAL`、`TASK`、`ORDER` | | `order_id` | 可选订单 ID | | `task_id` | 可选任务 ID | | `source_message_id` | 可选来源邮件 ID | | `template_code` | 模板编码 | | `template_version` | 模板版本 | | `invoice_payload_json` | 用户提交并经后端归一化后的业务字段 | | `calculated_totals_json` | 后端计算后的金额、税额和合计 | | `generated_excel_object_key` | 生成后的 Excel OSS 对象 Key | | `pdf_object_key` | PDF OSS 对象 Key | | `pdf_url` | PDF 访问 URL | | `generation_status` | 生成状态 | | `safe_error_code` | 安全错误码 | | `safe_error_summary` | 安全错误摘要 | | `created_by` | 创建人 | | `created_at` / `updated_at` | UTC 时间点 | 中文说明: - `generated_excel_object_key`、`pdf_object_key`、`pdf_url` 不只在成功记录中出现;如果流程在后续步骤失败,后端也会保留已经成功上传的对象定位,避免 OSS 生成物变成不可追踪的孤立文件。 - `SUCCEEDED` 只在模板填充、Excel 上传、PDF 转换、PDF 上传和业务审计均完成后写入;如果业务审计失败,不会先标记成功再覆盖为失败。 建议状态: | 状态 | 中文含义 | | --- | --- | | `PENDING` | 已接收,等待生成 | | `RUNNING` | 正在填模板或转换 | | `SUCCEEDED` | 生成成功 | | `FAILED` | 生成失败 | 第一阶段也可以同步生成并直接返回,但仍建议落库,因为这属于业务文档生成,不是一次性调试转换。 ## 7. 后端模块边界 建议放在 Reservation 工作流下,而不是平台文件转换模块: ```text server/src/main/java/cn/nianxx/thhotel/workflows/reservation/invoice ├── control ├── service │ └── impl ├── domain ├── mapper ├── repository └── common ├── dto ├── request ├── result └── enums ``` 中文说明: - `reservation.invoice` 负责业务字段校验、模板字段映射、发票生成记录、权限和审计。 - `platform.documentconversion` 继续作为底层文件转换能力,负责 Excel 到 PDF。 - `integrations.storage.aliyunoss` 继续作为 OSS 能力。 - 前端不能直接调用 `POST /api/system/document-conversions/excel-to-pdf` 来完成业务开票;那是调试 / 后台工具接口。 ## 8. 模板和字段映射 第一阶段建议先使用一个受控模板: ```text server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx ``` 后续可迁移到 OSS + 数据库模板版本管理。 模板字段映射原则: - 模板固定文案、Logo、银行信息、税号可以先保留在模板内。 - 当前 `PROFORMA_INVOICE_V1` 使用 A4 纵向单页模板,打印区域固定为 `A1:H52`,后端渲染时会再次写入纸张、缩放和打印区域,避免 LibreOffice 转 PDF 时分页漂移。 - 可动态变更但不属于单张 Invoice 的配置,后续迁移到酒店发票配置。 - 用户输入字段通过后端模板填充器写入指定单元格。 - 第一版明细行固定支持 1 到 10 行;超过模板默认行数时,后续 checkpoint 再做复制行样式和公式。 - 金额公式由后端统一写入或由模板公式统一生成,不复制历史案例中不一致的公式。 ## 9. 权限、安全和审计 第一阶段建议: - 接口分类:`FRONTEND_USER`,不是 `FRONTEND_DEBUG`。 - 必须 Bearer 登录。 - 权限码建议:`RESERVATION_INVOICE_GENERATE`。 - 必须校验用户对 `hotel_id` 的访问权。 - 如果传入 `task_id` 或 `order_id`,必须反查对象所属酒店并校验与 `hotel_id` 一致;如果两者同时传入,必须校验任务所属订单与 `order_id` 一致。 - 写入业务审计,记录创建人、酒店、来源类型、可选订单 / 任务、模板版本和生成结果。 - 日志不得输出完整 `invoice_payload_json`、客户邮箱、电话、OSS 签名 URL 或 PDF 内容。 ## 10. Checkpoint 规划 ### CP1:文档和页面方向确认 - 落地本文档。 - 明确当前 `invoice.html` 原型可作为前端交互和控件参考。 - 确认第一阶段是手工模式,不强依赖订单和任务。 ### CP2:后端手工生成接口 - 已完成:新增业务接口 `POST /api/reservation/invoices/manual-generations`。 - 已完成:新增生成记录表 `workflow_reservation_invoice_generation` 和业务审计。 - 已完成:根据请求 payload 填充受控 Excel 模板 `templates/reservation-invoice/proforma-invoice-v1.xlsx`。 - 已完成:模板切换为接近酒店历史样张的 A4 单页版式,后端渲染器固定 `A1:H52` 打印区域,避免生成 PDF 被拆成多页。 - 已完成:复用平台 Excel 转 PDF Adapter 生成 PDF,并上传阿里云 OSS。 - 已完成:返回 PDF URL、对象 Key、金额摘要和生成状态。 - 已完成:失败记录保留已上传成功的 Excel / PDF object key;业务审计完成后才标记 `SUCCEEDED`。 - 第一版限制:只支持 `source_type=MANUAL`、`template_code=PROFORMA_INVOICE_V1`、最多 10 条费用明细;暂不提供历史列表、详情查询、任务 / 订单预填和客户联系人目录查询接口。 ### CP3:前端 Manual Invoice 页面(已完成 V1) - 已完成:基于当前 `invoice.html` 原型改造成前端项目内 `/reservation/invoices/new` 页面。 - 已完成:页面按 `01 Basic Information`、`02 Please refer to the following reservation`、`03 Description&Amount` 三段式结构展示;标题、关键输入占位符和主要操作按钮已接入中 / 英 / 泰 i18n,其余原型字段标签后续按需求统一处理。 - 已完成:字段控件按“输入 / 选择 + 可手填 / 只读计算 / 模板配置”实现,第一条费用明细和预订摘要区保持联动。 - 已完成:提交到后端业务接口,不调用调试上传接口。 - 已完成:展示 PDF 生成结果、错误提示和下载 / 预览入口。 ### CP4:任务 / 订单预填 - 从任务详情进入时,使用任务详情 `fields[]`、草稿或确认 payload 预填。 - 从订单详情进入时,要求用户选择具体任务或确认仅使用订单摘要。 - 保持用户可以修改字段后再生成 PDF。 ### CP5:配置化和历史查询 - 客户 / 联系人目录、房型、Rate、Extra Bed、银行账户和模板版本逐步配置化。 - 增加 Invoice 生成历史列表和详情。 - 明确 OSS 生命周期、重新生成、作废和审计策略。 ## 11. 当前待确认事项 已确认并落地: - 第一阶段接口路径采用 `POST /api/reservation/invoices/manual-generations`。 - PDF 生成记录第一阶段必须落库。 - 生成后的 Excel 同步上传 OSS 并保存对象 Key,便于排查。 - Invoice Date 第一阶段允许前端提交,后端按酒店本地业务日期字段处理。 - 发票模板初始文件使用当前 `Invoice Templete.xlsx` 改造成受控模板资源。 - VAT 第一阶段固定按 7% 含税反算。 仍待后续确认: - 客户 / 联系人目录是否进入后端配置 / 数据库维护,还是继续由前端临时静态维护。 - 是否需要 Invoice 历史列表、详情、重新生成和作废接口。 - 是否需要支持超过 10 条费用明细、复制模板行样式和分页。 - 任务 / 订单预填时字段优先级和冲突提示规则。