From 11478c69132a120a157ea3f8e22cb19a58fc4afb Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 17 Jul 2026 11:24:51 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=89=8B=E5=B7=A5=E5=8F=91?= =?UTF-8?q?=E7=A5=A8=E7=94=9F=E6=88=90=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/README.md | 2 + docs/project/frontend-backend/README.md | 4 + .../backend-to-frontend-notes.md | 22 + docs/project/go-live-notes.md | 12 +- .../M009-manual-invoice-generation-v1.md | 401 +++++++++++++ .../security-access-control-boundary.md | 2 + .../2026-07-17-m009-manual-invoice-backend.md | 29 + server/pom.xml | 6 + .../common/enums/PlatformPermissionCode.java | 1 + .../impl/PlatformIdentityBootstrapRunner.java | 4 +- .../ReservationInvoiceGenerationDraft.java | 33 ++ .../common/dto/ReservationInvoiceTotals.java | 19 + .../ReservationInvoiceGenerationStatus.java | 11 + .../enums/ReservationInvoiceSourceType.java | 10 + .../ReservationInvoiceBookingRequest.java | 28 + .../ReservationInvoiceChargeRequest.java | 23 + .../ReservationInvoiceDocumentRequest.java | 21 + ...rvationInvoiceManualGenerationRequest.java | 29 + .../ReservationInvoicePayloadRequest.java | 19 + .../ReservationInvoiceRecipientRequest.java | 27 + .../ReservationInvoiceGenerationResult.java | 41 ++ .../ReservationInvoiceTotalsResult.java | 19 + ...eservationInvoiceGenerationController.java | 47 ++ .../ReservationInvoiceGenerationEntity.java | 92 +++ .../ReservationInvoiceGenerationMapper.java | 12 + ...eservationInvoiceGenerationRepository.java | 108 ++++ ...eservationInvoiceGenerationRepository.java | 41 ++ .../ReservationInvoiceGenerationService.java | 18 + ...servationInvoiceExcelTemplateRenderer.java | 175 ++++++ ...ReservationInvoiceGenerationException.java | 41 ++ ...servationInvoiceGenerationServiceImpl.java | 552 ++++++++++++++++++ ..._create_reservation_invoice_generation.sql | 26 + .../proforma-invoice-v1.xlsx | Bin 0 -> 34139 bytes ...vationInvoiceGenerationControllerTest.java | 400 +++++++++++++ ...ationInvoiceGenerationServiceImplTest.java | 143 +++++ 35 files changed, 2416 insertions(+), 2 deletions(-) create mode 100644 docs/project/requirements/M009-manual-invoice-generation-v1.md create mode 100644 docs/superpowers/plans/2026-07-17-m009-manual-invoice-backend.md create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceGenerationDraft.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceTotals.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceGenerationStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceSourceType.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceBookingRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceChargeRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceDocumentRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceManualGenerationRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoicePayloadRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceRecipientRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceGenerationResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceTotalsResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationInvoiceGenerationEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationInvoiceGenerationMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationInvoiceGenerationRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationInvoiceGenerationRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationInvoiceGenerationService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationException.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java create mode 100644 server/src/main/resources/db/migration/V22__create_reservation_invoice_generation.sql create mode 100644 server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImplTest.java diff --git a/docs/project/README.md b/docs/project/README.md index 5d724db..8031c94 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -44,6 +44,7 @@ | `requirements/M006-system-admin-management-console-v1.md` | 草案 | M006 系统管理后台方案,覆盖用户、角色、权限、菜单、酒店和用户酒店授权维护。 | | `requirements/M007-agentbus-superagent-auto-dispatch-v1.md` | 当前有效 | M007 AgentBus 新邮件入库后异步分发 SuperAgent 的后端 V1 方案,当前默认关闭,等待测试机联调。 | | `requirements/M008-excel-to-pdf-conversion-v1.md` | 当前有效 | M008 Excel 转 PDF 文件转换能力方案,记录 LibreOffice headless、手动上传转换、邮件附件自动派生 PDF 和部署要求;CP2 已实现手动上传后端接口。 | +| `requirements/M009-manual-invoice-generation-v1.md` | 当前有效 | M009 Manual Invoice 手工开票生成方案;后端 CP2 已支持无订单 / 无任务手工填写、填充 Excel 模板、转 PDF、OSS 输出和生成记录。 | ## 集成契约 @@ -67,6 +68,7 @@ | 文档 | 状态 | 中文说明 | | --- | --- | --- | | `../superpowers/plans/2026-07-10-m006-system-admin-v1.md` | 阶段记录 | M006 系统管理后台实现计划,作为执行 checkpoint 参考,不替代需求文档。 | +| `../superpowers/plans/2026-07-17-m009-manual-invoice-backend.md` | 阶段记录 | M009 Manual Invoice 后端 CP2 实现计划,记录本次接口、表、权限、模板和测试范围。 | ## 权威来源说明 diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 338d7bf..8bb3d49 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -14,6 +14,7 @@ | `frontend-to-backend-api-requests.md` | 前端提醒后端需要增加或补齐的接口,包含建议入参和返参草案。 | | `debug-eml-page-integration-guide.md` | Debug EML 页面前端对接指南,包含页面结构、上传接口、响应展示、错误处理和安全注意事项。 | | `../backend-time-design.md` | 时间设计说明,包含数据库 UTC、API `Z` 时间、酒店时区展示和本地日期筛选规则。 | +| `../security-access-control-boundary.md` | 接口暴露、权限和审计边界总表,前端判断普通业务、系统管理、Debug 和第三方接口边界时必须参考。 | ## 3. 当前字段来源分工 @@ -39,10 +40,12 @@ | --- | --- | --- | | SuperAgent HTTP 对外总契约 | `docs/project/integrations/superagent-api-contract.md` | 权威契约,包含查询上下文、对象详情、邮件会话任务、邮件会话正文、任务结果通知和统一 HMAC 规则。 | | SuperAgent MCP tools | `docs/project/integrations/superagent-mcp/README.md` | MCP 对外交付资料包,tools 字段语义应跟随 SuperAgent HTTP 对外总契约。 | +| 接口暴露、权限和审计边界 | `docs/project/security-access-control-boundary.md` | 前端判断普通业务、系统管理、Debug、第三方接口、权限码和敏感数据边界的总表。 | | SuperAgent 任务结果入站接口 | `docs/project/requirements/M002-superagent-task-result-api-contract.md` | 阶段记录,用于理解 M002 接收 AI 结果的落地细节;如与总契约冲突,以总契约为准。 | | SuperAgent 查询上下文接口 1、2 | `docs/project/requirements/M002-ai-query-minimal-fields.md` | 阶段记录,用于理解接口 1、2 的最小字段实现;如与总契约冲突,以总契约为准。 | | 订单任务主流程 V3 | `docs/project/requirements/M002-order-task-workflow-v3.md` | 当前开发基线,基于 0711 P0 冻结基线和 0712 P0.1 Parent Group 修订,覆盖 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed。 | | 任务卡字段控件契约 V1 | `docs/project/requirements/M002-task-field-control-contract-v1.md` | 后端已返回 `fields[]` 控件元数据,规定人工复核控件复用和前后端边界;前端待接入。 | +| Manual Invoice 手工开票生成 | `docs/project/requirements/M009-manual-invoice-generation-v1.md` | 当前有效;后端 CP2 已支持无订单 / 无任务手工填写字段、填 Excel 模板、转 PDF、OSS 输出和生成记录。 | | 订单任务主流程 V2 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 已实现阶段记录,保留用于理解当前代码中的 S000/S999、订单任务流转和 OPERA 模拟骨架。 | | 后端 checkpoint | `docs/project/requirements/M002-backend-checkpoint-plan.md` | 阶段记录,用于理解后端拆分和验收。 | | 前端可用接口与待补接口 | `docs/project/frontend-backend/frontend-to-backend-api-requests.md` | 前后端协作清单,已区分可用、后置和历史候选路径,不替代后端权威契约。 | @@ -57,6 +60,7 @@ - 用户 / 权限底座后端 CP1 已完成;前端登录页、动态菜单、管理后台和业务审计 actor 全量迁移仍后置。 - 真实 OPERA / OHIP 接入后置;当前仅有 OPERA 模拟骨架。 - 任务卡字段控件契约 V1 后端第一版已完成,任务详情 `fields[]` 已返回 `control_type/edit_scope/write_target/options_source/raw_readonly/control_hint`;前端后续按契约接入,不要硬编码 PMS 房型、Rate Code 或未冻结枚举。 +- Manual Invoice 第一阶段按 M009 推进:后端已提供 `POST /api/reservation/invoices/manual-generations`,页面可以不依赖订单或任务,用户手工填写 / 选择字段后由后端业务接口填充 Excel 模板并生成 PDF;前端不得直接调用 M008 的调试上传转换接口来完成业务开票。 ## 6. 前端开发注意事项 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index ad647ec..e971243 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -377,6 +377,28 @@ hotel_id: 可选;用于 OSS 对象路径分组 - `DOCUMENT_CONVERSION_TIMEOUT` / `DOCUMENT_CONVERSION_FAILED` 通常需要后端排查 LibreOffice、字体、文件格式或临时目录权限。 - CP2 不会创建转换任务记录,也不会自动处理邮件附件;邮件附件自动派生 PDF 是 M008 后续 checkpoint。 +### 5.11 Manual Invoice 手工开票页面接入方向 + +M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填写 / 选择字段后由后端填充 Excel 模板、转换 PDF 并上传 OSS。 + +前端注意: + +- 当前 `invoice.html` 可以作为交互原型和控件参考,但不能直接作为生产页面上线。 +- 第一阶段入口建议是独立页面,例如 `/reservation/invoices/new` 或 `/invoices/new`;不要求必须从任务详情或订单详情进入。 +- 无订单 / 无任务时,页面按 `source_type=MANUAL` 提交,`task_id` 和 `order_id` 可以为空。 +- 从任务进入时,后续可以使用 `GET /api/reservation/tasks/{taskId}` 的 `fields[]`、草稿或确认 payload 预填;从订单进入时,需要明确选择具体任务或提示仅使用订单摘要,避免一个订单多任务时字段来源不清。 +- Company、Attention、Address、Tel、Email、Booking Date 第一阶段可以按“选择 + Manual 手填”控件处理。 +- Company、Attention、Address、Tel、Email 是一组收件方联系人档案,不是五个互相独立字段;选择 Company 后应刷新 Attention 候选,选择 Attention 后应带出 Address、Tel、Email。Booking Date 展示在同一区域,但不属于联系人档案,应作为 `document.booking_date` 独立提交。 +- 第一阶段已确认三组客户 / 旅行社种子数据:`LIAN_TAI` / `LIAN TAI TRAVEL (THAILAND) CO., LTD.` + `Khun Ann`;`QBD` / `Q.B.D. TRAVEL GROUP CO., LTD` + `Jitdanun Panaphuchong`;`HANATOUR` / `HANATOUR TD CO., LTD.` + 7 个联系人。完整数据以 `docs/project/requirements/M009-manual-invoice-generation-v1.md` 为准。 +- Room Type、Room Rate、Extra Bed 建议也预留“选择 + 可手填 / 可覆盖”能力,后续接 PMS 房型、Rate Code 或价格配置。 +- Amount、Sub-Total、VAT、Total 是只读计算字段;前端可展示预览,但最终金额以后端计算和模板公式为准。 +- 酒店名称、Tax ID、法人主体、银行账户、Logo 和固定付款文案不应作为每张 Invoice 的手工输入;第一阶段可由模板或酒店发票配置提供。 +- 正式业务生成接口为 `POST /api/reservation/invoices/manual-generations`,需要 Bearer token、酒店访问权和 `RESERVATION_INVOICE_GENERATE` 权限。 +- 第一版后端只支持 `source_type=MANUAL`,`task_id` / `order_id` 可以为空;传入时后端会反查对象所属酒店;如果两者同时传入,任务必须属于该订单,否则返回 `RESERVATION_INVOICE_CONTEXT_MISMATCH`。 +- 第一版后端最多支持 10 条费用明细;超过 10 条会返回 `RESERVATION_INVOICE_VALIDATION_FAILED`。 +- 第一版暂未提供 Invoice 历史列表、详情查询、任务 / 订单预填接口和客户联系人目录查询接口;前端客户 / 联系人候选可先按 M009 文档中的种子数据实现。 +- 前端不得直接调用 `POST /api/system/document-conversions/excel-to-pdf` 来完成业务开票;该接口是 M008 调试 / 后台工具能力,受 access key 控制,不具备业务开票审计和权限边界。 + ## 6. 不给前端直接调用的接口 - `POST /api/system/reservation/demo-data` 只用于 dev/test 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index c712df1..9f485a7 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -19,6 +19,7 @@ - SuperAgent 查询上下文接口 1、2:支持 HMAC 鉴权的订单上下文查询和对象详情查询。 - Debug EML 上传到 SuperAgent 调试链路:受控上传 `.eml`、转存阿里云 OSS、写入 SourceMessage Inbox、调用 SuperAgent Open API 并返回调试结果。 - Excel 转 PDF 手动上传接口:受控上传 `.xls` / `.xlsx`,通过 LibreOffice headless 转 PDF 后上传阿里云 OSS 并返回 PDF URL。 +- Reservation Manual Invoice 后端生成接口:登录用户可通过 `POST /api/reservation/invoices/manual-generations` 手工生成 Proforma Invoice,后端填充受控 Excel 模板、转 PDF、上传 OSS,并写入生成记录和业务审计。 - 登录权限底座:支持用户名密码登录、登出、当前用户上下文、数据库 session token、可访问酒店、权限码和可见菜单。 - 系统管理后台 V1:支持用户、角色权限、菜单、酒店和管理操作审计的受控维护接口与前端页面。 @@ -35,7 +36,7 @@ - 现有业务接口强制登录和强制权限拦截。 - 业务审计 actor 全量迁移到当前登录用户。 - Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;即使已有登录权限,也不要开放给普通用户。 -- Excel 转 PDF 当前只完成手动上传后端接口;邮件附件自动转换、持久化转换任务和 worker 尚未实现。生产默认关闭,启用前必须确认 LibreOffice、字体、OSS、临时目录和访问口令。 +- Excel 转 PDF 当前只完成手动上传后端接口和 M009 Manual Invoice 内部复用;邮件附件自动转换、持久化转换任务和 worker 尚未实现。生产启用前必须确认 LibreOffice、字体、OSS、临时目录和访问口令。 ## 2. 上线前必须确认 @@ -223,6 +224,10 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系: - 当前接口只支持 `.xls` / `.xlsx`,不支持 `.xlsm`;后端会校验扩展名和文件头,改后缀的非 Excel 文件会返回受控错误。 - PDF 上传到阿里云 OSS,返回 `pdf_url`、`object_key`、`pdf_file_name`、`pdf_size_bytes` 和 `duration_millis`。 - CP2 不落库,不提供转换历史查询;如果需要自动处理邮件附件,应先进入 M008 后续持久化任务和 worker checkpoint。 +- M009 Manual Invoice 不调用上述调试接口,也不使用 `X-TH-Hotel-Document-Conversion-Key`;它通过登录 Bearer token、`RESERVATION_INVOICE_GENERATE` 权限和后端内部转换 Adapter 生成 PDF。 +- M009 Manual Invoice 当前模板资源为 `server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx`,部署包必须包含该资源。 +- M009 Manual Invoice 会写入 `workflow_reservation_invoice_generation`,上线前需确认 Flyway 已执行到 V22。 +- M009 Manual Invoice 失败记录也可能保留已上传成功的 Excel / PDF object key;排查或清理 OSS 生成物时应以生成记录为主,不只看 `SUCCEEDED` 状态。 ## 4. 数据库上线注意事项 @@ -253,6 +258,10 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系: - `server/src/main/resources/db/migration/V20__create_superagent_dispatch_run.sql` +当前 M009 Manual Invoice 相关 migration: + +- `server/src/main/resources/db/migration/V22__create_reservation_invoice_generation.sql` + 当前 M003 登录权限相关 migration: - `server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql` @@ -268,6 +277,7 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系: - 目标数据库为空库或 Flyway history 与当前代码一致。 - 如果某个环境已经在缺少 V10 的临时提交上执行过 V11 / V12,不能直接用默认 Flyway 策略补跑 V10;应先重建测试库,或按运维窗口明确 out-of-order / repair 策略。 - V21 会为 `workflow_reservation_order` 增加 `latest_activity_at`,并按订单更新时间和历史任务最新来源 / 创建时间回填一次;上线后订单列表依赖该字段排序,不再在列表查询时聚合全量任务。发布后需要确认 Flyway 已执行到 V21,且订单列表能按最新业务活动倒序返回。 +- V22 会新增 `workflow_reservation_invoice_generation`,用于记录 Manual Invoice 生成状态、Excel / PDF OSS 对象、金额摘要和安全错误摘要;发布后需要确认 `RESERVATION_INVOICE_GENERATE` 权限已由启动同步写入平台权限表,预订操作员或目标角色已拥有该权限。 - MySQL 版本满足项目要求,默认使用 MySQL 8.0+。 - migration 在 UAT 或测试库已经跑过。 - 表和字段中文注释能正常创建。 diff --git a/docs/project/requirements/M009-manual-invoice-generation-v1.md b/docs/project/requirements/M009-manual-invoice-generation-v1.md new file mode 100644 index 0000000..51d2347 --- /dev/null +++ b/docs/project/requirements/M009-manual-invoice-generation-v1.md @@ -0,0 +1,401 @@ +# M009 Manual Invoice 手工开票生成 V1 + +| 项目 | 内容 | +| --- | --- | +| 文档状态 | CP2 后端已实现,前端 CP3 待开发 | +| 适用范围 | 无订单 / 无任务场景下的手工 Invoice 创建,以及后续从任务或订单预填 Invoice | +| 当前目标 | 第一阶段基于现有 `invoice.html` 原型落地 Manual Invoice Mode;后端已支持手工生成 PDF,前端页面待接入 | +| 依赖能力 | 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、银行信息、税号可以先保留在模板内。 +- 可动态变更但不属于单张 Invoice 的配置,后续迁移到酒店发票配置。 +- 用户输入字段通过后端模板填充器写入指定单元格。 +- 明细行需要支持 1 到 N 行;超过模板默认行数时,后端应复制行样式和公式。 +- 金额公式由后端统一写入或由模板公式统一生成,不复制历史案例中不一致的公式。 + +## 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`。 +- 已完成:复用平台 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 页面 + +- 基于当前 `invoice.html` 原型改造成前端项目内页面。 +- 字段控件按“输入 / 选择 + 可手填 / 只读计算 / 模板配置”实现。 +- 提交到后端业务接口,不调用调试上传接口。 +- 展示 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 条费用明细、复制模板行样式和分页。 +- 任务 / 订单预填时字段优先级和冲突提示规则。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 037b07e..52f5f3c 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -54,6 +54,7 @@ | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt | | `GET /api/reservation/tasks/{taskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 酒店访问权 | 查询审计不再写审计 | +| `POST /api/reservation/invoices/manual-generations` | `FRONTEND_USER` | 已实现 M009 CP2;强制 Bearer 登录 + `RESERVATION_INVOICE_GENERATE` + 酒店访问权;`task_id` / `order_id` 可为空,传入时反查对象所属酒店 | 保持登录 + `RESERVATION_INVOICE_GENERATE` + 酒店访问权;后续如增加历史列表或预填接口需单独登记权限 | 写业务审计,记录来源类型、模板版本、生成结果摘要;生成失败写入生成记录安全错误摘要 | ### 3.3 来源邮件接口 @@ -122,6 +123,7 @@ | `RESERVATION_MANUAL_REVIEW_RESOLVE` | 处理人工复核和 Fallback 转换 | 复核解阻、Fallback 转换 | | `RESERVATION_OPERA_SIM_EXECUTE` | 执行或重试 OPERA 模拟 / 未来真实操作 | OPERA execute / retry | | `RESERVATION_AUDIT_READ` | 查看业务审计流水 | 任务审计列表 | +| `RESERVATION_INVOICE_GENERATE` | 生成 Reservation Proforma Invoice | Manual Invoice 生成、未来任务 / 订单预填生成 | | `SOURCE_MESSAGE_READ` | 查看来源邮件安全摘要 | SourceMessage 列表、详情、会话摘要 | | `SOURCE_MESSAGE_ORIGINAL_READ` | 查看邮件正文、HTML 和附件外链 | original / conversation 完整正文;必须叠加 `SOURCE_MESSAGE_READ` 使用 | | `SYSTEM_DEBUG_EML_RUN` | 使用 Debug EML 调试链路 | Debug EML 上传、查询、stream | diff --git a/docs/superpowers/plans/2026-07-17-m009-manual-invoice-backend.md b/docs/superpowers/plans/2026-07-17-m009-manual-invoice-backend.md new file mode 100644 index 0000000..af1194e --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-m009-manual-invoice-backend.md @@ -0,0 +1,29 @@ +# M009 Manual Invoice 后端 CP2 实现计划 + +## 目标 + +落地 `POST /api/reservation/invoices/manual-generations`,支持手工填写 Proforma Invoice 后生成 Excel、转换 PDF、上传 OSS,并写入生成记录和审计。 + +## 范围 + +- 新增 `workflow_reservation_invoice_generation` 表,记录生成状态、请求 payload、计算金额、Excel / PDF OSS Key、创建人和安全错误摘要。 +- 新增 Reservation Invoice 后端模块,保持 `control`、`service`、`service.impl`、`domain`、`mapper`、`repository`、`common.request/result/dto/enums` 分层。 +- 新增权限码 `RESERVATION_INVOICE_GENERATE`,加入预订操作员和系统管理员授权。 +- 使用受控 Excel 模板 `templates/reservation-invoice/proforma-invoice-v1.xlsx`,由后端写入用户字段和金额公式。 +- 复用 `ExcelToPdfConverter` 和 `ObjectStorageService`,不让前端直接调用 M008 调试转换接口。 +- 接口要求 Bearer 登录、权限码和酒店访问权;`task_id` / `order_id` 第一版可为空,传入时校验对象酒店归属。 + +## 不做 + +- 不做发票历史列表和详情。 +- 不做任务 / 订单预填接口。 +- 不做客户联系人目录维护后台。 +- 不做真实财务入账、收款核销、税务发票编号。 +- 不做前端页面。 + +## 验证 + +1. 新增 MockMvc 测试覆盖成功生成、缺少登录、缺少权限、字段校验失败。 +2. Mock `ExcelToPdfConverter` 和 `ObjectStorageService`,确认传入转换的是后端生成的 xlsx 字节,PDF 上传 Key 使用 `reservation-invoices/{hotel_id}/...`。 +3. 校验生成记录入库,响应返回 `invoice_generation_id`、`pdf_url`、`pdf_object_key`、`generated_excel_object_key` 和服务端计算 totals。 +4. 运行新增测试和相关后端测试。 diff --git a/server/pom.xml b/server/pom.xml index 82f19a7..fd8f048 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -25,6 +25,7 @@ 2.8.17 2.0.3 3.18.3 + 5.4.1 @@ -78,6 +79,11 @@ aliyun-sdk-oss ${aliyun-oss.version} + + org.apache.poi + poi-ooxml + ${apache-poi.version} + com.h2database diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java index 9951b54..9400b2d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java @@ -12,6 +12,7 @@ public enum PlatformPermissionCode { RESERVATION_TASK_CONFIRM, RESERVATION_OPERA_SIM_EXECUTE, RESERVATION_AUDIT_READ, + RESERVATION_INVOICE_GENERATE, HOTEL_SWITCH, SYSTEM_AUTH_READ, SYSTEM_USER_MANAGE, diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java index 603c6e1..9788703 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java @@ -290,6 +290,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { PlatformPermissionCode.RESERVATION_TASK_CONFIRM, PlatformPermissionCode.RESERVATION_OPERA_SIM_EXECUTE, PlatformPermissionCode.RESERVATION_AUDIT_READ, + PlatformPermissionCode.RESERVATION_INVOICE_GENERATE, PlatformPermissionCode.SOURCE_MESSAGE_READ, PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ)); matrix.put(PlatformRoleCode.RESERVATION_VIEWER, List.of( @@ -315,6 +316,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { case RESERVATION_TASK_CONFIRM -> "确认任务"; case RESERVATION_OPERA_SIM_EXECUTE -> "执行 OPERA 模拟"; case RESERVATION_AUDIT_READ -> "读取任务审计"; + case RESERVATION_INVOICE_GENERATE -> "生成预订发票"; case HOTEL_SWITCH -> "切换酒店"; case SYSTEM_AUTH_READ -> "读取当前登录上下文"; case SYSTEM_USER_MANAGE -> "管理用户"; @@ -334,7 +336,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { case SOURCE_MESSAGE_READ, SOURCE_MESSAGE_ORIGINAL_READ -> "SOURCE_MESSAGE"; case RESERVATION_ORDER_READ, RESERVATION_TASK_READ, RESERVATION_TASK_EDIT, RESERVATION_TASK_CONFIRM, RESERVATION_OPERA_SIM_EXECUTE, - RESERVATION_AUDIT_READ -> "RESERVATION"; + RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE -> "RESERVATION"; case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL"; default -> "SYSTEM"; }; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceGenerationDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceGenerationDraft.java new file mode 100644 index 0000000..0ab79f1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceGenerationDraft.java @@ -0,0 +1,33 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation Invoice 生成记录入库草稿。 + * + * @param hotelId 酒店 ID + * @param sourceType 来源类型 + * @param orderId 可选订单 ID + * @param taskId 可选任务 ID + * @param sourceMessageId 可选来源消息 ID + * @param templateCode 模板编码 + * @param templateVersion 模板版本 + * @param invoicePayloadJson 归一化后的业务字段 JSON + * @param generationStatus 生成状态 + * @param createdBy 创建人标识 + * @param now 记录创建和更新 UTC 时间 + */ +public record ReservationInvoiceGenerationDraft( + String hotelId, + String sourceType, + Long orderId, + Long taskId, + Long sourceMessageId, + String templateCode, + String templateVersion, + String invoicePayloadJson, + String generationStatus, + String createdBy, + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceTotals.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceTotals.java new file mode 100644 index 0000000..43d4431 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationInvoiceTotals.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.math.BigDecimal; + +/** + * Reservation Invoice 金额计算结果。后端统一计算,不信任前端金额预览。 + * + * @param subtotal 未税金额 + * @param vat VAT 金额 + * @param total 含税总金额 + * @param currency 币种 + */ +public record ReservationInvoiceTotals( + BigDecimal subtotal, + BigDecimal vat, + BigDecimal total, + String currency +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceGenerationStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceGenerationStatus.java new file mode 100644 index 0000000..8abd368 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceGenerationStatus.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation Invoice 生成状态。用于区分同步生成过程和最终结果。 + */ +public enum ReservationInvoiceGenerationStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceSourceType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceSourceType.java new file mode 100644 index 0000000..9d3bf01 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationInvoiceSourceType.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation Invoice 生成来源类型。第一版只开放 MANUAL,TASK / ORDER 为后续预填保留。 + */ +public enum ReservationInvoiceSourceType { + MANUAL, + TASK, + ORDER +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceBookingRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceBookingRequest.java new file mode 100644 index 0000000..5673374 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceBookingRequest.java @@ -0,0 +1,28 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * Invoice 预订摘要字段。 + * + * @param groupName Group Name 或 Group Code + * @param arrivalDate 到店日期 + * @param departureDate 离店日期 + * @param roomRateNote 房价备注 + * @param extraBedRate 加床价格 + */ +public record ReservationInvoiceBookingRequest( + @JsonProperty("group_name") + String groupName, + @JsonProperty("arrival_date") + LocalDate arrivalDate, + @JsonProperty("departure_date") + LocalDate departureDate, + @JsonProperty("room_rate_note") + String roomRateNote, + @JsonProperty("extra_bed_rate") + BigDecimal extraBedRate +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceChargeRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceChargeRequest.java new file mode 100644 index 0000000..3831639 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceChargeRequest.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; + +/** + * Invoice 费用明细行。金额由后端按 quantity * rate * nights 计算。 + * + * @param description 明细描述 + * @param roomType 房型文本 + * @param quantity 数量 + * @param rate 单价 + * @param nights 晚数 + */ +public record ReservationInvoiceChargeRequest( + String description, + @JsonProperty("room_type") + String roomType, + BigDecimal quantity, + BigDecimal rate, + BigDecimal nights +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceDocumentRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceDocumentRequest.java new file mode 100644 index 0000000..24d4bd4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceDocumentRequest.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDate; + +/** + * Invoice 单据日期字段。日期为酒店本地业务日期,不是 UTC 时间点。 + * + * @param invoiceDate Invoice 日期 + * @param bookingDate Booking Date + * @param dueDate Due Date + */ +public record ReservationInvoiceDocumentRequest( + @JsonProperty("invoice_date") + LocalDate invoiceDate, + @JsonProperty("booking_date") + LocalDate bookingDate, + @JsonProperty("due_date") + LocalDate dueDate +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceManualGenerationRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceManualGenerationRequest.java new file mode 100644 index 0000000..8352410 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceManualGenerationRequest.java @@ -0,0 +1,29 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 手工生成 Reservation Invoice 请求。 + * + * @param hotelId 酒店 ID,缺省时使用当前用户默认酒店 + * @param sourceType 来源类型,第一版只允许 MANUAL + * @param orderId 可选关联订单 ID + * @param taskId 可选关联任务 ID + * @param templateCode 模板编码,缺省使用 PROFORMA_INVOICE_V1 + * @param invoicePayload Invoice 业务字段 payload + */ +public record ReservationInvoiceManualGenerationRequest( + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("source_type") + String sourceType, + @JsonProperty("order_id") + Long orderId, + @JsonProperty("task_id") + Long taskId, + @JsonProperty("template_code") + String templateCode, + @JsonProperty("invoice_payload") + ReservationInvoicePayloadRequest invoicePayload +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoicePayloadRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoicePayloadRequest.java new file mode 100644 index 0000000..ef6dd03 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoicePayloadRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import java.util.List; + +/** + * Invoice 业务字段 payload。第一版对应当前 HTML 原型的核心字段。 + * + * @param document 单据日期字段 + * @param recipient 收件方和联系人字段 + * @param booking 预订摘要字段 + * @param charges 费用明细 + */ +public record ReservationInvoicePayloadRequest( + ReservationInvoiceDocumentRequest document, + ReservationInvoiceRecipientRequest recipient, + ReservationInvoiceBookingRequest booking, + List charges +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceRecipientRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceRecipientRequest.java new file mode 100644 index 0000000..510716a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationInvoiceRecipientRequest.java @@ -0,0 +1,27 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Invoice 收件方字段。目录 code 用于追溯,实际生成以文本字段为准。 + * + * @param companyCode 公司目录稳定编码 + * @param contactId 联系人目录稳定编码 + * @param company 公司名称 + * @param attention 收件联系人 + * @param address 地址 + * @param telephone 电话 + * @param email 邮箱 + */ +public record ReservationInvoiceRecipientRequest( + @JsonProperty("company_code") + String companyCode, + @JsonProperty("contact_id") + String contactId, + String company, + String attention, + String address, + String telephone, + String email +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceGenerationResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceGenerationResult.java new file mode 100644 index 0000000..ae54fe2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceGenerationResult.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * Reservation Invoice 生成结果。只返回生成物定位和金额摘要,不返回完整用户 payload。 + * + * @param invoiceGenerationId Invoice 生成记录 ID + * @param generationStatus 生成状态 + * @param sourceType 来源类型 + * @param hotelId 酒店 ID + * @param templateCode 模板编码 + * @param pdfUrl PDF 访问 URL + * @param pdfObjectKey PDF OSS 对象 Key + * @param generatedExcelObjectKey 生成 Excel OSS 对象 Key + * @param totals 金额摘要 + * @param createdAt 创建 UTC 时间 + */ +public record ReservationInvoiceGenerationResult( + @JsonProperty("invoice_generation_id") + String invoiceGenerationId, + @JsonProperty("generation_status") + String generationStatus, + @JsonProperty("source_type") + String sourceType, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("template_code") + String templateCode, + @JsonProperty("pdf_url") + String pdfUrl, + @JsonProperty("pdf_object_key") + String pdfObjectKey, + @JsonProperty("generated_excel_object_key") + String generatedExcelObjectKey, + ReservationInvoiceTotalsResult totals, + @JsonProperty("created_at") + OffsetDateTime createdAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceTotalsResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceTotalsResult.java new file mode 100644 index 0000000..6fb2d48 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationInvoiceTotalsResult.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import java.math.BigDecimal; + +/** + * Invoice 金额响应摘要。金额由后端计算后返回给前端展示。 + * + * @param subtotal 未税金额 + * @param vat VAT 金额 + * @param total 含税总金额 + * @param currency 币种 + */ +public record ReservationInvoiceTotalsResult( + BigDecimal subtotal, + BigDecimal vat, + BigDecimal total, + String currency +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationController.java new file mode 100644 index 0000000..8c84de7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationController.java @@ -0,0 +1,47 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceManualGenerationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationInvoiceGenerationResult; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationInvoiceGenerationService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reservation Invoice 生成接口。第一版只提供手工生成入口。 + */ +@RestController +@RequestMapping("/api/reservation/invoices") +public class ReservationInvoiceGenerationController { + + private final FrontendAuthorizationService authorizationService; + private final ReservationInvoiceGenerationService invoiceGenerationService; + + /** + * 注入前端鉴权服务和 Invoice 生成服务。 + */ + public ReservationInvoiceGenerationController( + FrontendAuthorizationService authorizationService, + ReservationInvoiceGenerationService invoiceGenerationService) { + this.authorizationService = authorizationService; + this.invoiceGenerationService = invoiceGenerationService; + } + + /** + * 手工生成 Proforma Invoice。需要登录、发票生成权限和酒店访问权。 + */ + @PostMapping("/manual-generations") + public ResponseEntity generateManualInvoice( + @RequestBody ReservationInvoiceManualGenerationRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_INVOICE_GENERATE.name()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(invoiceGenerationService.generateManualInvoice(request, actor)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationInvoiceGenerationEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationInvoiceGenerationEntity.java new file mode 100644 index 0000000..0e71647 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationInvoiceGenerationEntity.java @@ -0,0 +1,92 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation Proforma Invoice 生成记录实体。 + */ +@TableName("workflow_reservation_invoice_generation") +public class ReservationInvoiceGenerationEntity { + + /** Invoice 生成记录 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店 ID。 */ + private String hotelId; + /** 生成来源类型:MANUAL、TASK、ORDER。 */ + private String sourceType; + /** 可选关联订单 ID。 */ + private Long orderId; + /** 可选关联任务 ID。 */ + private Long taskId; + /** 可选来源消息 ID。 */ + private Long sourceMessageId; + /** 模板编码。 */ + private String templateCode; + /** 模板版本。 */ + private String templateVersion; + /** 归一化后的 Invoice 业务字段 JSON。 */ + private String invoicePayloadJson; + /** 后端计算金额摘要 JSON。 */ + private String calculatedTotalsJson; + /** 生成 Excel OSS 对象 Key。 */ + private String generatedExcelObjectKey; + /** 生成 PDF OSS 对象 Key。 */ + private String pdfObjectKey; + /** 生成 PDF 访问 URL。 */ + private String pdfUrl; + /** 生成状态。 */ + private String generationStatus; + /** 安全错误码。 */ + private String safeErrorCode; + /** 安全错误摘要。 */ + private String safeErrorSummary; + /** 创建人用户标识。 */ + private String createdBy; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public String getSourceType() { return sourceType; } + public void setSourceType(String sourceType) { this.sourceType = sourceType; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long orderId) { this.orderId = orderId; } + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + public Long getSourceMessageId() { return sourceMessageId; } + public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; } + public String getTemplateCode() { return templateCode; } + public void setTemplateCode(String templateCode) { this.templateCode = templateCode; } + public String getTemplateVersion() { return templateVersion; } + public void setTemplateVersion(String templateVersion) { this.templateVersion = templateVersion; } + public String getInvoicePayloadJson() { return invoicePayloadJson; } + public void setInvoicePayloadJson(String invoicePayloadJson) { this.invoicePayloadJson = invoicePayloadJson; } + public String getCalculatedTotalsJson() { return calculatedTotalsJson; } + public void setCalculatedTotalsJson(String calculatedTotalsJson) { this.calculatedTotalsJson = calculatedTotalsJson; } + public String getGeneratedExcelObjectKey() { return generatedExcelObjectKey; } + public void setGeneratedExcelObjectKey(String generatedExcelObjectKey) { this.generatedExcelObjectKey = generatedExcelObjectKey; } + public String getPdfObjectKey() { return pdfObjectKey; } + public void setPdfObjectKey(String pdfObjectKey) { this.pdfObjectKey = pdfObjectKey; } + public String getPdfUrl() { return pdfUrl; } + public void setPdfUrl(String pdfUrl) { this.pdfUrl = pdfUrl; } + public String getGenerationStatus() { return generationStatus; } + public void setGenerationStatus(String generationStatus) { this.generationStatus = generationStatus; } + public String getSafeErrorCode() { return safeErrorCode; } + public void setSafeErrorCode(String safeErrorCode) { this.safeErrorCode = safeErrorCode; } + public String getSafeErrorSummary() { return safeErrorSummary; } + public void setSafeErrorSummary(String safeErrorSummary) { this.safeErrorSummary = safeErrorSummary; } + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationInvoiceGenerationMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationInvoiceGenerationMapper.java new file mode 100644 index 0000000..78c00be --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationInvoiceGenerationMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationInvoiceGenerationEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation Invoice 生成记录 Mapper。MyBatis-Plus 自带方法不额外包一层 default。 + */ +@Mapper +public interface ReservationInvoiceGenerationMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationInvoiceGenerationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationInvoiceGenerationRepository.java new file mode 100644 index 0000000..2b729de --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationInvoiceGenerationRepository.java @@ -0,0 +1,108 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationInvoiceGenerationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationInvoiceGenerationStatus; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationInvoiceGenerationEntity; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationInvoiceGenerationMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import org.springframework.stereotype.Repository; + +/** + * Reservation Invoice 生成记录 MyBatis 持久化实现。 + */ +@Repository +public class MybatisReservationInvoiceGenerationRepository implements ReservationInvoiceGenerationRepository { + + private final ReservationInvoiceGenerationMapper mapper; + + /** + * 注入生成记录 Mapper。 + */ + public MybatisReservationInvoiceGenerationRepository(ReservationInvoiceGenerationMapper mapper) { + this.mapper = mapper; + } + + /** + * 新增生成记录,初始状态由调用方决定,便于后续支持异步 outbox。 + */ + @Override + public Long insertGeneration(ReservationInvoiceGenerationDraft draft) { + ReservationInvoiceGenerationEntity entity = new ReservationInvoiceGenerationEntity(); + entity.setHotelId(draft.hotelId()); + entity.setSourceType(draft.sourceType()); + entity.setOrderId(draft.orderId()); + entity.setTaskId(draft.taskId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setTemplateCode(draft.templateCode()); + entity.setTemplateVersion(draft.templateVersion()); + entity.setInvoicePayloadJson(draft.invoicePayloadJson()); + entity.setGenerationStatus(draft.generationStatus()); + entity.setCreatedBy(draft.createdBy()); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + mapper.insert(entity); + return entity.getId(); + } + + /** + * 将生成记录标记为成功,并保存生成物 OSS 定位信息。 + */ + @Override + public void markSucceeded( + Long generationId, + String calculatedTotalsJson, + String generatedExcelObjectKey, + String pdfObjectKey, + String pdfUrl, + LocalDateTime now) { + mapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationInvoiceGenerationEntity::getCalculatedTotalsJson, calculatedTotalsJson) + .set(ReservationInvoiceGenerationEntity::getGeneratedExcelObjectKey, generatedExcelObjectKey) + .set(ReservationInvoiceGenerationEntity::getPdfObjectKey, pdfObjectKey) + .set(ReservationInvoiceGenerationEntity::getPdfUrl, pdfUrl) + .set(ReservationInvoiceGenerationEntity::getGenerationStatus, + ReservationInvoiceGenerationStatus.SUCCEEDED.name()) + .set(ReservationInvoiceGenerationEntity::getSafeErrorCode, null) + .set(ReservationInvoiceGenerationEntity::getSafeErrorSummary, null) + .set(ReservationInvoiceGenerationEntity::getUpdatedAt, now) + .eq(ReservationInvoiceGenerationEntity::getId, generationId)); + } + + /** + * 记录 Excel 生成物对象路径,不改变当前生成状态。 + */ + @Override + public void recordGeneratedExcel(Long generationId, String generatedExcelObjectKey, LocalDateTime now) { + mapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationInvoiceGenerationEntity::getGeneratedExcelObjectKey, generatedExcelObjectKey) + .set(ReservationInvoiceGenerationEntity::getUpdatedAt, now) + .eq(ReservationInvoiceGenerationEntity::getId, generationId)); + } + + /** + * 记录 PDF 生成物对象路径,不改变当前生成状态。 + */ + @Override + public void recordGeneratedPdf(Long generationId, String pdfObjectKey, String pdfUrl, LocalDateTime now) { + mapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationInvoiceGenerationEntity::getPdfObjectKey, pdfObjectKey) + .set(ReservationInvoiceGenerationEntity::getPdfUrl, pdfUrl) + .set(ReservationInvoiceGenerationEntity::getUpdatedAt, now) + .eq(ReservationInvoiceGenerationEntity::getId, generationId)); + } + + /** + * 将生成记录标记为失败,不保存原始异常堆栈或用户输入明细。 + */ + @Override + public void markFailed(Long generationId, String safeErrorCode, String safeErrorSummary, LocalDateTime now) { + mapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationInvoiceGenerationEntity::getGenerationStatus, + ReservationInvoiceGenerationStatus.FAILED.name()) + .set(ReservationInvoiceGenerationEntity::getSafeErrorCode, safeErrorCode) + .set(ReservationInvoiceGenerationEntity::getSafeErrorSummary, safeErrorSummary) + .set(ReservationInvoiceGenerationEntity::getUpdatedAt, now) + .eq(ReservationInvoiceGenerationEntity::getId, generationId)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationInvoiceGenerationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationInvoiceGenerationRepository.java new file mode 100644 index 0000000..e4ea6d7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationInvoiceGenerationRepository.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationInvoiceGenerationDraft; +import java.time.LocalDateTime; + +/** + * Reservation Invoice 生成记录持久化边界。 + */ +public interface ReservationInvoiceGenerationRepository { + + /** + * 新增生成记录,并返回生成记录 ID。 + */ + Long insertGeneration(ReservationInvoiceGenerationDraft draft); + + /** + * 标记生成成功,保存 Excel / PDF 对象路径和金额摘要。 + */ + void markSucceeded( + Long generationId, + String calculatedTotalsJson, + String generatedExcelObjectKey, + String pdfObjectKey, + String pdfUrl, + LocalDateTime now); + + /** + * 记录已上传的 Excel 对象路径。即使后续 PDF 转换失败,也保留排查入口。 + */ + void recordGeneratedExcel(Long generationId, String generatedExcelObjectKey, LocalDateTime now); + + /** + * 记录已上传的 PDF 对象路径。成功状态仍由业务审计完成后统一标记。 + */ + void recordGeneratedPdf(Long generationId, String pdfObjectKey, String pdfUrl, LocalDateTime now); + + /** + * 标记生成失败,只保存安全错误码和安全摘要。 + */ + void markFailed(Long generationId, String safeErrorCode, String safeErrorSummary, LocalDateTime now); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationInvoiceGenerationService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationInvoiceGenerationService.java new file mode 100644 index 0000000..b2a9876 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationInvoiceGenerationService.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceManualGenerationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationInvoiceGenerationResult; + +/** + * Reservation Invoice 生成服务。 + */ +public interface ReservationInvoiceGenerationService { + + /** + * 手工生成 Proforma Invoice。第一版允许不关联订单和任务。 + */ + ReservationInvoiceGenerationResult generateManualInvoice( + ReservationInvoiceManualGenerationRequest request, + AuthenticatedUserContext actor); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java new file mode 100644 index 0000000..c6f9ec2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java @@ -0,0 +1,175 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceChargeRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceManualGenerationRequest; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; +import java.util.List; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellReference; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +/** + * Proforma Invoice Excel 模板渲染器。只负责把业务字段写入模板,不负责 PDF 转换和 OSS 上传。 + */ +@Component +public class ReservationInvoiceExcelTemplateRenderer { + + private static final String TEMPLATE_PATH = "templates/reservation-invoice/proforma-invoice-v1.xlsx"; + private static final DateTimeFormatter DISPLAY_DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + private static final int CHARGE_START_ROW_INDEX = 22; + private static final int DEFAULT_CHARGE_ROW_COUNT = 10; + private static final int SUBTOTAL_ROW_INDEX = 32; + private static final int VAT_ROW_INDEX = 33; + private static final int TOTAL_ROW_INDEX = 34; + + /** + * 使用受控 Excel 模板渲染 Invoice,并返回 xlsx 字节。 + */ + public byte[] render(ReservationInvoiceManualGenerationRequest request) { + try (Workbook workbook = loadTemplateWorkbook(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + Sheet sheet = workbook.getSheetAt(0); + fillHeader(sheet, request); + fillChargeRows(sheet, request.invoicePayload().charges()); + fillTotalFormulas(sheet); + workbook.setForceFormulaRecalculation(true); + workbook.write(outputStream); + return outputStream.toByteArray(); + } catch (IOException exception) { + throw new ReservationInvoiceGenerationException( + org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, + "RESERVATION_INVOICE_TEMPLATE_RENDER_FAILED", + "Invoice 模板渲染失败。"); + } + } + + /** + * 从 classpath 加载受控模板,避免运行时读取用户上传模板。 + */ + private Workbook loadTemplateWorkbook() throws IOException { + return new XSSFWorkbook(new ClassPathResource(TEMPLATE_PATH).getInputStream()); + } + + /** + * 填充 Invoice 抬头、收件人和预订摘要字段。 + */ + private void fillHeader(Sheet sheet, ReservationInvoiceManualGenerationRequest request) { + var payload = request.invoicePayload(); + var document = payload.document(); + var recipient = payload.recipient(); + var booking = payload.booking(); + var firstCharge = payload.charges().get(0); + + setText(sheet, "G5", "Date : " + DISPLAY_DATE_FORMATTER.format(document.invoiceDate())); + setText(sheet, "B6", recipient.attention()); + setText(sheet, "B7", recipient.company()); + setText(sheet, "B8", recipient.address()); + setText(sheet, "B12", recipient.telephone()); + setText(sheet, "B13", recipient.email()); + setText(sheet, "B14", DISPLAY_DATE_FORMATTER.format(document.bookingDate())); + setText(sheet, "G17", "Due Date : " + DISPLAY_DATE_FORMATTER.format(document.dueDate())); + setText(sheet, "B18", booking.groupName()); + setText(sheet, "B19", DISPLAY_DATE_FORMATTER.format(booking.arrivalDate())); + setText(sheet, "B20", DISPLAY_DATE_FORMATTER.format(booking.departureDate())); + setText(sheet, "G18", "Room Rate : " + formatDecimal(firstCharge.rate()) + + " /rm/n" + roomRateNoteSuffix(booking.roomRateNote())); + setText(sheet, "G19", "No.of room(s) : " + formatDecimal(totalQuantity(payload.charges()))); + setText(sheet, "G20", "No.of Night(s) : " + formatDecimal(firstCharge.nights())); + } + + /** + * 填充费用明细。第一版使用模板默认 10 行,超过行数由 Service 层提前拦截。 + */ + private void fillChargeRows(Sheet sheet, List charges) { + clearChargeRows(sheet); + for (int index = 0; index < charges.size(); index++) { + ReservationInvoiceChargeRequest charge = charges.get(index); + int rowIndex = CHARGE_START_ROW_INDEX + index; + Row row = row(sheet, rowIndex); + setCellText(row, 0, charge.description() + "\n" + charge.roomType()); + setCellNumber(row, 3, charge.quantity()); + setCellNumber(row, 4, charge.rate()); + setCellNumber(row, 5, charge.nights()); + setCellFormula(row, 6, "D" + (rowIndex + 1) + "*E" + (rowIndex + 1) + "*F" + (rowIndex + 1)); + } + } + + /** + * 写入模板汇总公式,保持 PDF 转换前由 LibreOffice 重新计算。 + */ + private void fillTotalFormulas(Sheet sheet) { + Row subtotalRow = row(sheet, SUBTOTAL_ROW_INDEX); + Row vatRow = row(sheet, VAT_ROW_INDEX); + Row totalRow = row(sheet, TOTAL_ROW_INDEX); + setCellFormula(subtotalRow, 6, "SUM(G23:G32)/1.07"); + setCellFormula(vatRow, 6, "SUM(G23:G32)-G33"); + setCellFormula(totalRow, 6, "SUM(G23:G32)"); + } + + /** + * 清理模板默认明细行,避免历史模板残留值进入输出文件。 + */ + private void clearChargeRows(Sheet sheet) { + for (int index = 0; index < DEFAULT_CHARGE_ROW_COUNT; index++) { + Row row = row(sheet, CHARGE_START_ROW_INDEX + index); + for (int cellIndex = 0; cellIndex <= 6; cellIndex++) { + Cell cell = cell(row, cellIndex); + cell.setBlank(); + } + } + } + + private Row row(Sheet sheet, int rowIndex) { + Row row = sheet.getRow(rowIndex); + return row == null ? sheet.createRow(rowIndex) : row; + } + + private Cell cell(Row row, int cellIndex) { + Cell cell = row.getCell(cellIndex); + return cell == null ? row.createCell(cellIndex) : cell; + } + + private void setText(Sheet sheet, String cellRef, String value) { + CellReference reference = new CellReference(cellRef); + setCellText(row(sheet, reference.getRow()), reference.getCol(), value); + } + + private void setCellText(Row row, int cellIndex, String value) { + cell(row, cellIndex).setCellValue(value == null ? "" : value); + } + + private void setCellNumber(Row row, int cellIndex, BigDecimal value) { + cell(row, cellIndex).setCellValue(value == null ? 0D : value.doubleValue()); + } + + private void setCellFormula(Row row, int cellIndex, String formula) { + cell(row, cellIndex).setCellFormula(formula); + } + + private BigDecimal totalQuantity(List charges) { + return charges.stream() + .map(ReservationInvoiceChargeRequest::quantity) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private String roomRateNoteSuffix(String note) { + if (note == null || note.isBlank()) { + return ""; + } + return " (" + note.trim() + ")"; + } + + private String formatDecimal(BigDecimal value) { + if (value == null) { + return ""; + } + return value.stripTrailingZeros().toPlainString(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationException.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationException.java new file mode 100644 index 0000000..2cdc29a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationException.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import java.util.List; +import org.springframework.http.HttpStatus; + +/** + * Reservation Invoice 生成受控异常。错误响应不得包含完整用户 payload、PDF 字节或 OSS 签名 URL。 + */ +public class ReservationInvoiceGenerationException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + private final List details; + + public ReservationInvoiceGenerationException(HttpStatus status, String errorCode, String message) { + this(status, errorCode, message, List.of()); + } + + public ReservationInvoiceGenerationException( + HttpStatus status, + String errorCode, + String message, + List details) { + super(message); + this.status = status; + this.errorCode = errorCode; + this.details = details == null ? List.of() : List.copyOf(details); + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } + + public List getDetails() { + return details; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java new file mode 100644 index 0000000..d3d1e5b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImpl.java @@ -0,0 +1,552 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationInvoiceGenerationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationInvoiceTotals; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationInvoiceGenerationStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationInvoiceSourceType; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceChargeRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceManualGenerationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoicePayloadRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationInvoiceGenerationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationInvoiceTotalsResult; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationInvoiceGenerationRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationInvoiceGenerationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +/** + * Reservation Invoice 生成服务实现。负责业务校验、模板渲染、PDF 转换、OSS 上传和生成审计。 + */ +@Service +public class ReservationInvoiceGenerationServiceImpl implements ReservationInvoiceGenerationService { + + private static final String DEFAULT_TEMPLATE_CODE = "PROFORMA_INVOICE_V1"; + private static final String TEMPLATE_VERSION = "v1"; + private static final String CURRENCY = "THB"; + private static final int MAX_CHARGE_LINES = 10; + private static final BigDecimal VAT_DIVISOR = new BigDecimal("1.07"); + private static final String EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + private final ObjectMapper objectMapper; + private final HotelContextService hotelContextService; + private final ReservationAiWorkflowRepository workflowRepository; + private final ReservationInvoiceGenerationRepository invoiceGenerationRepository; + private final ReservationInvoiceExcelTemplateRenderer templateRenderer; + private final ExcelToPdfConverter excelToPdfConverter; + private final ObjectStorageService objectStorageService; + + /** + * 注入发票生成需要的业务仓储、模板渲染器和平台文件能力。 + */ + public ReservationInvoiceGenerationServiceImpl( + ObjectMapper objectMapper, + HotelContextService hotelContextService, + ReservationAiWorkflowRepository workflowRepository, + ReservationInvoiceGenerationRepository invoiceGenerationRepository, + ReservationInvoiceExcelTemplateRenderer templateRenderer, + ExcelToPdfConverter excelToPdfConverter, + ObjectStorageService objectStorageService) { + this.objectMapper = objectMapper; + this.hotelContextService = hotelContextService; + this.workflowRepository = workflowRepository; + this.invoiceGenerationRepository = invoiceGenerationRepository; + this.templateRenderer = templateRenderer; + this.excelToPdfConverter = excelToPdfConverter; + this.objectStorageService = objectStorageService; + } + + /** + * 手工生成 Proforma Invoice。第一版同步生成并返回 PDF URL。 + */ + @Override + public ReservationInvoiceGenerationResult generateManualInvoice( + ReservationInvoiceManualGenerationRequest request, + AuthenticatedUserContext actor) { + ReservationInvoiceManualGenerationRequest normalizedRequest = normalizeAndValidate(request); + String hotelId = resolveAccessibleHotel(normalizedRequest.hotelId()); + ContextCheckResult context = validateOptionalContext(hotelId, normalizedRequest); + ReservationInvoiceTotals totals = calculateTotals(normalizedRequest.invoicePayload().charges()); + LocalDateTime now = nowUtc(); + String payloadJson = writeJson(normalizedRequest.invoicePayload()); + Long generationId = invoiceGenerationRepository.insertGeneration(new ReservationInvoiceGenerationDraft( + hotelId, + ReservationInvoiceSourceType.MANUAL.name(), + normalizedRequest.orderId(), + normalizedRequest.taskId(), + context.sourceMessageId(), + normalizedRequest.templateCode(), + TEMPLATE_VERSION, + payloadJson, + ReservationInvoiceGenerationStatus.RUNNING.name(), + actor.username(), + now)); + + try { + byte[] excelBytes = templateRenderer.render(normalizedRequest); + String baseObjectKey = buildObjectKeyPrefix(hotelId, generationId); + ObjectStoragePutResult excelResult = uploadExcel(baseObjectKey, generationId, excelBytes); + invoiceGenerationRepository.recordGeneratedExcel(generationId, excelResult.objectKey(), nowUtc()); + ExcelToPdfConvertedDocument pdfDocument = convertToPdf(generationId, excelBytes); + ObjectStoragePutResult pdfResult = uploadPdf(baseObjectKey, pdfDocument); + invoiceGenerationRepository.recordGeneratedPdf( + generationId, + pdfResult.objectKey(), + pdfResult.publicUrl(), + nowUtc()); + String totalsJson = writeJson(totals); + LocalDateTime finishedAt = nowUtc(); + writeSuccessAudit(hotelId, normalizedRequest, context.sourceMessageId(), generationId, totals, actor, finishedAt); + invoiceGenerationRepository.markSucceeded( + generationId, + totalsJson, + excelResult.objectKey(), + pdfResult.objectKey(), + pdfResult.publicUrl(), + finishedAt); + return toResult(generationId, hotelId, normalizedRequest.templateCode(), pdfResult, excelResult, totals, now); + } catch (ReservationInvoiceGenerationException exception) { + invoiceGenerationRepository.markFailed( + generationId, + exception.getErrorCode(), + safeSummary(exception.getMessage()), + nowUtc()); + throw exception; + } catch (DocumentConversionException exception) { + invoiceGenerationRepository.markFailed( + generationId, + exception.getErrorCode(), + safeSummary(exception.getMessage()), + nowUtc()); + throw new ReservationInvoiceGenerationException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } catch (RuntimeException exception) { + invoiceGenerationRepository.markFailed( + generationId, + "RESERVATION_INVOICE_GENERATION_FAILED", + safeSummary(exception.getMessage()), + nowUtc()); + throw new ReservationInvoiceGenerationException( + HttpStatus.BAD_GATEWAY, + "RESERVATION_INVOICE_GENERATION_FAILED", + "Invoice PDF 生成失败,请稍后重试。"); + } + } + + /** + * 归一化请求并执行第一版字段校验。 + */ + private ReservationInvoiceManualGenerationRequest normalizeAndValidate( + ReservationInvoiceManualGenerationRequest request) { + if (request == null) { + throw validationError(List.of("request: 请求体不能为空。")); + } + String sourceType = defaultIfBlank(request.sourceType(), ReservationInvoiceSourceType.MANUAL.name()); + String templateCode = defaultIfBlank(request.templateCode(), DEFAULT_TEMPLATE_CODE); + ReservationInvoiceManualGenerationRequest normalized = new ReservationInvoiceManualGenerationRequest( + trimToNull(request.hotelId()), + sourceType, + request.orderId(), + request.taskId(), + templateCode, + request.invoicePayload()); + List errors = validateNormalizedRequest(normalized); + if (!errors.isEmpty()) { + throw validationError(errors); + } + return normalized; + } + + /** + * 校验归一化后的请求字段,返回全部可一次性提示给前端的错误。 + */ + private List validateNormalizedRequest(ReservationInvoiceManualGenerationRequest request) { + List errors = new ArrayList<>(); + if (!ReservationInvoiceSourceType.MANUAL.name().equals(request.sourceType())) { + errors.add("source_type: 第一版只支持 MANUAL。"); + } + if (!DEFAULT_TEMPLATE_CODE.equals(request.templateCode())) { + errors.add("template_code: 第一版只支持 PROFORMA_INVOICE_V1。"); + } + ReservationInvoicePayloadRequest payload = request.invoicePayload(); + if (payload == null) { + errors.add("invoice_payload: 不能为空。"); + return errors; + } + if (payload.document() == null) { + errors.add("invoice_payload.document: 不能为空。"); + } else { + if (payload.document().invoiceDate() == null) { + errors.add("invoice_payload.document.invoice_date: 必填字段缺失。"); + } + if (payload.document().bookingDate() == null) { + errors.add("invoice_payload.document.booking_date: 必填字段缺失。"); + } + if (payload.document().dueDate() == null) { + errors.add("invoice_payload.document.due_date: 必填字段缺失。"); + } + } + validateRecipient(payload, errors); + validateBooking(payload, errors); + validateCharges(payload, errors); + return errors; + } + + /** + * 校验收件人字段。第一版以最终文本生成 PDF,目录 code 允许为空。 + */ + private void validateRecipient(ReservationInvoicePayloadRequest payload, List errors) { + if (payload.recipient() == null) { + errors.add("invoice_payload.recipient: 不能为空。"); + return; + } + if (isBlank(payload.recipient().company())) { + errors.add("invoice_payload.recipient.company: 必填字段缺失。"); + } + if (isBlank(payload.recipient().attention())) { + errors.add("invoice_payload.recipient.attention: 必填字段缺失。"); + } + if (isBlank(payload.recipient().address())) { + errors.add("invoice_payload.recipient.address: 必填字段缺失。"); + } + if (isBlank(payload.recipient().telephone())) { + errors.add("invoice_payload.recipient.telephone: 必填字段缺失。"); + } + if (isBlank(payload.recipient().email())) { + errors.add("invoice_payload.recipient.email: 必填字段缺失。"); + } + } + + /** + * 校验预订摘要字段。到店日期必须早于离店日期。 + */ + private void validateBooking(ReservationInvoicePayloadRequest payload, List errors) { + if (payload.booking() == null) { + errors.add("invoice_payload.booking: 不能为空。"); + return; + } + if (isBlank(payload.booking().groupName())) { + errors.add("invoice_payload.booking.group_name: 必填字段缺失。"); + } + if (payload.booking().arrivalDate() == null) { + errors.add("invoice_payload.booking.arrival_date: 必填字段缺失。"); + } + if (payload.booking().departureDate() == null) { + errors.add("invoice_payload.booking.departure_date: 必填字段缺失。"); + } + if (payload.booking().arrivalDate() != null && payload.booking().departureDate() != null + && !payload.booking().departureDate().isAfter(payload.booking().arrivalDate())) { + errors.add("invoice_payload.booking.departure_date: 离店日期必须晚于到店日期。"); + } + } + + /** + * 校验费用明细行。第一版最多承载模板默认 10 行。 + */ + private void validateCharges(ReservationInvoicePayloadRequest payload, List errors) { + List charges = payload.charges(); + if (charges == null || charges.isEmpty()) { + errors.add("invoice_payload.charges: 至少需要一条费用明细。"); + return; + } + if (charges.size() > MAX_CHARGE_LINES) { + errors.add("invoice_payload.charges: 第一版最多支持 10 条费用明细。"); + } + for (int index = 0; index < charges.size(); index++) { + ReservationInvoiceChargeRequest charge = charges.get(index); + String prefix = "invoice_payload.charges." + index + "."; + if (charge == null) { + errors.add(prefix + "item: 明细不能为空。"); + continue; + } + if (isBlank(charge.description())) { + errors.add(prefix + "description: 必填字段缺失。"); + } + if (isBlank(charge.roomType())) { + errors.add(prefix + "room_type: 必填字段缺失。"); + } + validatePositive(charge.quantity(), prefix + "quantity", errors); + validatePositive(charge.rate(), prefix + "rate", errors); + validatePositive(charge.nights(), prefix + "nights", errors); + } + } + + /** + * 校验当前用户可访问酒店。 + */ + private String resolveAccessibleHotel(String requestedHotelId) { + try { + return hotelContextService.requireAccessibleHotel(requestedHotelId); + } catch (HotelContextException exception) { + throw new ReservationInvoiceGenerationException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + + /** + * 校验可选订单 / 任务上下文归属酒店,并提取可追溯的 source_message_id。 + */ + private ContextCheckResult validateOptionalContext( + String hotelId, + ReservationInvoiceManualGenerationRequest request) { + Long sourceMessageId = null; + if (request.orderId() != null) { + ReservationAiQueryOrderSnapshot order = workflowRepository.findAiQueryOrderById(request.orderId()) + .orElseThrow(() -> new ReservationInvoiceGenerationException( + HttpStatus.NOT_FOUND, + "RESERVATION_INVOICE_ORDER_NOT_FOUND", + "关联订单不存在。")); + if (!hotelId.equals(order.hotelId())) { + throw new ReservationInvoiceGenerationException( + HttpStatus.FORBIDDEN, + "HOTEL_ACCESS_DENIED", + "当前用户无权访问该订单所属酒店。"); + } + sourceMessageId = order.sourceMessageId(); + } + if (request.taskId() != null) { + ReservationTaskSnapshot task = workflowRepository.findTaskById(request.taskId()) + .orElseThrow(() -> new ReservationInvoiceGenerationException( + HttpStatus.NOT_FOUND, + "RESERVATION_INVOICE_TASK_NOT_FOUND", + "关联任务不存在。")); + if (!hotelId.equals(task.hotelId())) { + throw new ReservationInvoiceGenerationException( + HttpStatus.FORBIDDEN, + "HOTEL_ACCESS_DENIED", + "当前用户无权访问该任务所属酒店。"); + } + if (request.orderId() != null && !Objects.equals(request.orderId(), task.orderId())) { + throw new ReservationInvoiceGenerationException( + HttpStatus.BAD_REQUEST, + "RESERVATION_INVOICE_CONTEXT_MISMATCH", + "关联任务不属于传入订单。"); + } + sourceMessageId = task.sourceMessageId(); + } + return new ContextCheckResult(sourceMessageId); + } + + /** + * 计算含税总额、未税金额和 VAT。 + */ + private ReservationInvoiceTotals calculateTotals(List charges) { + BigDecimal total = charges.stream() + .map(this::lineAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(2, RoundingMode.HALF_UP); + BigDecimal subtotal = total.divide(VAT_DIVISOR, 2, RoundingMode.HALF_UP); + BigDecimal vat = total.subtract(subtotal).setScale(2, RoundingMode.HALF_UP); + return new ReservationInvoiceTotals(subtotal, vat, total, CURRENCY); + } + + /** + * 计算单行金额。 + */ + private BigDecimal lineAmount(ReservationInvoiceChargeRequest charge) { + return charge.quantity() + .multiply(charge.rate()) + .multiply(charge.nights()); + } + + /** + * 上传生成后的 Excel,便于后续排查 PDF 生成问题。 + */ + private ObjectStoragePutResult uploadExcel(String baseObjectKey, Long generationId, byte[] excelBytes) { + String fileName = "proforma-invoice-" + generationId + ".xlsx"; + return objectStorageService.putObject(new ObjectStoragePutRequest( + baseObjectKey + fileName, + fileName, + EXCEL_CONTENT_TYPE, + (long) excelBytes.length, + excelBytes)); + } + + /** + * 调用平台 Excel 转 PDF Adapter,不直接依赖 LibreOffice 命令细节。 + */ + private ExcelToPdfConvertedDocument convertToPdf(Long generationId, byte[] excelBytes) { + String fileName = "proforma-invoice-" + generationId + ".xlsx"; + return excelToPdfConverter.convert(new ExcelToPdfConversionInput( + fileName, + EXCEL_CONTENT_TYPE, + (long) excelBytes.length, + excelBytes)); + } + + /** + * 上传 PDF 到 OSS,并返回可展示 URL。 + */ + private ObjectStoragePutResult uploadPdf(String baseObjectKey, ExcelToPdfConvertedDocument pdfDocument) { + return objectStorageService.putObject(new ObjectStoragePutRequest( + baseObjectKey + safeFileName(pdfDocument.fileName(), "proforma-invoice.pdf"), + safeFileName(pdfDocument.fileName(), "proforma-invoice.pdf"), + defaultIfBlank(pdfDocument.contentType(), MediaType.APPLICATION_PDF_VALUE), + pdfDocument.sizeBytes() == null ? (long) pdfDocument.content().length : pdfDocument.sizeBytes(), + pdfDocument.content())); + } + + /** + * 记录生成成功审计,只保存摘要,不保存完整发票 payload。 + */ + private void writeSuccessAudit( + String hotelId, + ReservationInvoiceManualGenerationRequest request, + Long sourceMessageId, + Long generationId, + ReservationInvoiceTotals totals, + AuthenticatedUserContext actor, + LocalDateTime occurredAt) { + workflowRepository.insertAuditLog(new ReservationAuditLogDraft( + hotelId, + request.orderId(), + request.taskId(), + null, + "USER", + actor.username(), + "RESERVATION_INVOICE_GENERATE", + "手工生成 Proforma Invoice", + null, + writeJson(Map.of( + "invoice_generation_id", String.valueOf(generationId), + "source_message_id", sourceMessageId == null ? "" : String.valueOf(sourceMessageId), + "template_code", request.templateCode(), + "subtotal", totals.subtotal(), + "vat", totals.vat(), + "total", totals.total(), + "currency", totals.currency())), + occurredAt)); + } + + /** + * 转换成对前端稳定的响应结构。 + */ + private ReservationInvoiceGenerationResult toResult( + Long generationId, + String hotelId, + String templateCode, + ObjectStoragePutResult pdfResult, + ObjectStoragePutResult excelResult, + ReservationInvoiceTotals totals, + LocalDateTime createdAt) { + return new ReservationInvoiceGenerationResult( + String.valueOf(generationId), + ReservationInvoiceGenerationStatus.SUCCEEDED.name(), + ReservationInvoiceSourceType.MANUAL.name(), + hotelId, + templateCode, + pdfResult.publicUrl(), + pdfResult.objectKey(), + excelResult.objectKey(), + new ReservationInvoiceTotalsResult(totals.subtotal(), totals.vat(), totals.total(), totals.currency()), + UtcTimeFormatter.toUtcOffsetDateTime(createdAt)); + } + + private ReservationInvoiceGenerationException validationError(List details) { + return new ReservationInvoiceGenerationException( + HttpStatus.BAD_REQUEST, + "RESERVATION_INVOICE_VALIDATION_FAILED", + "Invoice 字段校验失败。", + details); + } + + private void validatePositive(BigDecimal value, String fieldPath, List errors) { + if (value == null) { + errors.add(fieldPath + ": 必填字段缺失。"); + return; + } + if (value.compareTo(BigDecimal.ZERO) <= 0) { + errors.add(fieldPath + ": 必须大于 0。"); + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new ReservationInvoiceGenerationException( + HttpStatus.INTERNAL_SERVER_ERROR, + "RESERVATION_INVOICE_JSON_SERIALIZE_FAILED", + "Invoice 数据序列化失败。"); + } + } + + private String buildObjectKeyPrefix(String hotelId, Long generationId) { + return "reservation-invoices/" + hotelId + "/" + java.time.LocalDate.now(Clock.systemUTC()) + + "/" + generationId + "/"; + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(Clock.systemUTC()); + } + + private String safeSummary(String message) { + if (message == null || message.isBlank()) { + return "Invoice PDF 生成失败。"; + } + String normalized = message.replaceAll("\\s+", " ").trim(); + return normalized.length() <= 500 ? normalized : normalized.substring(0, 500); + } + + private String safeFileName(String fileName, String fallback) { + String value = trimToNull(fileName); + if (value == null) { + return fallback; + } + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private String defaultIfBlank(String value, String defaultValue) { + String normalized = trimToNull(value); + return normalized == null ? defaultValue : normalized; + } + + private String trimToNull(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } + + private boolean isBlank(String value) { + return trimToNull(value) == null; + } + + /** + * 订单 / 任务可选上下文校验结果。 + * + * @param sourceMessageId 可追溯来源消息 ID + */ + private record ContextCheckResult(Long sourceMessageId) { + } +} diff --git a/server/src/main/resources/db/migration/V22__create_reservation_invoice_generation.sql b/server/src/main/resources/db/migration/V22__create_reservation_invoice_generation.sql new file mode 100644 index 0000000..917119d --- /dev/null +++ b/server/src/main/resources/db/migration/V22__create_reservation_invoice_generation.sql @@ -0,0 +1,26 @@ +CREATE TABLE workflow_reservation_invoice_generation ( + id BIGINT NOT NULL COMMENT 'Invoice 生成记录 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID', + source_type VARCHAR(32) NOT NULL COMMENT '生成来源类型:MANUAL、TASK、ORDER', + order_id BIGINT NULL COMMENT '可选关联订单 ID', + task_id BIGINT NULL COMMENT '可选关联任务 ID', + source_message_id BIGINT NULL COMMENT '可选来源消息 ID', + template_code VARCHAR(64) NOT NULL COMMENT '模板编码', + template_version VARCHAR(32) NOT NULL COMMENT '模板版本', + invoice_payload_json LONGTEXT NOT NULL COMMENT '归一化后的 Invoice 业务字段 JSON', + calculated_totals_json LONGTEXT NULL COMMENT '后端计算金额摘要 JSON', + generated_excel_object_key VARCHAR(512) NULL COMMENT '生成 Excel OSS 对象 Key', + pdf_object_key VARCHAR(512) NULL COMMENT '生成 PDF OSS 对象 Key', + pdf_url VARCHAR(1024) NULL COMMENT '生成 PDF 访问 URL', + generation_status VARCHAR(32) NOT NULL COMMENT '生成状态', + safe_error_code VARCHAR(128) NULL COMMENT '安全错误码', + safe_error_summary VARCHAR(1024) NULL COMMENT '安全错误摘要', + created_by VARCHAR(128) NOT NULL COMMENT '创建人用户标识', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + KEY idx_reservation_invoice_hotel_created (hotel_id, created_at), + KEY idx_reservation_invoice_hotel_source_created (hotel_id, source_type, created_at), + KEY idx_reservation_invoice_hotel_order_created (hotel_id, order_id, created_at), + KEY idx_reservation_invoice_hotel_task_created (hotel_id, task_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation Proforma Invoice 生成记录表'; diff --git a/server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx b/server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..44000f86201645bad890a676d90adc31acb38af1 GIT binary patch literal 34139 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%0A7V)fnzx-W<*uhfr=BhtR90L& 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 literal 0 HcmV?d00001 diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java new file mode 100644 index 0000000..ba9700b --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationInvoiceGenerationControllerTest.java @@ -0,0 +1,400 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; +import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:reservation_invoice_generation;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=invoice-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=系统管理员", + "auth.bootstrap.default-hotel-id=HOTEL-TEST", + "auth.bootstrap.default-hotel-name=测试酒店", + "auth.bootstrap.default-hotel-time-zone=Asia/Bangkok", + "superagent.task-result.hmac-secret=test-superagent-secret", + "mcp.enabled=true", + "mcp.auth-token=test-mcp-token" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ReservationInvoiceGenerationControllerTest { + + private static final String ENDPOINT = "/api/reservation/invoices/manual-generations"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformIdentityRepository identityRepository; + @Autowired + private PlatformHotelRepository hotelRepository; + @Autowired + private AuthPasswordService passwordService; + + @MockBean + private ExcelToPdfConverter excelToPdfConverter; + @MockBean + private ObjectStorageService objectStorageService; + + @BeforeEach + void setUpNoPermissionUser() { + jdbcTemplate.update("delete from workflow_reservation_invoice_generation"); + PlatformUserEntity user = identityRepository.findUserByUsername("invoice-no-permission") + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername("invoice-no-permission"); + created.setPasswordHash(passwordService.hash("NoPerm@123456")); + created.setDisplayName("无权限用户"); + created.setUserStatus(PlatformUserStatus.ACTIVE.name()); + created.setSuperAdmin(false); + created.setPasswordChangedAt(now); + created.setCreatedAt(now); + created.setUpdatedAt(now); + identityRepository.insertUser(created); + return created; + }); + hotelRepository.ensureUserHotel(user.getId(), "HOTEL-TEST", true); + } + + @Test + void shouldGenerateManualInvoicePdfAndPersistGenerationRecord() throws Exception { + byte[] pdfBytes = "%PDF-1.7\nmanual-invoice".getBytes(StandardCharsets.UTF_8); + when(excelToPdfConverter.convert(any())).thenReturn(new ExcelToPdfConvertedDocument( + "proforma-invoice.pdf", + MediaType.APPLICATION_PDF_VALUE, + (long) pdfBytes.length, + pdfBytes, + 88L)); + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + String token = loginToken(mockMvc, "invoice-admin", "Admin@123456"); + + performAuthorized(mockMvc, token, post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(validRequest())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.generation_status").value("SUCCEEDED")) + .andExpect(jsonPath("$.source_type").value("MANUAL")) + .andExpect(jsonPath("$.hotel_id").value("HOTEL-TEST")) + .andExpect(jsonPath("$.template_code").value("PROFORMA_INVOICE_V1")) + .andExpect(jsonPath("$.pdf_url", containsString("https://oss.example.test/"))) + .andExpect(jsonPath("$.pdf_object_key", containsString("reservation-invoices/HOTEL-TEST/"))) + .andExpect(jsonPath("$.generated_excel_object_key", containsString("reservation-invoices/HOTEL-TEST/"))) + .andExpect(jsonPath("$.totals.subtotal").value(16822.43)) + .andExpect(jsonPath("$.totals.vat").value(1177.57)) + .andExpect(jsonPath("$.totals.total").value(18000.00)) + .andExpect(jsonPath("$.totals.currency").value("THB")) + .andExpect(jsonPath("$.created_at", containsString("Z"))) + .andExpect(content().string(not(containsString("op.liantaitravel@gmail.com")))); + + ArgumentCaptor converterInputCaptor = + ArgumentCaptor.forClass(ExcelToPdfConversionInput.class); + verify(excelToPdfConverter).convert(converterInputCaptor.capture()); + assertThat(converterInputCaptor.getValue().fileName()).endsWith(".xlsx"); + assertThat(converterInputCaptor.getValue().content()).startsWith(new byte[]{0x50, 0x4B}); + + ArgumentCaptor storageRequestCaptor = + ArgumentCaptor.forClass(ObjectStoragePutRequest.class); + verify(objectStorageService, times(2)).putObject(storageRequestCaptor.capture()); + assertThat(storageRequestCaptor.getAllValues()) + .extracting(ObjectStoragePutRequest::objectKey) + .anySatisfy(objectKey -> assertThat(objectKey).endsWith(".xlsx")) + .anySatisfy(objectKey -> assertThat(objectKey).endsWith(".pdf")); + + Integer generatedRows = jdbcTemplate.queryForObject( + "select count(*) from workflow_reservation_invoice_generation where hotel_id = 'HOTEL-TEST'", + Integer.class); + assertThat(generatedRows).isEqualTo(1); + } + + @Test + void shouldRejectManualInvoiceWhenTokenMissing() throws Exception { + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(validRequest())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldRejectManualInvoiceWhenPermissionMissing() throws Exception { + String token = loginToken(mockMvc, "invoice-no-permission", "NoPerm@123456"); + + performAuthorized(mockMvc, token, post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(validRequest())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldRejectManualInvoiceWhenChargeLinesMissing() throws Exception { + String token = loginToken(mockMvc, "invoice-admin", "Admin@123456"); + + performAuthorized(mockMvc, token, post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(emptyChargesRequest())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("RESERVATION_INVOICE_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value("invoice_payload.charges: 至少需要一条费用明细。")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldRejectManualInvoiceWhenTaskDoesNotBelongToOrder() throws Exception { + insertOrder(2080010000000000001L, 2080010000000000101L, "GRP-M009-ORDER-1"); + insertOrder(2080010000000000002L, 2080010000000000102L, "GRP-M009-ORDER-2"); + insertTask(2080010000000000201L, 2080010000000000002L, 2080010000000000102L); + String token = loginToken(mockMvc, "invoice-admin", "Admin@123456"); + + performAuthorized(mockMvc, token, post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(validRequestWithContext(2080010000000000001L, 2080010000000000201L))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("RESERVATION_INVOICE_CONTEXT_MISMATCH")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldKeepGeneratedExcelObjectKeyWhenPdfConversionFails() throws Exception { + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + when(excelToPdfConverter.convert(any())).thenThrow(new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_FAILED", + "PDF 转换失败。")); + String token = loginToken(mockMvc, "invoice-admin", "Admin@123456"); + + performAuthorized(mockMvc, token, post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(validRequest())) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FAILED")); + + String generationStatus = jdbcTemplate.queryForObject( + "SELECT generation_status FROM workflow_reservation_invoice_generation", + String.class); + String generatedExcelObjectKey = jdbcTemplate.queryForObject( + "SELECT generated_excel_object_key FROM workflow_reservation_invoice_generation", + String.class); + String safeErrorCode = jdbcTemplate.queryForObject( + "SELECT safe_error_code FROM workflow_reservation_invoice_generation", + String.class); + assertThat(generationStatus).isEqualTo("FAILED"); + assertThat(generatedExcelObjectKey).endsWith(".xlsx"); + assertThat(safeErrorCode).isEqualTo("DOCUMENT_CONVERSION_FAILED"); + verify(objectStorageService).putObject(any()); + } + + private String validRequest() { + return """ + { + "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 + } + ] + } + } + """; + } + + private String validRequestWithContext(Long orderId, Long taskId) { + return """ + { + "hotel_id": "HOTEL-TEST", + "source_type": "MANUAL", + "task_id": %s, + "order_id": %s, + "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 + } + ] + } + } + """.formatted(taskId, orderId); + } + + private String emptyChargesRequest() { + return """ + { + "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": [] + } + } + """; + } + + private void insertOrder(Long orderId, Long sourceMessageId, String groupCode) { + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, business_key_source, display_name, + source_message_id, version, latest_activity_at, created_at, updated_at + ) + VALUES (?, 'HOTEL-TEST', 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'USER_CONFIRMED', ?, + ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, orderId, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId); + } + + private void insertTask(Long taskId, Long orderId, Long sourceMessageId) { + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_task ( + id, hotel_id, order_id, source_message_id, ai_transition_id, + result_type, ai_task_type, system_task_type, task_card_type, task_subtype, + task_status, queue_participation, execution_order, blocked_until_parent_completed, + version, created_at, updated_at + ) + VALUES (?, 'HOTEL-TEST', ?, ?, ?, 'normal_task', 'Update Booking', 'UPDATE_BOOKING', + 'UPDATE_BOOKING', 'manual_invoice_test', 'READY', 1, 1, 0, 0, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, taskId, orderId, sourceMessageId, taskId - 1); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImplTest.java new file mode 100644 index 0000000..a7d98e3 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceGenerationServiceImplTest.java @@ -0,0 +1,143 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceBookingRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceChargeRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceDocumentRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceManualGenerationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoicePayloadRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceRecipientRequest; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationInvoiceGenerationRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; + +@ExtendWith(MockitoExtension.class) +class ReservationInvoiceGenerationServiceImplTest { + + @Mock + private HotelContextService hotelContextService; + @Mock + private ReservationAiWorkflowRepository workflowRepository; + @Mock + private ReservationInvoiceGenerationRepository invoiceGenerationRepository; + @Mock + private ReservationInvoiceExcelTemplateRenderer templateRenderer; + @Mock + private ExcelToPdfConverter excelToPdfConverter; + @Mock + private ObjectStorageService objectStorageService; + + private ReservationInvoiceGenerationServiceImpl service; + + @BeforeEach + void setUp() { + service = new ReservationInvoiceGenerationServiceImpl( + new ObjectMapper().findAndRegisterModules(), + hotelContextService, + workflowRepository, + invoiceGenerationRepository, + templateRenderer, + excelToPdfConverter, + objectStorageService); + } + + @Test + void shouldNotMarkGenerationSucceededBeforeAuditIsWritten() { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + when(invoiceGenerationRepository.insertGeneration(any())).thenReturn(2080020000000000001L); + when(templateRenderer.render(any())).thenReturn(new byte[]{0x50, 0x4B, 0x03, 0x04}); + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + when(excelToPdfConverter.convert(any())).thenReturn(new ExcelToPdfConvertedDocument( + "proforma-invoice.pdf", + MediaType.APPLICATION_PDF_VALUE, + 8L, + "%PDF-1.7".getBytes(), + 10L)); + when(workflowRepository.insertAuditLog(any())).thenThrow(new RuntimeException("audit insert failed")); + + ReservationInvoiceGenerationException exception = catchThrowableOfType( + () -> service.generateManualInvoice(validRequest(), actor()), + ReservationInvoiceGenerationException.class); + + assertThat(exception.getErrorCode()).isEqualTo("RESERVATION_INVOICE_GENERATION_FAILED"); + verify(invoiceGenerationRepository, never()).markSucceeded( + anyLong(), any(), any(), any(), any(), any()); + verify(invoiceGenerationRepository).markFailed( + anyLong(), any(), any(), any()); + } + + private AuthenticatedUserContext actor() { + return new AuthenticatedUserContext( + 1L, + "invoice-admin", + "系统管理员", + true, + "HOTEL-TEST", + List.of("HOTEL-TEST"), + List.of("RESERVATION_INVOICE_GENERATE")); + } + + private ReservationInvoiceManualGenerationRequest validRequest() { + return new ReservationInvoiceManualGenerationRequest( + "HOTEL-TEST", + "MANUAL", + null, + null, + "PROFORMA_INVOICE_V1", + new ReservationInvoicePayloadRequest( + new ReservationInvoiceDocumentRequest( + LocalDate.of(2026, 7, 17), + LocalDate.of(2026, 7, 12), + LocalDate.of(2026, 7, 22)), + new ReservationInvoiceRecipientRequest( + "LIAN_TAI", + "LIAN_TAI_KHUN_ANN", + "LIAN TAI TRAVEL (THAILAND) CO., LTD.", + "Khun Ann", + "2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240", + "061-397-2675", + "op.liantaitravel@gmail.com"), + new ReservationInvoiceBookingRequest( + "GRP-DEMO-0802", + LocalDate.of(2026, 8, 2), + LocalDate.of(2026, 8, 5), + "includingBF", + new BigDecimal("1200")), + List.of(new ReservationInvoiceChargeRequest( + "GRP-DEMO-0802", + "Deluxe Room", + new BigDecimal("2"), + new BigDecimal("3000"), + new BigDecimal("3"))))); + } +}