实现手工发票生成后端接口

This commit is contained in:
andy
2026-07-17 11:24:51 +07:00
parent bc0413f21c
commit 11478c6913
35 changed files with 2416 additions and 2 deletions

View File

@@ -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,

View File

@@ -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";
};

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,11 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation Invoice 生成状态。用于区分同步生成过程和最终结果。
*/
public enum ReservationInvoiceGenerationStatus {
PENDING,
RUNNING,
SUCCEEDED,
FAILED
}

View File

@@ -0,0 +1,10 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation Invoice 生成来源类型。第一版只开放 MANUALTASK / ORDER 为后续预填保留。
*/
public enum ReservationInvoiceSourceType {
MANUAL,
TASK,
ORDER
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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<ReservationInvoiceChargeRequest> charges
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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<ReservationInvoiceGenerationResult> generateManualInvoice(
@RequestBody ReservationInvoiceManualGenerationRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_INVOICE_GENERATE.name());
return ResponseEntity.status(HttpStatus.CREATED)
.body(invoiceGenerationService.generateManualInvoice(request, actor));
}
}

View File

@@ -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; }
}

View File

@@ -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<ReservationInvoiceGenerationEntity> {
}

View File

@@ -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.<ReservationInvoiceGenerationEntity>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.<ReservationInvoiceGenerationEntity>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.<ReservationInvoiceGenerationEntity>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.<ReservationInvoiceGenerationEntity>lambdaUpdate()
.set(ReservationInvoiceGenerationEntity::getGenerationStatus,
ReservationInvoiceGenerationStatus.FAILED.name())
.set(ReservationInvoiceGenerationEntity::getSafeErrorCode, safeErrorCode)
.set(ReservationInvoiceGenerationEntity::getSafeErrorSummary, safeErrorSummary)
.set(ReservationInvoiceGenerationEntity::getUpdatedAt, now)
.eq(ReservationInvoiceGenerationEntity::getId, generationId));
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<ReservationInvoiceChargeRequest> 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<ReservationInvoiceChargeRequest> 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();
}
}

View File

@@ -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<String> details;
public ReservationInvoiceGenerationException(HttpStatus status, String errorCode, String message) {
this(status, errorCode, message, List.of());
}
public ReservationInvoiceGenerationException(
HttpStatus status,
String errorCode,
String message,
List<String> 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<String> getDetails() {
return details;
}
}

View File

@@ -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<String> errors = validateNormalizedRequest(normalized);
if (!errors.isEmpty()) {
throw validationError(errors);
}
return normalized;
}
/**
* 校验归一化后的请求字段,返回全部可一次性提示给前端的错误。
*/
private List<String> validateNormalizedRequest(ReservationInvoiceManualGenerationRequest request) {
List<String> 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<String> 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<String> 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<String> errors) {
List<ReservationInvoiceChargeRequest> 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<ReservationInvoiceChargeRequest> 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<String> details) {
return new ReservationInvoiceGenerationException(
HttpStatus.BAD_REQUEST,
"RESERVATION_INVOICE_VALIDATION_FAILED",
"Invoice 字段校验失败。",
details);
}
private void validatePositive(BigDecimal value, String fieldPath, List<String> 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) {
}
}

View File

@@ -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 生成记录表';