实现手工发票生成后端接口
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation Invoice 生成状态。用于区分同步生成过程和最终结果。
|
||||
*/
|
||||
public enum ReservationInvoiceGenerationStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
SUCCEEDED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation Invoice 生成来源类型。第一版只开放 MANUAL,TASK / ORDER 为后续预填保留。
|
||||
*/
|
||||
public enum ReservationInvoiceSourceType {
|
||||
MANUAL,
|
||||
TASK,
|
||||
ORDER
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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 生成记录表';
|
||||
Binary file not shown.
Reference in New Issue
Block a user