From 1a092bc9d71de07c3bf3a2bafafb348b7e509274 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 13:51:01 +0800
Subject: [PATCH 01/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 7 +++++--
firstleg/pom.xml | 1 +
.../firstleg/client/WarehouseSupplierClient.java | 6 ++++++
.../db/phase3-terminal-drayage-schema.sql | 3 +--
.../mapper/TerminalDrayageSqlContractTest.java | 14 ++++++++++++++
.../controller/InternalSupplierController.java | 12 ++++++++++++
6 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 07b97973b..47161a1ca 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18131,8 +18131,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL)
- AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL)
- AND (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
+ AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
@@ -18158,6 +18157,10 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。
ALTER TABLE fl_terminal_drayage
+ DROP CHECK chk_terminal_drayage_times,
+ ADD CONSTRAINT chk_terminal_drayage_times CHECK(
+ (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
+ (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)),
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
diff --git a/firstleg/pom.xml b/firstleg/pom.xml
index 1a35216ef..98dc43916 100644
--- a/firstleg/pom.xml
+++ b/firstleg/pom.xml
@@ -5,6 +5,7 @@
com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml
firstleg-service
+ org.springframework.bootspring-boot-starter-mail
com.baiyue.sharedbaiyue-common-core${project.version}
com.baiyue.sharedcommon-event-contract${project.version}
com.baiyue.sharedbaiyue-common-audit${project.version}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java
index 7559f7e14..a0874578a 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java
@@ -24,4 +24,10 @@ public interface WarehouseSupplierClient {
record Snapshot(long supplierId, String name, String category, String status,
List carrierCodes) {}
+
+ @GetMapping("/{supplierId}/trucking-contact")
+ ApiResponse truckingContact(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable long supplierId, @RequestParam boolean requireActive);
+
+ record TruckingContact(long supplierId, String name, String email) {}
}
diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
index f68365ccd..4b779b7fa 100644
--- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
@@ -23,8 +23,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK(
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
- (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND
- (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
+ (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
index fd6635a2b..cac6e7aaf 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
@@ -8,6 +8,20 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageSqlContractTest {
+ @Test
+ void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception {
+ for (Path path : java.util.List.of(
+ Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"),
+ Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
+ String sql = Files.readString(path);
+ assertThat(sql).contains("actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at")
+ .doesNotContain("actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL");
+ }
+ String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
+ assertThat(release).contains("DROP CHECK chk_terminal_drayage_times",
+ "ADD CONSTRAINT chk_terminal_drayage_times CHECK(");
+ }
+
@Test
void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java
index be193cc9c..78cfb51e8 100644
--- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java
+++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java
@@ -37,5 +37,17 @@ public class InternalSupplierController {
return ApiResponse.success(service.internalBookingCandidates(carrierCode));
}
+ public record TruckingContact(long supplierId, String name, String email) {}
+
+ @GetMapping("/{supplierId}/trucking-contact")
+ public ApiResponse truckingContact(
+ @RequestHeader(value="X-Internal-Service-Token", required=false) String token,
+ @PathVariable long supplierId, @RequestParam(defaultValue="true") boolean requireActive) {
+ requireToken(token);
+ service.internalSnapshot(supplierId, requireActive, "TRUCKING");
+ var supplier = service.detail(supplierId);
+ return ApiResponse.success(new TruckingContact(supplierId, supplier.name(), supplier.email()));
+ }
+
private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); }
}
From f31a0494448af13c36c20e458fd495ad53cd0225 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 13:54:47 +0800
Subject: [PATCH 02/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 24 +++++
.../PlatformTerminalDrayageController.java | 27 ++++++
.../dto/TerminalDrayageDtos.java | 1 +
.../mapper/TerminalDrayageMapper.java | 61 +++++++++++++
.../TerminalDrayageMailDispatcher.java | 62 +++++++++++++
.../service/TerminalDrayageMailService.java | 88 +++++++++++++++++++
.../service/TerminalDrayageMailTemplates.java | 20 +++++
.../service/TerminalDrayageService.java | 25 ++++++
.../db/phase3-terminal-drayage-schema.sql | 24 +++++
.../ContainerWorkbenchServiceTest.java | 4 +-
.../service/TerminalDrayageServiceTest.java | 4 +-
11 files changed, 336 insertions(+), 4 deletions(-)
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 47161a1ca..3b6c100d1 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18133,6 +18133,30 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL)
AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
+CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
+ id BIGINT UNSIGNED NOT NULL,
+ container_booking_id BIGINT UNSIGNED NOT NULL,
+ fleet_supplier_id BIGINT UNSIGNED NOT NULL,
+ fleet_name VARCHAR(128) NOT NULL,
+ recipient VARCHAR(254) NOT NULL,
+ kind VARCHAR(32) NOT NULL,
+ subject VARCHAR(255) NOT NULL,
+ body TEXT NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
+ attempts INT NOT NULL DEFAULT 0,
+ failure_reason VARCHAR(500) NULL,
+ created_by BIGINT UNSIGNED NOT NULL,
+ updated_by BIGINT UNSIGNED NOT NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ sent_at DATETIME(3) NULL,
+ PRIMARY KEY(id),
+ KEY idx_terminal_mail_dispatch(status,id),
+ KEY idx_terminal_mail_container(container_booking_id,id,status),
+ CONSTRAINT fk_terminal_mail_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
+ CONSTRAINT chk_terminal_mail_kind CHECK(kind IN ('REQUEST_PICKUP','CANCEL_PICKUP')),
+ CONSTRAINT chk_terminal_mail_status CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))
+) ENGINE=InnoDB COMMENT='码头车队邮件任务与投递记录';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', action VARCHAR(32) NOT NULL COMMENT '业务动作',
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
index 3bcf2952d..bfbfb36d7 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
@@ -36,6 +36,33 @@ import java.util.List;
@Tag(name = "目的港码头拖车")
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
+ @jakarta.annotation.Resource private com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService mails;
+
+ @PostMapping("/{containerBookingId}/pickup-email")
+ @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
+ public ApiResponse sendPickupEmail(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable @Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key,
+ @Valid @RequestBody com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.authorize(service.sendPickupEmail(containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
+ }
+
+ @GetMapping("/{containerBookingId}/emails")
+ @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
+ public ApiResponse emails(
+ @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) {
+ FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
+ return ApiResponse.success(mails.list(containerBookingId));
+ }
+
+ @PostMapping("/{containerBookingId}/emails/{mailId}/retry")
+ @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
+ public ApiResponse retryEmail(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable @Positive long containerBookingId, @PathVariable @Positive long mailId) {
+ FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
+ mails.retry(containerBookingId, mailId, user.userId());
+ return ApiResponse.success(null);
+ }
private final TerminalDrayageService service;
@GetMapping
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java
index 60d2c36b3..4db1f2972 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerAc
public final class TerminalDrayageDtos {
private TerminalDrayageDtos() {}
+ public record EmailRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Positive Long fleetSupplierId,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
index 6bf23c0dd..f2d7f3182 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
@@ -110,4 +110,65 @@ public interface TerminalDrayageMapper extends BaseMapper
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
LocalDateTime unloadingCompletedAt) {}
record Operation(String operation, String requestHash, Integer resultVersion) {}
+
+ record MailTask(Long id, Long containerBookingId, Long fleetSupplierId, String fleetName,
+ String recipient, String kind, String subject, String body, String status,
+ Integer attempts, String failureReason, LocalDateTime createdAt, LocalDateTime sentAt) {}
+
+ @Insert("""
+ INSERT INTO fl_terminal_drayage_mail(id,container_booking_id,fleet_supplier_id,fleet_name,
+ recipient,kind,subject,body,status,created_by,updated_by)
+ VALUES(#{id},#{containerId},#{supplierId},#{name},#{email},#{kind},#{subject},#{body},'PENDING',#{operatorId},#{operatorId})
+ """)
+ int insertMail(@Param("id") long id, @Param("containerId") long containerId,
+ @Param("supplierId") long supplierId, @Param("name") String name, @Param("email") String email,
+ @Param("kind") String kind, @Param("subject") String subject, @Param("body") String body,
+ @Param("operatorId") long operatorId);
+
+ @Select("SELECT * FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId} ORDER BY id DESC LIMIT 100")
+ List mails(@Param("containerId") long containerId);
+
+ @Select("""
+ SELECT * FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId}
+ AND fleet_supplier_id=#{supplierId} AND kind='REQUEST_PICKUP' ORDER BY id DESC LIMIT 1
+ """)
+ MailTask lastPickupMail(@Param("containerId") long containerId, @Param("supplierId") long supplierId);
+
+ @Select("""
+ SELECT m.* FROM fl_terminal_drayage_mail m WHERE m.status='PENDING'
+ AND NOT EXISTS(SELECT 1 FROM fl_terminal_drayage_mail earlier
+ WHERE earlier.container_booking_id=m.container_booking_id AND earlier.id'SENT')
+ ORDER BY m.id LIMIT 20
+ """)
+ List pendingMails();
+
+ @Update("""
+ UPDATE fl_terminal_drayage_mail SET status='SENDING',attempts=attempts+1,updated_at=CURRENT_TIMESTAMP(3)
+ WHERE id=#{id} AND status='PENDING'
+ """)
+ int claimMail(@Param("id") long id);
+
+ @Update("""
+ UPDATE fl_terminal_drayage_mail SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),failure_reason=NULL,
+ updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='SENDING'
+ """)
+ int completeMail(@Param("id") long id);
+
+ @Update("""
+ UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason=#{reason},updated_at=CURRENT_TIMESTAMP(3)
+ WHERE id=#{id} AND status='SENDING'
+ """)
+ int failMail(@Param("id") long id, @Param("reason") String reason);
+
+ @Update("""
+ UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason='发送中断,结果待核对;确认未收到后再重试',
+ updated_at=CURRENT_TIMESTAMP(3) WHERE status='SENDING' AND updated_at records) {}
+ public Mailbox list(long containerBookingId) { return new Mailbox(enabled, mapper.mails(containerBookingId)); }
+
+ public void request(long containerId, String containerNo, long supplierId, long operatorId) {
+ var contact = contact(supplierId, true);
+ enqueue(containerId, containerNo, supplierId, contact.name(), contact.email(), "REQUEST_PICKUP", operatorId);
+ }
+
+ public void replaceFleet(long containerId, String containerNo, long oldSupplierId, long newSupplierId, long operatorId) {
+ var next = contact(newSupplierId, true);
+ // 取消通知发往原请求的收件地址;尚未发过请求则读取原车队资料,允许原车队已停用。
+ MailTask previous = mapper.lastPickupMail(containerId, oldSupplierId);
+ var old = previous == null ? contact(oldSupplierId, false)
+ : new WarehouseSupplierClient.TruckingContact(oldSupplierId, previous.fleetName(), previous.recipient());
+ enqueue(containerId, containerNo, oldSupplierId, old.name(), old.email(), "CANCEL_PICKUP", operatorId);
+ enqueue(containerId, containerNo, newSupplierId, next.name(), next.email(), "REQUEST_PICKUP", operatorId);
+ }
+
+ private WarehouseSupplierClient.TruckingContact contact(long supplierId, boolean requireActive) {
+ try {
+ var response = suppliers.truckingContact(internalToken, supplierId, requireActive);
+ if (response == null || !response.successful() || response.data() == null
+ || response.data().supplierId() != supplierId) {
+ throw new BizException(ResultCode.CONFLICT, "车队联系资料不可用,请检查供应商资料");
+ }
+ var value = response.data();
+ validateAddress(value.email());
+ if (!StringUtils.hasText(value.name())) throw new BizException(ResultCode.CONFLICT, "车队名称缺失");
+ return value;
+ } catch (FeignException exception) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "车队联系资料服务暂不可用");
+ }
+ }
+
+ static void validateAddress(String email) {
+ try {
+ if (!StringUtils.hasText(email) || email.contains("\r") || email.contains("\n")) throw new IllegalArgumentException();
+ InternetAddress[] parsed = InternetAddress.parse(email.trim(), true);
+ if (parsed.length != 1 || parsed[0].isGroup() || !email.trim().equals(parsed[0].getAddress())) throw new IllegalArgumentException();
+ parsed[0].validate();
+ if (!parsed[0].getAddress().contains("@")) throw new IllegalArgumentException();
+ } catch (Exception exception) {
+ throw new BizException(ResultCode.BAD_REQUEST, "车队未配置有效邮箱,请先在供应商资料中维护单个收件邮箱");
+ }
+ }
+
+ private void enqueue(long containerId, String containerNo, long supplierId, String fleetName,
+ String email, String kind, long operatorId) {
+ if (!StringUtils.hasText(containerNo)) throw new BizException(ResultCode.CONFLICT, "请先补齐真实集装箱号再发送邮件");
+ validateAddress(email);
+ var content = TerminalDrayageMailTemplates.render(kind, containerNo, fleetName);
+ mapper.insertMail(IdWorker.getId(), containerId, supplierId, fleetName, email.trim(), kind,
+ content.subject(), content.body(), operatorId);
+ }
+
+ @Transactional
+ public void retry(long containerId, long mailId, long operatorId) {
+ if (mapper.retryMail(containerId, mailId, operatorId) != 1) {
+ throw new BizException(ResultCode.CONFLICT, "只有发送失败的邮件可以重试,请刷新邮件记录");
+ }
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java
new file mode 100644
index 000000000..2c4b73f2d
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java
@@ -0,0 +1,20 @@
+package com.baiyue.firstleg.terminaldrayage.service;
+
+/** 临时英文模板。正式邮件文案到位后只替换此处,不改变任务与发送流程。 */
+public final class TerminalDrayageMailTemplates {
+ private TerminalDrayageMailTemplates() {}
+ public record Content(String subject, String body) {}
+
+ public static Content render(String kind, String containerNo, String fleetName) {
+ boolean cancel = "CANCEL_PICKUP".equals(kind);
+ String subject = (cancel ? "Cancel port pickup - " : "Port pickup request - ") + containerNo;
+ String body = "Dear " + fleetName + ",\n\n"
+ + (cancel ? "Please cancel our port pickup request for container "
+ : "Please arrange port pickup for container ")
+ + containerNo + ".\n"
+ + (cancel ? "Please do not dispatch a truck for this request.\n"
+ : "Please contact our operations team to confirm the pickup details before dispatch.\n")
+ + "\nPlease reply to confirm receipt.\nThank you.\nOperations Team";
+ return new Content(subject, body);
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
index fe95c4aed..d6a6a272b 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
@@ -46,6 +46,7 @@ public class TerminalDrayageService {
private final WarehouseSupplierSnapshotService suppliers;
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
+ private final TerminalDrayageMailService mails;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
@@ -95,6 +96,10 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
+ if (value != null && Objects.equals(value.getFleetSupplierId(), fleet.supplierId())) return view(context, value);
+ if (value != null && value.getFleetSupplierId() != null) {
+ mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId);
+ }
int fromVersion = version(value);
String fromStatus = status(context, value);
if (value == null) value = create(containerBookingId, operatorId, operatorName);
@@ -197,6 +202,23 @@ public class TerminalDrayageService {
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
+ @Transactional
+ public View sendPickupEmail(long containerBookingId, long operatorId, String operatorName, String key,
+ com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
+ String action = "SEND_PICKUP_EMAIL";
+ WorkContext context = requiredLockedContext(containerBookingId);
+ View replay = replay(context, action, key, request);
+ if (replay != null) return replay;
+ TerminalDrayageEntity value = requiredAggregate(containerBookingId);
+ requireVersion(value, request.expectedVersion());
+ if (!allowedActions(context, value).contains(action)) throw conflict("当前货柜不能发送提柜请求邮件");
+ int fromVersion = version(value);
+ String fromStatus = status(context, value);
+ mails.request(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), operatorId);
+ persist(value, fromVersion, operatorId, operatorName);
+ return finish(context, value, action, key, request, fromStatus, fromVersion, "提柜邮件已加入发送队列", operatorId, operatorName);
+ }
+
private View finish(WorkContext context, TerminalDrayageEntity value, String action, String key, Object request,
String fromStatus, int fromVersion, String reason,
long operatorId, String operatorName) {
@@ -267,6 +289,8 @@ public class TerminalDrayageService {
private List allowedActions(WorkContext context, TerminalDrayageEntity value) {
List result = new ArrayList<>();
+ if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
+ && actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
@@ -371,6 +395,7 @@ public class TerminalDrayageService {
private static String permission(String action) {
return switch (action) {
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
+ case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
index 4b779b7fa..ab8619c7c 100644
--- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
@@ -26,6 +26,30 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
+CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
+ id BIGINT UNSIGNED NOT NULL,
+ container_booking_id BIGINT UNSIGNED NOT NULL,
+ fleet_supplier_id BIGINT UNSIGNED NOT NULL,
+ fleet_name VARCHAR(128) NOT NULL,
+ recipient VARCHAR(254) NOT NULL,
+ kind VARCHAR(32) NOT NULL,
+ subject VARCHAR(255) NOT NULL,
+ body TEXT NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
+ attempts INT NOT NULL DEFAULT 0,
+ failure_reason VARCHAR(500) NULL,
+ created_by BIGINT UNSIGNED NOT NULL,
+ updated_by BIGINT UNSIGNED NOT NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ sent_at DATETIME(3) NULL,
+ PRIMARY KEY(id),
+ KEY idx_terminal_mail_dispatch(status,id),
+ KEY idx_terminal_mail_container(container_booking_id,id,status),
+ CONSTRAINT fk_terminal_mail_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
+ CONSTRAINT chk_terminal_mail_kind CHECK(kind IN ('REQUEST_PICKUP','CANCEL_PICKUP')),
+ CONSTRAINT chk_terminal_mail_status CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))
+) ENGINE=InnoDB COMMENT='码头车队邮件任务与投递记录';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java
index 628f38447..ef54e5fd8 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java
@@ -103,7 +103,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var realDrayages = new TerminalDrayageService(mapper,
- mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json);
+ mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
@@ -148,7 +148,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules,
- new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
+ new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0);
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
index 16fae4d27..d8e86def6 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
@@ -57,7 +57,7 @@ class TerminalDrayageServiceTest {
if (facts.isEmpty()) return List.of();
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
});
- service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
+ service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@@ -95,7 +95,7 @@ class TerminalDrayageServiceTest {
assertThat(result.version()).isEqualTo(1);
assertThat(result.fleetSupplierId()).isEqualTo(91L);
assertThat(result.fleetSupplierName()).isEqualTo("服务端车队");
- assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP");
+ assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP", "SEND_PICKUP_EMAIL");
verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(),
anyInt(), anyInt(), any(), anyLong(), anyString());
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
From 3727ff141c2bb6fd1ed50310447192a7ebaf3205 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 14:00:03 +0800
Subject: [PATCH 03/42] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=A0=81=E5=A4=B4?=
=?UTF-8?q?=E8=BD=A6=E9=98=9F=E9=82=AE=E4=BB=B6=E8=A1=A8=E5=AD=97=E6=AE=B5?=
=?UTF-8?q?=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 32 +++++++++----------
.../db/phase3-terminal-drayage-schema.sql | 32 +++++++++----------
2 files changed, 32 insertions(+), 32 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 3b6c100d1..e1d15656f 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18134,22 +18134,22 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
- id BIGINT UNSIGNED NOT NULL,
- container_booking_id BIGINT UNSIGNED NOT NULL,
- fleet_supplier_id BIGINT UNSIGNED NOT NULL,
- fleet_name VARCHAR(128) NOT NULL,
- recipient VARCHAR(254) NOT NULL,
- kind VARCHAR(32) NOT NULL,
- subject VARCHAR(255) NOT NULL,
- body TEXT NOT NULL,
- status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
- attempts INT NOT NULL DEFAULT 0,
- failure_reason VARCHAR(500) NULL,
- created_by BIGINT UNSIGNED NOT NULL,
- updated_by BIGINT UNSIGNED NOT NULL,
- created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
- updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
- sent_at DATETIME(3) NULL,
+ id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID',
+ container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '归属物理货柜ID',
+ fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT '车队供应商ID快照',
+ fleet_name VARCHAR(128) NOT NULL COMMENT '车队名称快照',
+ recipient VARCHAR(254) NOT NULL COMMENT '收件人邮箱',
+ kind VARCHAR(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜',
+ subject VARCHAR(255) NOT NULL COMMENT '邮件主题',
+ body TEXT NOT NULL COMMENT '邮件正文',
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败',
+ attempts INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数',
+ failure_reason VARCHAR(500) NULL COMMENT '发送失败原因',
+ created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
+ updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ sent_at DATETIME(3) NULL COMMENT '发送成功时间',
PRIMARY KEY(id),
KEY idx_terminal_mail_dispatch(status,id),
KEY idx_terminal_mail_container(container_booking_id,id,status),
diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
index ab8619c7c..4edf7ade9 100644
--- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
@@ -27,22 +27,22 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
- id BIGINT UNSIGNED NOT NULL,
- container_booking_id BIGINT UNSIGNED NOT NULL,
- fleet_supplier_id BIGINT UNSIGNED NOT NULL,
- fleet_name VARCHAR(128) NOT NULL,
- recipient VARCHAR(254) NOT NULL,
- kind VARCHAR(32) NOT NULL,
- subject VARCHAR(255) NOT NULL,
- body TEXT NOT NULL,
- status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
- attempts INT NOT NULL DEFAULT 0,
- failure_reason VARCHAR(500) NULL,
- created_by BIGINT UNSIGNED NOT NULL,
- updated_by BIGINT UNSIGNED NOT NULL,
- created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
- updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
- sent_at DATETIME(3) NULL,
+ id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID',
+ container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '归属物理货柜ID',
+ fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT '车队供应商ID快照',
+ fleet_name VARCHAR(128) NOT NULL COMMENT '车队名称快照',
+ recipient VARCHAR(254) NOT NULL COMMENT '收件人邮箱',
+ kind VARCHAR(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜',
+ subject VARCHAR(255) NOT NULL COMMENT '邮件主题',
+ body TEXT NOT NULL COMMENT '邮件正文',
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败',
+ attempts INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数',
+ failure_reason VARCHAR(500) NULL COMMENT '发送失败原因',
+ created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
+ updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ sent_at DATETIME(3) NULL COMMENT '发送成功时间',
PRIMARY KEY(id),
KEY idx_terminal_mail_dispatch(status,id),
KEY idx_terminal_mail_container(container_booking_id,id,status),
From 250b623f3a2277b0fd76b3c27a039ac084bde74d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 14:26:34 +0800
Subject: [PATCH 04/42] =?UTF-8?q?=E9=99=90=E5=88=B6=E5=AE=B9=E5=99=A8?=
=?UTF-8?q?=E5=BC=82=E5=B8=B8=E9=80=80=E5=87=BA=E5=90=8E=E6=9C=80=E5=A4=9A?=
=?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E5=90=AF=E4=B8=89=E6=AC=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/docker-compose.overseas.yml | 2 +-
deploy/docker-compose.rocketmq.yml | 6 +++---
deploy/docker-compose.yml | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml
index 274f284f9..a9910006c 100644
--- a/deploy/docker-compose.overseas.yml
+++ b/deploy/docker-compose.overseas.yml
@@ -1,7 +1,7 @@
name: firstleg-${DEPLOY_ENV:-test}-overseas
x-overseas-common: &overseas-common
- restart: unless-stopped
+ restart: "on-failure:3"
networks: [firstleg-overseas-network, infra-network]
extra_hosts:
- "host.docker.internal:host-gateway"
diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml
index f15310c7a..7775ff3b9 100644
--- a/deploy/docker-compose.rocketmq.yml
+++ b/deploy/docker-compose.rocketmq.yml
@@ -9,14 +9,14 @@ x-bounded-logging: &bounded-logging
services:
rocketmq-namesrv:
image: apache/rocketmq:5.3.1
- restart: unless-stopped
+ restart: "on-failure:3"
command: sh mqnamesrv
logging: *bounded-logging
networks: [infra-network]
rocketmq-broker:
image: apache/rocketmq:5.3.1
- restart: unless-stopped
+ restart: "on-failure:3"
depends_on: [rocketmq-namesrv]
environment:
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
@@ -53,7 +53,7 @@ services:
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
- restart: on-failure
+ restart: "on-failure:3"
logging: *bounded-logging
networks: [infra-network]
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 59079dc6a..ec6953f55 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -1,7 +1,7 @@
name: firstleg-${DEPLOY_ENV:-test}
x-common: &common
- restart: unless-stopped
+ restart: "on-failure:3"
networks: [firstleg-network, infra-network]
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
extra_hosts:
From 5f671e164f95471c5655c319b34a4c76fb108bd0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 14:31:59 +0800
Subject: [PATCH 05/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/README.md | 4 ++
deploy/sql/头程数据库统一增量-MySQL8.sql | 3 +
firstleg/AGENTS.md | 2 +-
firstleg/docs/terminal-drayage-mail.md | 34 ++++++++++
.../service/OceanBookingPrintService.java | 6 +-
.../PlatformTerminalDrayageController.java | 10 ++-
.../mapper/TerminalDrayageMapper.java | 6 +-
.../TerminalDrayageMailDispatcher.java | 3 +-
.../service/TerminalDrayageMailService.java | 2 +-
.../service/TerminalDrayageService.java | 3 +-
firstleg/src/main/resources/application.yml | 10 +++
.../main/resources/openapi/firstleg-v1.yaml | 4 +-
.../resources/openapi/ocean-bookings-v1.yaml | 8 +--
.../openapi/terminal-drayage-v1.yaml | 56 +++++++++++++++-
.../service/OceanBookingPrintServiceTest.java | 35 ++++++++--
.../TerminalDrayageOpenApiContractTest.java | 3 +
.../TerminalDrayageSqlContractTest.java | 19 ++++++
.../TerminalDrayageMailDispatcherTest.java | 44 +++++++++++++
.../TerminalDrayageMailServiceTest.java | 65 +++++++++++++++++++
.../service/TerminalDrayageServiceTest.java | 47 +++++++++++++-
warehouse/AGENTS.md | 2 +-
.../main/resources/openapi/supplier-v1.yaml | 23 +++++++
.../InternalSupplierContactTest.java | 27 ++++++++
23 files changed, 384 insertions(+), 32 deletions(-)
create mode 100644 firstleg/docs/terminal-drayage-mail.md
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java
create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java
diff --git a/deploy/sql/README.md b/deploy/sql/README.md
index a462cdee7..501c91676 100644
--- a/deploy/sql/README.md
+++ b/deploy/sql/README.md
@@ -244,3 +244,7 @@ SELECT
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
+
+## 码头车队邮件
+
+国内统一入口包含 `fl_terminal_drayage_mail`,用于冻结收件地址、两类邮件正文、投递状态和失败信息;创建后才可发布相关 Firstleg 功能。发信配置默认关闭,配置说明见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。邮件功能涉及 Admin、Firstleg 和 Warehouse 内部联系资料接口,无新增权限码。
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index e1d15656f..f0ae5bd88 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18229,6 +18229,9 @@ DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema;
DELIMITER $$
CREATE PROCEDURE assert_terminal_drayage_schema()
BEGIN
+ IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg' AND TABLE_NAME='fl_terminal_drayage_mail') <> 1 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一基线校验失败:码头车队邮件任务表缺失';
+ END IF;
IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg'
AND TABLE_NAME IN ('fl_terminal_drayage','fl_terminal_drayage_history','fl_terminal_drayage_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车表不完整';
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index ee9eb076b..b28569360 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -44,7 +44,7 @@
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
-| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` |
+| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 |
diff --git a/firstleg/docs/terminal-drayage-mail.md b/firstleg/docs/terminal-drayage-mail.md
new file mode 100644
index 000000000..59a202dcc
--- /dev/null
+++ b/firstleg/docs/terminal-drayage-mail.md
@@ -0,0 +1,34 @@
+# 码头车队邮件
+
+## 行为
+
+- 首次选择车队只保存车队;列表“发送邮件”提交当前版本和幂等键,向供应商资料中的单个邮箱发送提柜请求。
+- 更换为另一车队时,与车队变更在同一数据库事务中生成 `CANCEL_PICKUP`、`REQUEST_PICKUP` 两个任务。取消邮件使用原请求的收件地址快照;没有原请求时读取原车队当前资料,允许原车队已停用。新车队必须为 ACTIVE/TRUCKING。
+- 同一车队重复选择不发邮件、不改变版本。发送操作递增拖车版本,同版本的重复点击不会再次入队。
+- 同柜严格顺序投递,取消未成功时新请求等待。失败不自动重发,避免 SMTP 超时后重复通知;人工核对未收到后通过邮件记录重试。进程中断超过五分钟的发送任务转为失败待核对。
+- `SENT` 表示 SMTP 已接受邮件,不代表车队已阅读或执行。前端入队后只提示待发送,不伪装为成功投递。邮件记录展示最近 100 封,展示收件人、冻结主题和正文、状态及失败信息。
+- 邮件发送、重试复用 `platform:firstleg:terminal-drayage:fleet:edit`,记录查看复用列表权限。已提柜、已卸柜或已还柜不再开放新的提柜邮件动作。
+
+## 代码与存储
+
+- `terminaldrayage/service/TerminalDrayageMailTemplates.java`:临时英文取消/提柜模板;正式文案在这里替换。已排队邮件保持原始正文快照。
+- `TerminalDrayageMailService.java`:联系资料校验、邮件入队与重试;`TerminalDrayageMailDispatcher.java`:SMTP 投递。
+- `terminaldrayage/mapper/TerminalDrayageMapper.java`:邮件存储与状态抢占;`fl_terminal_drayage_mail` 定义于 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 和国内统一 SQL 入口。
+- Warehouse `/internal/warehouse/v1/suppliers/{supplierId}/trucking-contact` 仅供内部令牌调用;地址不由浏览器提交。
+- 公网接口及动作以 `src/main/resources/openapi/terminal-drayage-v1.yaml`、Warehouse `supplier-v1.yaml` 为准。
+
+## 启用配置
+
+Firstleg 增加 Spring Boot mail starter,仅用于 SMTP,不引入独立邮件平台。默认 `TERMINAL_DRAYAGE_MAIL_ENABLED=false`,可保存任务但不会发信。启用前需配置正式发件账号并核对待发送任务;启用后会投递已排队邮件。
+
+| 环境变量 | 用途 |
+|---|---|
+| `TERMINAL_DRAYAGE_MAIL_ENABLED` | 默认 false,true 启用投递 |
+| `TERMINAL_DRAYAGE_MAIL_HOST` | SMTP 主机 |
+| `TERMINAL_DRAYAGE_MAIL_PORT` | 默认 587 |
+| `TERMINAL_DRAYAGE_MAIL_USERNAME` / `TERMINAL_DRAYAGE_MAIL_PASSWORD` | SMTP 账号与凭据,仅环境注入 |
+| `TERMINAL_DRAYAGE_MAIL_FROM` | 单个发件邮箱 |
+| `TERMINAL_DRAYAGE_MAIL_SSL` | 默认 false,强制 STARTTLS;true 使用隐式 TLS,一般配合 465 端口 |
+| `TERMINAL_DRAYAGE_MAIL_SCAN_DELAY` | 默认 10000 毫秒 |
+
+发布涉及 Admin、Firstleg、Warehouse 和新增邮件表;没有新增 IAM 权限。代码开发不授权部署、执行环境 SQL或向真实车队试发。UI 按现有列表与居中弹框承载,此新增交互依据当前用户需求,历史原型未定义邮件功能;待人工在 1920×1080、100% 缩放下截图确认。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
index 6452a9ce7..1b98b7535 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
@@ -44,9 +44,6 @@ public class OceanBookingPrintService {
private GeneratedPdf generate(long id, String requestId, String template, String suffix) {
Detail detail = bookings.detail(id);
OceanBookingEntity booking = detail.oceanBooking();
- if (!"RELEASED".equals(booking.getStatus())) {
- throw new BizException(ResultCode.CONFLICT, "订舱放舱后才可打印正式单据");
- }
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
@@ -97,7 +94,8 @@ public class OceanBookingPrintService {
fields.put("containerSummary", requirementSummary(detail.containerRequirements()));
fields.put("notes", text(booking.getRemark()));
}
- fields.values().removeIf(String::isEmpty);
+ // Missing booking data is displayed explicitly, including required PDF template fields.
+ fields.replaceAll((key, value) -> value.isEmpty() ? "—" : value);
return fields;
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
index bfbfb36d7..494789da7 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
@@ -9,6 +9,10 @@ import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectio
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
+import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest;
+import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService;
+import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService.Mailbox;
+import jakarta.annotation.Resource;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -36,20 +40,20 @@ import java.util.List;
@Tag(name = "目的港码头拖车")
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
- @jakarta.annotation.Resource private com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService mails;
+ @Resource private TerminalDrayageMailService mails;
@PostMapping("/{containerBookingId}/pickup-email")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse sendPickupEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key,
- @Valid @RequestBody com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
+ @Valid @RequestBody EmailRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.authorize(service.sendPickupEmail(containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@GetMapping("/{containerBookingId}/emails")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
- public ApiResponse emails(
+ public ApiResponse emails(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
return ApiResponse.success(mails.list(containerBookingId));
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
index f2d7f3182..e50cb8ec3 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
@@ -125,17 +125,17 @@ public interface TerminalDrayageMapper extends BaseMapper
@Param("kind") String kind, @Param("subject") String subject, @Param("body") String body,
@Param("operatorId") long operatorId);
- @Select("SELECT * FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId} ORDER BY id DESC LIMIT 100")
+ @Select("SELECT id,container_booking_id AS containerBookingId,fleet_supplier_id AS fleetSupplierId,fleet_name AS fleetName,recipient,kind,subject,body,status,attempts,failure_reason AS failureReason,created_at AS createdAt,sent_at AS sentAt FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId} ORDER BY id DESC LIMIT 100")
List mails(@Param("containerId") long containerId);
@Select("""
- SELECT * FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId}
+ SELECT id,container_booking_id AS containerBookingId,fleet_supplier_id AS fleetSupplierId,fleet_name AS fleetName,recipient,kind,subject,body,status,attempts,failure_reason AS failureReason,created_at AS createdAt,sent_at AS sentAt FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId}
AND fleet_supplier_id=#{supplierId} AND kind='REQUEST_PICKUP' ORDER BY id DESC LIMIT 1
""")
MailTask lastPickupMail(@Param("containerId") long containerId, @Param("supplierId") long supplierId);
@Select("""
- SELECT m.* FROM fl_terminal_drayage_mail m WHERE m.status='PENDING'
+ SELECT m.id,m.container_booking_id AS containerBookingId,m.fleet_supplier_id AS fleetSupplierId,m.fleet_name AS fleetName,m.recipient,m.kind,m.subject,m.body,m.status,m.attempts,m.failure_reason AS failureReason,m.created_at AS createdAt,m.sent_at AS sentAt FROM fl_terminal_drayage_mail m WHERE m.status='PENDING'
AND NOT EXISTS(SELECT 1 FROM fl_terminal_drayage_mail earlier
WHERE earlier.container_booking_id=m.container_booking_id AND earlier.id'SENT')
ORDER BY m.id LIMIT 20
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
index b0b34fc6c..d44d0ed46 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
@@ -43,14 +43,13 @@ public class TerminalDrayageMailDispatcher {
@Scheduled(fixedDelayString="${baiyue.terminal-drayage.mail.scan-delay:10000}")
public void dispatch() {
mapper.expireMails();
- var sender = sender();
for (var mail : mapper.pendingMails()) {
if (mapper.claimMail(mail.id()) != 1) continue;
try {
var message = new SimpleMailMessage();
message.setFrom(from); message.setTo(mail.recipient());
message.setSubject(mail.subject()); message.setText(mail.body());
- sender.send(message);
+ sender().send(message);
if (mapper.completeMail(mail.id()) != 1) log.warn("Terminal drayage mail completion requires review: {}", mail.id());
} catch (Exception exception) {
// SMTP 超时不等于未送达,禁止自动重发;失败阻断同柜后续邮件,交由人工核对。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
index 6cb5d76f8..afd2ede88 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
@@ -60,7 +60,7 @@ public class TerminalDrayageMailService {
static void validateAddress(String email) {
try {
- if (!StringUtils.hasText(email) || email.contains("\r") || email.contains("\n")) throw new IllegalArgumentException();
+ if (!StringUtils.hasText(email) || email.length() > 254 || email.contains("\r") || email.contains("\n")) throw new IllegalArgumentException();
InternetAddress[] parsed = InternetAddress.parse(email.trim(), true);
if (parsed.length != 1 || parsed[0].isGroup() || !email.trim().equals(parsed[0].getAddress())) throw new IllegalArgumentException();
parsed[0].validate();
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
index d6a6a272b..7956ff126 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -204,7 +205,7 @@ public class TerminalDrayageService {
@Transactional
public View sendPickupEmail(long containerBookingId, long operatorId, String operatorName, String key,
- com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
+ EmailRequest request) {
String action = "SEND_PICKUP_EMAIL";
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml
index fbb976456..aa8f58cd5 100644
--- a/firstleg/src/main/resources/application.yml
+++ b/firstleg/src/main/resources/application.yml
@@ -30,6 +30,16 @@ spring:
size: ${FIRSTLEG_SCHEDULING_POOL_SIZE:5}
thread-name-prefix: firstleg-scheduler-
baiyue:
+ terminal-drayage:
+ mail:
+ enabled: ${TERMINAL_DRAYAGE_MAIL_ENABLED:false}
+ host: ${TERMINAL_DRAYAGE_MAIL_HOST:}
+ port: ${TERMINAL_DRAYAGE_MAIL_PORT:587}
+ username: ${TERMINAL_DRAYAGE_MAIL_USERNAME:}
+ password: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:}
+ from: ${TERMINAL_DRAYAGE_MAIL_FROM:}
+ ssl: ${TERMINAL_DRAYAGE_MAIL_SSL:false}
+ scan-delay: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:10000}
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
index d20509348..51f3c9c4e 100644
--- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml
+++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
@@ -2032,7 +2032,7 @@ components:
allowedActions:
type: array
description: 已同时按真实业务状态与当前用户权限裁剪
- items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
+ items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
ContainerWorkbenchStatusSummary:
@@ -2079,7 +2079,7 @@ components:
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {type: object, nullable: true}
- allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
+ allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
index babc43d70..1ef8cff2c 100644
--- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
+++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
@@ -88,16 +88,16 @@ paths:
'200': {description: Original file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
- summary: Render the released booking as an export shipping instruction PDF
- description: Firstleg maps the authoritative released aggregate to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
+ summary: Render the current saved booking as an export shipping instruction PDF
+ description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
'200': {description: PDF or a business-error JSON envelope, content: {application/pdf: {schema: {type: string, format: binary}}, application/json: {schema: {type: object}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
- summary: Render the released booking as a bill-of-lading confirmation PDF
- description: Firstleg maps the authoritative released aggregate to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
+ summary: Render the current saved booking as a bill-of-lading confirmation PDF
+ description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml
index ad98b0d01..e13196f78 100644
--- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml
+++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml
@@ -52,11 +52,63 @@ paths:
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
+ /api/platform/v1/terminal-drayages/{containerBookingId}/pickup-email:
+ post:
+ summary: 将当前车队提柜邮件加入发送队列
+ description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。首次更换车队自动原子入队取消和新提柜两封邮件。同一车队重复选择不发信。
+ x-permission: platform:firstleg:terminal-drayage:fleet:edit
+ parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
+ requestBody: {required: true, content: {application/json: {schema: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}}}}}}
+ responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
+ /api/platform/v1/terminal-drayages/{containerBookingId}/emails:
+ get:
+ summary: 查询最近100封车队邮件及发信开关
+ x-permission: platform:firstleg:terminal-drayage:list
+ parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
+ responses:
+ '200':
+ description: 邮件记录;SENT表示SMTP已接受,不表示收件人已阅读
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code: {type: string}
+ data:
+ type: object
+ properties:
+ deliveryEnabled: {type: boolean}
+ records: {type: array, items: {$ref: '#/components/schemas/EmailTask'}}
+ /api/platform/v1/terminal-drayages/{containerBookingId}/emails/{mailId}/retry:
+ post:
+ summary: 核对失败邮件未收到后手动重试
+ description: 仅FAILED状态可重试,服务端校验mailId归属本柜;失败可能已经送达,须人工核对。同柜邮件依次发送,失败阻断后续邮件。
+ x-permission: platform:firstleg:terminal-drayage:fleet:edit
+ parameters:
+ - {$ref: '#/components/parameters/ContainerBookingId'}
+ - {name: mailId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ responses: {'200': {description: 已重新入队}}
components:
parameters:
ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}}
schemas:
+ EmailTask:
+ type: object
+ properties:
+ id: {type: string}
+ containerBookingId: {type: string}
+ fleetSupplierId: {type: string}
+ fleetName: {type: string}
+ recipient: {type: string, format: email}
+ kind: {type: string, enum: [REQUEST_PICKUP, CANCEL_PICKUP]}
+ subject: {type: string}
+ body: {type: string}
+ status: {type: string, enum: [PENDING, SENDING, SENT, FAILED]}
+ attempts: {type: integer}
+ failureReason: {type: string, nullable: true}
+ createdAt: {type: string, format: date-time}
+ sentAt: {type: string, format: date-time, nullable: true}
FleetSelectionRequest:
type: object
required: [expectedVersion, fleetSupplierId]
@@ -104,7 +156,7 @@ components:
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
- allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
+ allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
updatedAt: {type: string, format: date-time, nullable: true}
ContainerActualFacts:
@@ -127,7 +179,7 @@ components:
type: object
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
properties:
- action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
+ action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
fromStatus: {type: string}
toStatus: {type: string}
fromVersion: {type: integer, minimum: 0}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
index 24a26ad4d..25068db02 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
@@ -1,6 +1,5 @@
package com.baiyue.firstleg.oceanbooking.service;
-import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
@@ -22,7 +21,6 @@ import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -60,11 +58,34 @@ class OceanBookingPrintServiceTest {
});
}
- @Test void printRejectsDraftWithoutCallingPrintService() {
- when(bookings.detail(10L)).thenReturn(detail("DRAFT"));
- assertThatThrownBy(() -> service.billConfirmation(10L, "req-2"))
- .isInstanceOf(BizException.class).hasMessageContaining("放舱后");
- verifyNoInteractions(print);
+ @Test void bothDocumentsPrintDraftWithMissingReleaseData() {
+ Detail detail = detail("DRAFT");
+ detail.oceanBooking().setBillOfLadingNo(null);
+ detail.oceanBooking().setPlaceOfDelivery(null);
+ detail.oceanBooking().setServiceTerm(null);
+ detail.oceanBooking().setFreightTerm(null);
+ detail = new Detail(detail.oceanBooking(), List.of(detail.parties().get(0)),
+ detail.containerRequirements(), List.of(), List.of(), List.of());
+ when(bookings.detail(10L)).thenReturn(detail);
+ when(print.generateFreightA4Documents(eq("token"), eq("req-2"), any()))
+ .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1, 2, 3}));
+
+ assertThat(service.shippingInstruction(10L, "req-2").content()).isNotEmpty();
+ assertThat(service.billConfirmation(10L, "req-2").content()).isNotEmpty();
+
+ ArgumentCaptor request =
+ ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
+ verify(print, times(2)).generateFreightA4Documents(eq("token"), eq("req-2"), request.capture());
+ for (var captured : request.getAllValues()) {
+ assertThat(captured.documents().get(0).fields())
+ .containsEntry("shipper", "Shipper\nAddress")
+ .containsEntry("consignee", "—")
+ .containsEntry("notifyParty", "—")
+ .containsEntry("placeOfDelivery", "—")
+ .containsEntry("serviceTerm", "—")
+ .containsEntry("freightTerm", "—");
+ }
+ assertThat(request.getValue().documents().get(0).fields()).containsEntry("bookingNumber", "—");
}
private static Detail detail(String status) {
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java
index 2e35fc928..9c33e839a 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java
@@ -28,6 +28,9 @@ class TerminalDrayageOpenApiContractTest {
assertPermission("detail", "platform:firstleg:terminal-drayage:list");
assertPermission("history", "platform:firstleg:terminal-drayage:list");
assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit");
+ assertPermission("sendPickupEmail", "platform:firstleg:terminal-drayage:fleet:edit");
+ assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit");
+ assertPermission("emails", "platform:firstleg:terminal-drayage:list");
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
index cac6e7aaf..e98489c6d 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
@@ -8,6 +8,25 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageSqlContractTest {
+ @Test
+ void mailProjectionMatchesRecordAndPendingQueryBlocksEarlierUnsentMail() throws Exception {
+ var mapperType = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
+ var fields = java.util.Arrays.stream(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.MailTask.class.getRecordComponents())
+ .map(java.lang.reflect.RecordComponent::getName).toList();
+ for (String name : java.util.List.of("mails", "lastPickupMail", "pendingMails")) {
+ var method = java.util.Arrays.stream(mapperType.getMethods()).filter(value -> value.getName().equals(name)).findFirst().orElseThrow();
+ String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
+ String projection = sql.substring(sql.indexOf("SELECT")+6, sql.indexOf("FROM"));
+ var selected = java.util.Arrays.stream(projection.split(",")).map(String::trim)
+ .map(value -> value.contains(" AS ") ? value.substring(value.indexOf(" AS ")+4) : value.replace("m.", "")).toList();
+ assertThat(selected).isEqualTo(fields);
+ if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id'SENT'");
+ }
+ for (Path path : java.util.List.of(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
+ assertThat(Files.readString(path)).contains("CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))");
+ }
+ }
+
@Test
void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception {
for (Path path : java.util.List.of(
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java
new file mode 100644
index 000000000..50c12bebc
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java
@@ -0,0 +1,44 @@
+package com.baiyue.firstleg.terminaldrayage.service;
+
+import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.mail.MailSendException;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.util.List;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class TerminalDrayageMailDispatcherTest {
+ TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class);
+ JavaMailSenderImpl sender = mock(JavaMailSenderImpl.class);
+ TerminalDrayageMailDispatcher worker = spy(new TerminalDrayageMailDispatcher(mapper));
+
+ void setup() {
+ ReflectionTestUtils.setField(worker,"from","operations@example.com");
+ doReturn(sender).when(worker).sender();
+ when(mapper.pendingMails()).thenReturn(List.of(new TerminalDrayageMapper.MailTask(
+ 1L,11L,2L,"Fleet","fleet@example.com","REQUEST_PICKUP","Pickup","Body","PENDING",0,null,null,null)));
+ when(mapper.claimMail(1L)).thenReturn(1);
+ when(mapper.completeMail(1L)).thenReturn(1);
+ }
+
+ @Test void onlyClaimsOnceAndMarksSentAfterSmtpAccepted() {
+ setup(); worker.dispatch();
+ var ordered = inOrder(mapper,sender);
+ ordered.verify(mapper).claimMail(1L);
+ ordered.verify(sender).send(any(SimpleMailMessage.class));
+ ordered.verify(mapper).completeMail(1L);
+ when(mapper.claimMail(1L)).thenReturn(0);
+ worker.dispatch();
+ verify(sender,times(1)).send(any(SimpleMailMessage.class));
+ }
+
+ @Test void smtpFailureRequiresReviewInsteadOfReportingSuccess() {
+ setup(); doThrow(new MailSendException("uncertain")).when(sender).send(any(SimpleMailMessage.class));
+ worker.dispatch();
+ verify(mapper).failMail(eq(1L),contains("结果不确定"));
+ verify(mapper,never()).completeMail(anyLong());
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java
new file mode 100644
index 000000000..e8863fdc7
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java
@@ -0,0 +1,65 @@
+package com.baiyue.firstleg.terminaldrayage.service;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.firstleg.client.WarehouseSupplierClient;
+import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.test.util.ReflectionTestUtils;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class TerminalDrayageMailServiceTest {
+ TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class);
+ WarehouseSupplierClient suppliers = mock(WarehouseSupplierClient.class);
+ TerminalDrayageMailService service = new TerminalDrayageMailService(mapper, suppliers);
+
+ @BeforeEach void setup() {
+ ReflectionTestUtils.setField(service, "internalToken", "test-token");
+ when(suppliers.truckingContact("test-token", 1L, false)).thenReturn(ApiResponse.success(
+ new WarehouseSupplierClient.TruckingContact(1L, "Old Fleet", "old@example.com")));
+ when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success(
+ new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", "new@example.com")));
+ }
+
+ @Test void replacementQueuesCancellationThenRequestWithSeparateFrozenRecipients() {
+ service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
+ var order = inOrder(mapper);
+ order.verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Old Fleet"), eq("old@example.com"),
+ eq("CANCEL_PICKUP"), eq("Cancel port pickup - MSCU1234567"), contains("Please cancel"), eq(7L));
+ order.verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"),
+ eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
+ }
+
+ @Test void cancellationUsesOriginalRecipientEvenIfSupplierEmailChanged() {
+ when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
+ 90L,11L,1L,"Frozen Fleet","original@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
+ service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
+ verify(suppliers, never()).truckingContact("test-token", 1L, false);
+ verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Frozen Fleet"), eq("original@example.com"),
+ eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L));
+ }
+
+ @Test void missingNewEmailQueuesNeitherMail() {
+ when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success(
+ new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", null)));
+ assertThatThrownBy(() -> service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L)).isInstanceOf(BizException.class);
+ verify(mapper, never()).insertMail(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings={"", "not-an-email", "a@example.com,b@example.com", "a@example.com\r\nBcc: b@example.com", "Name "})
+ void rejectsInvalidOrMultipleRecipients(String email) {
+ assertThatThrownBy(() -> TerminalDrayageMailService.validateAddress(email)).isInstanceOf(BizException.class);
+ }
+
+ @Test void retriesOnlyFailedMailBelongingToContainer() {
+ assertThatThrownBy(() -> service.retry(11L, 55L, 7L)).isInstanceOf(BizException.class);
+ when(mapper.retryMail(11L,55L,7L)).thenReturn(1);
+ service.retry(11L,55L,7L);
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
index d8e86def6..babd5ddca 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class TerminalDrayageServiceTest {
@@ -45,6 +46,7 @@ class TerminalDrayageServiceTest {
private WarehouseSupplierSnapshotService suppliers;
private CarrierScheduleQueryService carrierSchedules;
private TerminalDrayageService service;
+ private TerminalDrayageMailService mails;
private Deque facts;
@BeforeEach
@@ -57,7 +59,8 @@ class TerminalDrayageServiceTest {
if (facts.isEmpty()) return List.of();
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
});
- service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
+ mails = mock(TerminalDrayageMailService.class);
+ service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails);
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@@ -343,6 +346,48 @@ class TerminalDrayageServiceTest {
assertThat(result.version()).isEqualTo(4);
}
+ @Test
+ void selectingSameFleetDoesNotQueueMailOrChangeVersion() {
+ when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null));
+ when(mapper.lock(11L)).thenReturn(row(3));
+ when(suppliers.trucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of()));
+ service.selectFleet(11L,7L,"admin","same-fleet",new FleetSelectionRequest(3,91L,null,null));
+ verifyNoInteractions(mails);
+ verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString());
+ }
+
+ @Test
+ void fleetReplacementQueuesBothEmailsBeforeSavingNewFleet() {
+ Context context = context("ARRIVED_DESTINATION_PORT",null);
+ when(mapper.lockContext(11L)).thenReturn(context);
+ when(mapper.context(11L)).thenReturn(context);
+ TerminalDrayageEntity row = row(3);
+ when(mapper.lock(11L)).thenReturn(row);
+ when(mapper.find(11L)).thenReturn(row);
+ when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenReturn(1);
+ when(suppliers.trucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of()));
+ service.selectFleet(11L,7L,"admin","change-fleet",new FleetSelectionRequest(3,92L,null,null));
+ var ordered = org.mockito.Mockito.inOrder(mails,mapper);
+ ordered.verify(mails).replaceFleet(11L,"MSCU1234567",91L,92L,7L);
+ ordered.verify(mapper).updateAggregate(any(),org.mockito.ArgumentMatchers.eq(3),org.mockito.ArgumentMatchers.eq(7L),org.mockito.ArgumentMatchers.eq("admin"));
+ assertThat(row.getFleetSupplierId()).isEqualTo(92L);
+ }
+
+ @Test
+ void manualPickupEmailUsesLockedVersionAndRejectsStaleClick() {
+ Context context = context("ARRIVED_DESTINATION_PORT",null);
+ when(mapper.lockContext(11L)).thenReturn(context);
+ when(mapper.context(11L)).thenReturn(context);
+ TerminalDrayageEntity row = row(3);
+ when(mapper.lock(11L)).thenReturn(row);
+ when(mapper.find(11L)).thenReturn(row);
+ when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> {row.setVersion(4);return 1;});
+ var request = new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(3);
+ service.sendPickupEmail(11L,7L,"admin","mail-1",request);
+ assertThatThrownBy(() -> service.sendPickupEmail(11L,7L,"admin","mail-2",request)).isInstanceOf(BizException.class);
+ verify(mails,times(1)).request(11L,"MSCU1234567",91L,7L);
+ }
+
private Context context(String node, LocalDateTime unloadedAt) {
int sequenceVersion = switch (node) {
case "PICKED_UP" -> 5;
diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md
index 044a7d7b7..5e9a9083f 100644
--- a/warehouse/AGENTS.md
+++ b/warehouse/AGENTS.md
@@ -21,7 +21,7 @@
| 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` |
| 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` |
| 海外仓阶梯费用 | `controller/PlatformOverseasStorageFeeController.java`、`service/OverseasStorageFeeService.java` |
-| 平台公共供应商 | `controller/PlatformSupplierController.java`、`service/SupplierService.java`;契约见 `src/main/resources/openapi/supplier-v1.yaml` |
+| 平台公共供应商 | `controller/PlatformSupplierController.java`、`service/SupplierService.java`;契约见 `src/main/resources/openapi/supplier-v1.yaml` `controller/InternalSupplierController.java` 的 `/{supplierId}/trucking-contact` 为 Firstleg 提供平台 TRUCKING 邮箱资料,取消旧请求允许读取已停用车队邮箱,仍验证内部令牌及平台归属。 |
| 国内整柜业务 | `domesticcontainer/controller/PlatformDomesticContainerController.java`、`domesticcontainer/service/DomesticContainerService.java`;契约见 `src/main/resources/openapi/domestic-container-v1.yaml` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL | `src/main/resources/db/`;存量统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
diff --git a/warehouse/src/main/resources/openapi/supplier-v1.yaml b/warehouse/src/main/resources/openapi/supplier-v1.yaml
index 2d760ac77..434d0f86a 100644
--- a/warehouse/src/main/resources/openapi/supplier-v1.yaml
+++ b/warehouse/src/main/resources/openapi/supplier-v1.yaml
@@ -81,6 +81,29 @@ paths:
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [status], properties: { status: { $ref: '#/components/schemas/Status' } } } } } }
responses: { '200': { description: 成功 } }
+ /internal/warehouse/v1/suppliers/{supplierId}/trucking-contact:
+ get:
+ summary: 内部查询码头车队收件资料
+ description: 固定校验TRUCKING类别与平台归属;取消旧车队请求允许requireActive=false。
+ security: [{internalServiceToken: []}]
+ parameters:
+ - {name: supplierId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
+ - {name: requireActive, in: query, schema: {type: boolean, default: true}}
+ responses:
+ '200':
+ description: 联系资料
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code: {type: string}
+ data:
+ type: object
+ properties:
+ supplierId: {type: string}
+ name: {type: string}
+ email: {type: string, nullable: true}
/internal/warehouse/v1/suppliers/{supplierId}:
get:
summary: 内部服务查询并校验供应商快照
diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java
new file mode 100644
index 000000000..df136ffc2
--- /dev/null
+++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java
@@ -0,0 +1,27 @@
+package com.baiyue.warehouse.controller;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.warehouse.dto.SupplierDtos.SupplierResource;
+import com.baiyue.warehouse.service.SupplierService;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import static org.mockito.Mockito.*;
+import static org.assertj.core.api.Assertions.*;
+
+class InternalSupplierContactTest {
+ @Test void contactRequiresInternalTokenAndChecksTruckingCategory() {
+ var service = mock(SupplierService.class);
+ var controller = new InternalSupplierController();
+ ReflectionTestUtils.setField(controller,"service",service);
+ ReflectionTestUtils.setField(controller,"internalToken","test-token");
+ controller.initializeToken();
+ assertThatThrownBy(() -> controller.truckingContact("wrong",1L,true)).isInstanceOf(BizException.class);
+ verifyNoInteractions(service);
+ var supplier = mock(SupplierResource.class);
+ when(supplier.name()).thenReturn("Fleet"); when(supplier.email()).thenReturn("fleet@example.com");
+ when(service.detail(1L)).thenReturn(supplier);
+ var result = controller.truckingContact("test-token",1L,false).data();
+ assertThat(result.email()).isEqualTo("fleet@example.com");
+ verify(service).internalSnapshot(1L,false,"TRUCKING");
+ }
+}
From 2980308f4d2b608ac8255010a4ec3d75e872cae2 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 15:27:10 +0800
Subject: [PATCH 06/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../event/BusinessFeeSourceChangedV1.java | 9 +++
.../PlatformFinanceVoucherController.java | 65 +++++++++++++++++++
.../BusinessFeeSourceEventService.java | 11 +++-
.../BusinessFeeSourceEventServiceTest.java | 6 +-
.../PlatformFinanceVoucherControllerTest.java | 51 +++++++++++++++
.../financefee/dto/OverseasFeeSourceDtos.java | 9 ++-
.../financefee/entity/OverseasFeeSource.java | 9 +++
.../service/OverseasFeeCurrencyConverter.java | 47 ++++++++++++++
.../service/OverseasFeeOutboxService.java | 5 +-
.../service/OverseasFeeSourceService.java | 18 ++++-
.../service/DestuffingPalletCostService.java | 4 ++
.../src/main/resources/db/lastleg.sql | 11 +++-
.../openapi/overseas-finance-fees-v1.yaml | 9 ++-
...OverseasContainerFeeSourceServiceTest.java | 8 ++-
.../service/OverseasFeeBusinessGuardTest.java | 6 +-
.../OverseasFeeCurrencyConverterTest.java | 37 +++++++++++
.../service/OverseasFeeOutboxServiceTest.java | 4 ++
17 files changed, 292 insertions(+), 17 deletions(-)
create mode 100644 finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java
create mode 100644 finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java
create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java
create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java
diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java
index 8ae7a67e5..012d99fc3 100644
--- a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java
+++ b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java
@@ -44,6 +44,15 @@ public record BusinessFeeSourceChangedV1(
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
+ String originalCurrency,
+ BigDecimal originalAmount,
+ BigDecimal exchangeRate,
+ String exchangeRateMonth,
+ BigDecimal chargeableQuantity,
+ String quantityUnit,
+ BigDecimal unitPrice,
+ LocalDate periodStart,
+ LocalDate periodEnd,
String reviewStatus,
String publicationStatus,
String sourceStatus,
diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java
new file mode 100644
index 000000000..2ad9c3bac
--- /dev/null
+++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java
@@ -0,0 +1,65 @@
+package com.baiyue.finance.controller;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.finance.client.FinanceFileClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.nio.charset.StandardCharsets;
+
+@RestController
+@RequestMapping("/api/platform/finance/v1/vouchers")
+@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
+public class PlatformFinanceVoucherController {
+ private final FinanceFileClient files;
+ private final String token;
+
+ public PlatformFinanceVoucherController(FinanceFileClient files,
+ @Value("${baiyue.security.internal-service-token:}") String token) {
+ this.files = files;
+ this.token = token;
+ }
+
+ public record Voucher(String fileId, String fileName, String contentType, Long sizeBytes) {}
+
+ @GetMapping("/{fileId}")
+ public ApiResponse metadata(@PathVariable long fileId, @RequestParam long customerId) {
+ var file = scopedFile(customerId, fileId);
+ return FinanceResponses.success(new Voucher(String.valueOf(fileId), file.fileName(), file.contentType(), file.sizeBytes()));
+ }
+
+ @GetMapping("/{fileId}/content")
+ public ResponseEntity content(@PathVariable long fileId, @RequestParam long customerId) {
+ var file = scopedFile(customerId, fileId);
+ var response = files.download(token, customerId, customerId, fileId);
+ if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "付款凭证读取失败,请稍后重试");
+ }
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore())
+ .header("X-Content-Type-Options", "nosniff")
+ .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
+ .filename(file.fileName() == null ? "付款凭证" : file.fileName(), StandardCharsets.UTF_8).build().toString())
+ .contentType(MediaType.APPLICATION_OCTET_STREAM).body(response.getBody());
+ }
+
+ private FinanceFileClient.Metadata scopedFile(long customerId, long fileId) {
+ if (customerId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户或付款凭证无效");
+ var response = files.metadata(token, customerId, customerId, fileId);
+ var file = response == null ? null : response.data();
+ if (file == null || !Long.valueOf(fileId).equals(file.fileId())
+ || !Long.valueOf(customerId).equals(file.tenantId())
+ || !"FINANCE_VOUCHER".equals(file.businessType())
+ || !String.valueOf(customerId).equals(file.businessId())) {
+ throw new BizException(ResultCode.BAD_REQUEST, "付款凭证不存在或不属于当前客户");
+ }
+ return file;
+ }
+}
diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
index a1f421fe9..946bfce8e 100644
--- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
@@ -115,6 +115,13 @@ public class BusinessFeeSourceEventService {
|| blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction())
|| value.businessDate() == null || !"CNY".equals(value.currency())
|| value.totalAmount() == null || value.totalAmount().signum() <= 0
+ || !Set.of("CNY", "USD").contains(value.originalCurrency())
+ || value.originalAmount() == null || value.originalAmount().signum() <= 0
+ || value.exchangeRate() == null || value.exchangeRate().signum() <= 0
+ || blank(value.exchangeRateMonth())
+ || (value.periodStart() == null) != (value.periodEnd() == null)
+ || value.periodStart() != null && value.periodStart().isAfter(value.periodEnd())
+ || (value.chargeableQuantity() == null) != blank(value.quantityUnit())
|| !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) {
throw error("FIN-EVT-012", "跨区费用来源快照字段不完整");
}
@@ -148,7 +155,9 @@ public class BusinessFeeSourceEventService {
value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(),
value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(),
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
- value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
+ value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
+ value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
+ value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
index a997a3a92..5392b1a73 100644
--- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
+++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
@@ -69,7 +69,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
- new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null);
+ new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
+ "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
@@ -77,7 +78,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
- new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
+ new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
+ "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java
new file mode 100644
index 000000000..62a70024d
--- /dev/null
+++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java
@@ -0,0 +1,51 @@
+package com.baiyue.finance;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.finance.client.FinanceFileClient;
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.finance.controller.PlatformFinanceVoucherController;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class PlatformFinanceVoucherControllerTest {
+ private final FinanceFileClient files = mock(FinanceFileClient.class);
+ private final PlatformFinanceVoucherController controller = new PlatformFinanceVoucherController(files, "test-token");
+
+ private FinanceFileClient.Metadata file(long customerId, String type, String businessId) {
+ return new FinanceFileClient.Metadata(91L, customerId, type, businessId, "付款.png", "sha", "image/png", 3L);
+ }
+
+ @Test void returnsFilenameAndDownloadsWithinCustomerScope() {
+ when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
+ when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
+ assertThat(controller.metadata(91, 11).data().fileName()).isEqualTo("付款.png");
+ var content = controller.content(91, 11);
+ assertThat(content.getBody()).containsExactly(1, 2, 3);
+ assertThat(content.getHeaders().getCacheControl()).isEqualTo("no-store");
+ assertThat(content.getHeaders().getFirst("Content-Disposition")).startsWith("attachment;");
+ }
+
+ @Test void rejectsWrongCustomerBusinessTypeAndBusinessIdBeforeDownload() {
+ for (var invalid : new FinanceFileClient.Metadata[]{file(12, "FINANCE_VOUCHER", "11"), file(11, "OTHER", "11"), file(11, "FINANCE_VOUCHER", "12")}) {
+ when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(invalid, "test", "test"));
+ assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
+ }
+ verify(files, never()).download(anyString(), anyLong(), anyLong(), anyLong());
+ }
+
+ @Test void rejectsMissingMetadataAndFailedContent() {
+ assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
+ when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
+ when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.notFound().build());
+ assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
+ }
+
+ @Test void requiresPlatformListPermission() {
+ assertThat(PlatformFinanceVoucherController.class.getAnnotation(PreAuthorize.class).value())
+ .isEqualTo("hasAuthority('platform:finance:customer-arap:list')");
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
index af3d859bf..0c4a4b379 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
@@ -43,8 +43,13 @@ public final class OverseasFeeSourceDtos {
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@NotNull LocalDate businessDate,
- @NotBlank @Pattern(regexp = "CNY") String currency,
- @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
+ @NotBlank @Pattern(regexp = "CNY|USD") String currency,
+ @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
+ @DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
+ @Size(max = 32) String quantityUnit,
+ @DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
+ LocalDate periodStart,
+ LocalDate periodEnd
) { }
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java
index 59507799f..860dd5991 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java
@@ -46,6 +46,15 @@ public class OverseasFeeSource {
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
+ private String originalCurrency;
+ private BigDecimal originalAmount;
+ private BigDecimal exchangeRate;
+ private String exchangeRateMonth;
+ private BigDecimal chargeableQuantity;
+ private String quantityUnit;
+ private BigDecimal unitPrice;
+ private LocalDate periodStart;
+ private LocalDate periodEnd;
private String reviewStatus;
private String publicationStatus;
private String sourceStatus;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java
new file mode 100644
index 000000000..69263374b
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java
@@ -0,0 +1,47 @@
+package com.baiyue.lastleg.financefee.service;
+
+import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
+import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+@Component
+@RequiredArgsConstructor
+public class OverseasFeeCurrencyConverter {
+ private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM");
+ private final MonthlyExchangeRateMapper rates;
+
+ public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) {
+ String originalCurrency = currency == null ? null : currency.trim().toUpperCase();
+ BigDecimal originalAmount = amount == null ? null : amount.setScale(2, RoundingMode.HALF_UP);
+ if ("CNY".equals(originalCurrency)) {
+ return new Conversion(originalCurrency, originalAmount, BigDecimal.ONE.setScale(6),
+ businessDate.format(MONTH), originalAmount);
+ }
+ if (!"USD".equals(originalCurrency)) throw bad("海外费用原币只支持CNY或USD");
+ String month = businessDate.format(MONTH);
+ MonthlyExchangeRate rate = rates.findMonth(month);
+ if (rate == null || !"ACTIVE".equals(rate.getStatus()) || rate.getUsdToCnyAverage() == null
+ || rate.getUsdToCnyAverage().signum() <= 0) {
+ throw bad("业务月份缺少有效USD兑CNY汇率,不能发布财务费用");
+ }
+ BigDecimal exchangeRate = rate.getUsdToCnyAverage().setScale(6, RoundingMode.HALF_UP);
+ return new Conversion(originalCurrency, originalAmount, exchangeRate, month,
+ originalAmount.multiply(exchangeRate).setScale(2, RoundingMode.HALF_UP));
+ }
+
+ private static ResponseStatusException bad(String message) {
+ return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
+ }
+
+ public record Conversion(String originalCurrency, BigDecimal originalAmount,
+ BigDecimal exchangeRate, String exchangeRateMonth,
+ BigDecimal settlementAmount) { }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java
index ed5410c7e..349dab9e1 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java
@@ -66,7 +66,10 @@ public class OverseasFeeOutboxService {
text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(),
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
- value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
+ value.getBusinessDate(), value.getCurrency(), value.getAmount(),
+ value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
+ value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
+ value.getReviewStatus(),
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
index 8318c2813..123f3f3c5 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
@@ -19,7 +19,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
-import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -40,6 +39,7 @@ public class OverseasFeeSourceService {
private final OverseasFeeCommandMapper commands;
private final OverseasFeeBusinessGuard businessGuard;
private final OverseasFeeOutboxService outbox;
+ private final OverseasFeeCurrencyConverter currencyConverter;
private final ObjectMapper objectMapper;
public List list(String customerId, String businessType, String businessNo,
@@ -151,8 +151,14 @@ public class OverseasFeeSourceService {
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
- value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setCurrency(request.currency());
- value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT");
+ value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
+ OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
+ value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
+ value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
+ value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
+ value.setChargeableQuantity(request.chargeableQuantity()); value.setQuantityUnit(text(request.quantityUnit()));
+ value.setUnitPrice(request.unitPrice()); value.setPeriodStart(request.periodStart()); value.setPeriodEnd(request.periodEnd());
+ value.setReviewStatus("DRAFT");
value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now()); value.setUpdatedAt(now());
return value;
@@ -172,6 +178,12 @@ public class OverseasFeeSourceService {
if ("OVERSEAS_CONTAINER".equals(value.businessType())
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
throw bad("海外货柜费用项目不受支持");
+ if ((value.periodStart() == null) != (value.periodEnd() == null)
+ || value.periodStart() != null && value.periodStart().isAfter(value.periodEnd()))
+ throw bad("计费期间必须同时填写且开始日期不能晚于结束日期");
+ if ((value.chargeableQuantity() == null) != (text(value.quantityUnit()) == null)
+ || value.unitPrice() != null && value.chargeableQuantity() == null)
+ throw bad("计费数量、单位和单价信息不完整");
boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) {
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java
index 2ad342942..946ded305 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java
@@ -83,6 +83,10 @@ public class DestuffingPalletCostService {
v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo);
v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE");
v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount);
+ v.setOriginalCurrency("CNY"); v.setOriginalAmount(amount); v.setExchangeRate(BigDecimal.ONE.setScale(6));
+ v.setExchangeRateMonth(LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
+ v.setChargeableQuantity(volume.volume()); v.setQuantityUnit("CBM");
+ v.setUnitPrice(volume.volume().signum() > 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null);
v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator);
v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v;
}
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index 5480c02c4..9e97b8de2 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -832,8 +832,13 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
- business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY',
- amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
+ business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY',
+ amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD',
+ original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
+ exchange_rate_month CHAR(7) NOT NULL COMMENT '汇率月份yyyy-MM', chargeable_quantity DECIMAL(20,6) NULL COMMENT '计费数量,如箱数、托数、体积或天数',
+ quantity_unit VARCHAR(32) NULL COMMENT '计费数量单位', unit_price DECIMAL(20,6) NULL COMMENT '原币计费单价',
+ period_start DATE NULL COMMENT '仓租等期间费用开始日期', period_end DATE NULL COMMENT '仓租等期间费用结束日期',
+ review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
@@ -842,6 +847,8 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
+ CONSTRAINT chk_ll_finance_fee_original_currency CHECK(original_currency IN('CNY','USD')),
+ CONSTRAINT chk_ll_finance_fee_period CHECK((period_start IS NULL AND period_end IS NULL) OR (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start<=period_end)),
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
index 6c2072bf1..13c19aa0d 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
@@ -85,6 +85,11 @@ components:
feeItemName: { type: string }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
businessDate: { type: string, format: date }
- currency: { type: string, enum: [CNY] }
- amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true }
+ currency: { type: string, enum: [CNY, USD], description: 业务原币;USD由服务端按业务月份有效汇率折算为CNY入池 }
+ amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, description: 业务原币金额 }
+ chargeableQuantity: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true }
+ quantityUnit: { type: string, maxLength: 32, nullable: true }
+ unitPrice: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true }
+ periodStart: { type: string, format: date, nullable: true }
+ periodEnd: { type: string, format: date, nullable: true }
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java
index a0a883740..59c3f2e4c 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
+import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -22,7 +23,9 @@ class OverseasContainerFeeSourceServiceTest {
private final OverseasFeeBusinessGuard guard = mock(OverseasFeeBusinessGuard.class);
private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class);
private final OverseasFeeSourceService service = new OverseasFeeSourceService(
- mapper, commands, guard, outbox, new ObjectMapper().findAndRegisterModules());
+ mapper, commands, guard, outbox,
+ new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)),
+ new ObjectMapper().findAndRegisterModules());
@Test
void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() {
@@ -50,7 +53,8 @@ class OverseasContainerFeeSourceServiceTest {
source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
- source.businessDate(), source.currency(), source.amount());
+ source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
+ source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd());
when(commands.find("operator-a", "CREATE", "bad-container-fee")).thenReturn(null);
assertThatThrownBy(() -> service.create("operator-a", "bad-container-fee", request))
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java
index a3ae4605a..d91032261 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java
@@ -57,7 +57,8 @@ class OverseasFeeBusinessGuardTest {
source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
- source.businessDate(), source.currency(), source.amount());
+ source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
+ source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd());
StubJdbc jdbc = new StubJdbc();
assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent))
@@ -83,7 +84,8 @@ class OverseasFeeBusinessGuardTest {
return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER",
bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L,
"PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null,
- List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"));
+ List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"),
+ null, null, null, null, null);
}
private static final class StubJdbc extends JdbcTemplate {
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java
new file mode 100644
index 000000000..40358bd1f
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java
@@ -0,0 +1,37 @@
+package com.baiyue.lastleg.financefee.service;
+
+import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
+import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OverseasFeeCurrencyConverterTest {
+ private final MonthlyExchangeRateMapper rates = mock(MonthlyExchangeRateMapper.class);
+ private final OverseasFeeCurrencyConverter converter = new OverseasFeeCurrencyConverter(rates);
+
+ @Test
+ void convertsUsdUsingTheActiveRateForTheBusinessMonth() {
+ MonthlyExchangeRate rate = new MonthlyExchangeRate();
+ rate.setStatus("ACTIVE"); rate.setUsdToCnyAverage(new BigDecimal("7.120000"));
+ when(rates.findMonth("2026-09")).thenReturn(rate);
+
+ var result = converter.convert("USD", new BigDecimal("12.34"), LocalDate.of(2026, 9, 9));
+
+ assertThat(result.originalAmount()).isEqualByComparingTo("12.34");
+ assertThat(result.exchangeRate()).isEqualByComparingTo("7.120000");
+ assertThat(result.settlementAmount()).isEqualByComparingTo("87.86");
+ }
+
+ @Test
+ void refusesUsdWhenTheBusinessMonthHasNoActiveRate() {
+ assertThatThrownBy(() -> converter.convert("USD", BigDecimal.TEN, LocalDate.of(2026, 9, 9)))
+ .hasMessageContaining("缺少有效USD兑CNY汇率");
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java
index e76b49736..6f9a6acdf 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java
@@ -38,6 +38,8 @@ class OverseasFeeOutboxServiceTest {
source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费");
source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2));
source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00"));
+ source.setOriginalCurrency("USD"); source.setOriginalAmount(new BigDecimal("16.85"));
+ source.setExchangeRate(new BigDecimal("7.122000")); source.setExchangeRateMonth("2026-09");
source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED");
source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of());
@@ -52,5 +54,7 @@ class OverseasFeeOutboxServiceTest {
assertThat(payload.inboundId()).isNull();
assertThat(payload.inboundCode()).isNull();
assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71);
+ assertThat(payload.originalCurrency()).isEqualTo("USD");
+ assertThat(payload.exchangeRateMonth()).isEqualTo("2026-09");
}
}
From 9fdb199972be766c4c09cc59016c756a85ff002d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 15:41:32 +0800
Subject: [PATCH 07/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?=
=?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E6=B5=8B=E8=AF=95=E7=9A=84?=
=?UTF-8?q?=E4=B8=89=E6=AC=A1=E9=87=8D=E5=90=AF=E7=AD=96=E7=95=A5=E6=96=AD?=
=?UTF-8?q?=E8=A8=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../baiyue/file/config/ExportInfrastructureContractTest.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java
index 333f3e4ca..41bcb25fa 100644
--- a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java
+++ b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java
@@ -33,7 +33,8 @@ class ExportInfrastructureContractTest {
assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?"));
assertTrue(compose.contains("sh -eu -c 'until sh mqadmin clusterList"));
assertTrue(compose.contains("| grep -q \"BaiyueCluster\""));
- assertTrue(compose.contains("restart: on-failure"));
+ assertTrue(compose.contains("restart: \"on-failure:3\""),
+ "RocketMQ bootstrap must limit automatic restart retries to three");
String environment=read("..","deploy","env",".env.infra-test.example");
assertTrue(environment.contains("ROCKETMQ_BROKER_IP=rocketmq-broker"));
assertTrue(environment.contains("MQ_TOPIC_PREFIX=test-"));
From 17b9f3eb73d79273973ac09e41fa20d278c8bbcb Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 16:37:28 +0800
Subject: [PATCH 08/42] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=B4=A2=E5=8A=A1?=
=?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=9D=A5=E6=BA=90=E5=85=A5=E6=B1=A0=E9=93=BE?=
=?UTF-8?q?=E8=B7=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 34 ++++++++++
deploy/sql/海外区域统一增量-MySQL8.sql | 41 ++++++++++++
.../baiyue/finance/dto/FinancePhase1Dtos.java | 12 ++--
.../BusinessFeeSourceEventService.java | 11 +++-
.../FinancePhase1OperationService.java | 23 +++++--
.../service/WalletCommandEventService.java | 7 +-
.../db/fee-source-event-migration.sql | 2 +
finance/src/main/resources/db/finance.sql | 4 +-
.../BusinessFeeSourceEventServiceTest.java | 1 +
.../WalletCommandEventServiceTest.java | 16 +++++
.../FinanceExportProviderServiceTest.java | 3 +-
.../controller/LastlegOrderController.java | 8 ++-
.../delivery/dto/DeliveryRequests.java | 20 +++++-
.../lastleg/delivery/entity/DeliveryFee.java | 24 -------
.../delivery/mapper/DeliveryFeeMapper.java | 16 -----
.../service/DeliveryTransferService.java | 41 +++++++++---
.../dto/LastlegOrderDetailResponse.java | 4 +-
.../lastleg/dto/LastlegOrderRequests.java | 23 +++++--
.../lastleg/entity/LastlegOrderFee.java | 25 -------
.../financefee/dto/OverseasFeeSourceDtos.java | 4 +-
.../service/OverseasFeeBusinessGuard.java | 26 ++++++++
.../service/OverseasFeeSourceService.java | 35 ++++++++++
.../lastleg/mapper/LastlegOrderFeeMapper.java | 9 ---
.../service/OverseasProcurementService.java | 21 ++++++
.../service/RelabelApplicationService.java | 24 ++++++-
.../lastleg/service/LastlegOrderService.java | 54 ++++++++-------
.../src/main/resources/db/lastleg.sql | 22 -------
.../openapi/overseas-business-v1.yaml | 66 +++++++++++++------
.../openapi/overseas-finance-fees-v1.yaml | 4 +-
.../MerchantDeliveryControllerTest.java | 4 +-
.../service/DeliveryTransferServiceTest.java | 14 ++--
.../OverseasProcurementServiceTest.java | 5 ++
.../RelabelApplicationServiceTest.java | 5 ++
.../Phase5OverseasUpgradeSqlContractTest.java | 2 +-
.../service/LastlegOrderServiceTest.java | 27 +++++---
35 files changed, 442 insertions(+), 195 deletions(-)
delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java
delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java
delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java
delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index f0ae5bd88..4bf249086 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -12185,6 +12185,8 @@ CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
+CALL finance_fee_source_add_column('fin_fee_source', 'wallet_hold_id',
+ "BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
@@ -18387,6 +18389,38 @@ DELIMITER ;
CALL migrate_container_workspace_permissions();
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
+
+-- 财务 P0:海外费用原币、计费依据与钱包结算关联
+DROP PROCEDURE IF EXISTS add_finance_p0_column;
+DELIMITER $$
+CREATE PROCEDURE add_finance_p0_column(
+ IN target_schema VARCHAR(64), IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
+)
+BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=target_schema AND table_name=target_table)
+ AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=target_schema AND table_name=target_table AND column_name=target_column) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_schema, '`.`', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
+ PREPARE statement FROM @ddl;
+ EXECUTE statement;
+ DEALLOCATE PREPARE statement;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','original_currency',"CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '原始业务币种' AFTER amount");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','original_amount',"DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '原币金额' AFTER original_currency");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','exchange_rate',"DECIMAL(20,8) NOT NULL DEFAULT 1 COMMENT '原币折算CNY汇率' AFTER original_amount");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','exchange_rate_month',"CHAR(7) NOT NULL DEFAULT '1970-01' COMMENT '汇率月份YYYY-MM' AFTER exchange_rate");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','chargeable_quantity',"DECIMAL(20,6) NULL COMMENT '计费数量' AFTER exchange_rate_month");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','quantity_unit',"VARCHAR(32) NULL COMMENT '计费单位' AFTER chargeable_quantity");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','unit_price',"DECIMAL(20,6) NULL COMMENT '原币单价' AFTER quantity_unit");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','period_start',"DATE NULL COMMENT '计费期间开始日' AFTER unit_price");
+CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','period_end',"DATE NULL COMMENT '计费期间结束日' AFTER period_start");
+CALL add_finance_p0_column('baiyue_finance','fin_fee_source','wallet_hold_id',"BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
+DROP PROCEDURE IF EXISTS add_finance_p0_column;
+
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)'));
+PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
-- 货柜双工作台权限结束,恢复头程数据库上下文
USE baiyue_firstleg;
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index 0ed4eb788..67f990886 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -2052,3 +2052,44 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
+
+-- 财务 P0:海外费用保留原币、月度汇率和计费依据。
+USE baiyue_lastleg;
+DROP PROCEDURE IF EXISTS add_overseas_finance_p0_column;
+DELIMITER $$
+CREATE PROCEDURE add_overseas_finance_p0_column(IN p_column VARCHAR(64), IN p_definition TEXT)
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='baiyue_lastleg' AND table_name='ll_finance_fee_source' AND column_name=p_column) THEN
+ SET @ddl = CONCAT('ALTER TABLE ll_finance_fee_source ADD COLUMN ', p_column, ' ', p_definition);
+ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+CALL add_overseas_finance_p0_column('original_currency', "VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '原币币种'");
+CALL add_overseas_finance_p0_column('original_amount', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '原币金额'");
+CALL add_overseas_finance_p0_column('exchange_rate', "DECIMAL(18,8) NOT NULL DEFAULT 1 COMMENT '原币折算CNY汇率'");
+CALL add_overseas_finance_p0_column('exchange_rate_month', "CHAR(7) NULL COMMENT '汇率月份YYYY-MM'");
+CALL add_overseas_finance_p0_column('chargeable_quantity', "DECIMAL(18,4) NULL COMMENT '计费数量'");
+CALL add_overseas_finance_p0_column('quantity_unit', "VARCHAR(32) NULL COMMENT '计费单位'");
+CALL add_overseas_finance_p0_column('unit_price', "DECIMAL(18,4) NULL COMMENT '原币单价'");
+CALL add_overseas_finance_p0_column('period_start', "DATE NULL COMMENT '周期费用开始日期'");
+CALL add_overseas_finance_p0_column('period_end', "DATE NULL COMMENT '周期费用结束日期'");
+DROP PROCEDURE IF EXISTS add_overseas_finance_p0_column;
+
+DROP PROCEDURE IF EXISTS assert_overseas_finance_p0_ready;
+DELIMITER $$
+CREATE PROCEDURE assert_overseas_finance_p0_ready()
+BEGIN
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema='baiyue_lastleg' AND table_name='ll_finance_fee_source'
+ AND column_name IN ('original_currency','original_amount','exchange_rate','exchange_rate_month',
+ 'chargeable_quantity','quantity_unit','unit_price','period_start','period_end')) <> 9 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外财务P0原币、汇率和计费依据字段未完整部署';
+ END IF;
+END$$
+DELIMITER ;
+CALL assert_overseas_finance_p0_ready();
+DROP PROCEDURE IF EXISTS assert_overseas_finance_p0_ready;
+
+SELECT 'PASS' AS migration_status, '海外财务P0费用来源字段已就绪' AS message;
diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java
index 17210777c..476a75f6a 100644
--- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java
+++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java
@@ -55,8 +55,10 @@ public final class FinancePhase1Dtos {
Long supplierId, String supplierName, Long orderId,
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
- BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
- String currency, String reviewStatus,
+ BigDecimal quantity, String quantityUnit, BigDecimal unitPrice,
+ LocalDate periodStart, LocalDate periodEnd, BigDecimal amount,
+ String currency, String originalCurrency, BigDecimal originalAmount,
+ BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
@@ -65,8 +67,10 @@ public final class FinancePhase1Dtos {
String customerName, Long supplierId, String supplierName,
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
- BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
- String currency, String reviewStatus,
+ BigDecimal quantity, String quantityUnit, BigDecimal unitPrice,
+ LocalDate periodStart, LocalDate periodEnd, BigDecimal amount,
+ String currency, String originalCurrency, BigDecimal originalAmount,
+ BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus,
String sourceStatus, boolean statementGenerated,
String reviewReason, String sourceHash,
int operationVersion, List operationLogs) {}
diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
index 946bfce8e..2089ae768 100644
--- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
@@ -102,6 +102,7 @@ public class BusinessFeeSourceEventService {
voucherJson(source.paymentVoucherFileIds()), source.sourceHash(), json(source));
long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?",
Long.class, system, source.sourceType(), feeId, source.sourceVersion());
+ linkSettledWalletHold(id, source);
if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode()))
materializeDirectPaidCost(system, feeId, source);
return id;
@@ -175,15 +176,21 @@ public class BusinessFeeSourceEventService {
}
private void requireSourceNotFrozen(String system, String type, long feeId) {
- Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId);
+ Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (f.wallet_hold_id IS NOT NULL OR EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代");
}
private void requireNotFrozen(long id) {
- Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id);
+ Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (f.wallet_hold_id IS NOT NULL OR EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废");
}
+ private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) {
+ if (!"RECEIVABLE".equals(source.direction())) return;
+ jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount",
+ feeSourceId);
+ }
+
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java
index efedc01dd..41f128c0f 100644
--- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java
@@ -152,7 +152,7 @@ public class FinancePhase1OperationService {
String normalizedReview = normalized(reviewStatus,
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
- List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
+ List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
@@ -322,9 +322,15 @@ public class FinancePhase1OperationService {
if (generated && "AVAILABLE".equals(status)) status = "BILLED";
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
- BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
+ BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
+ BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"),
+ quantity != null && quantity.signum() > 0 ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
- nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
+ nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
+ quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
+ amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
+ firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
+ defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
@@ -333,15 +339,18 @@ public class FinancePhase1OperationService {
boolean generated = rs.getBoolean("statement_generated");
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = money(rs, "amount");
- BigDecimal unitPrice = quantity != null && quantity.signum() > 0
- ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
+ BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
+ BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0
+ ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
- quantity, unitPrice, amount, rs.getString("currency_code"),
+ quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
+ amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
+ firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
@@ -421,10 +430,12 @@ public class FinancePhase1OperationService {
}
}
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
+ private static BigDecimal firstDecimal(BigDecimal first, BigDecimal second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; }
private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); }
private static LocalDate date(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? LocalDate.of(1970, 1, 1) : LocalDate.parse(value); }
+ private static LocalDate nullableDate(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : LocalDate.parse(value); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java
index 87725a744..843b3d644 100644
--- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java
@@ -59,7 +59,7 @@ public class WalletCommandEventService {
}
private WalletHold execute(WalletCommandRequestedV1 command) {
- return switch (command.operation()) {
+ WalletHold hold = switch (command.operation()) {
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
@@ -68,6 +68,11 @@ public class WalletCommandEventService {
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
};
+ if ("SETTLE".equals(command.operation())) {
+ jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount",
+ hold.holdId());
+ }
+ return hold;
}
private WalletCommandRequestedV1 validate(EventEnvelope envelope) {
diff --git a/finance/src/main/resources/db/fee-source-event-migration.sql b/finance/src/main/resources/db/fee-source-event-migration.sql
index 580e757d7..374949a68 100644
--- a/finance/src/main/resources/db/fee-source-event-migration.sql
+++ b/finance/src/main/resources/db/fee-source-event-migration.sql
@@ -63,6 +63,8 @@ CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
+CALL finance_fee_source_add_column('fin_fee_source', 'wallet_hold_id',
+ "BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql
index 9ce08b473..4ec095e21 100644
--- a/finance/src/main/resources/db/finance.sql
+++ b/finance/src/main/resources/db/finance.sql
@@ -35,13 +35,15 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号',
- status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
+ wallet_hold_id BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID',
+ status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SETTLED资金账户已结清、SUPERSEDED已被新版本替代、VOIDED已作废',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_fee_source (source_system, source_type, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status),
KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id),
+ KEY idx_fin_fee_source_wallet_hold (wallet_hold_id),
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
CONSTRAINT chk_fin_fee_source_settlement CHECK (
diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
index 5392b1a73..edce650ca 100644
--- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
+++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
@@ -61,6 +61,7 @@ class BusinessFeeSourceEventServiceTest {
assertThat(values.getValue()[5]).isEqualTo("PO-US-001");
assertThat(values.getValue()[9]).isNull();
assertThat(values.getValue()[13]).isNull();
+ verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(901L));
}
private EventEnvelope eventWithoutInbound() {
diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java
index 5abfcde17..42481247f 100644
--- a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java
+++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java
@@ -71,6 +71,22 @@ class WalletCommandEventServiceTest {
eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId()));
}
+ @Test
+ void settledWalletHoldMarksMatchingReceivableSourceSettled() {
+ EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50"));
+ when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
+ .thenReturn(List.of());
+ when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
+ when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
+ eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L,
+ "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"),
+ new BigDecimal("8.50"), "SETTLED", 71L, 72L, null, null, null));
+
+ service.consume(envelope);
+
+ verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L));
+ }
+
@Test
void rejectsCustomerMismatchBeforeTouchingWallet() {
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00"));
diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java
index 12976c3d2..663f1c3e9 100644
--- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java
+++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java
@@ -64,7 +64,8 @@ class FinanceExportProviderServiceTest {
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
- BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED",
+ BigDecimal.TEN, "KG", new BigDecimal("30"), null, null, new BigDecimal("300"), "CNY",
+ "CNY", new BigDecimal("300"), BigDecimal.ONE, "2026-08", "APPROVED",
"AVAILABLE", true, null, "hash", 2, List.of());
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
index 0984202f9..db718ffc9 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
@@ -10,7 +10,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
-import com.baiyue.lastleg.entity.LastlegOrderFee;
+import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -132,12 +132,14 @@ public class LastlegOrderController {
@PostMapping("/api/platform/v1/last-leg/orders/{id}/fees")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
- public ApiResponse createFee(
+ public ApiResponse createFee(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
+ @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody LastlegOrderRequests.CreateFee request) {
- return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
+ return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request,
+ operatorName(user), idempotencyKey));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java
index 5716aaafd..a33009f98 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java
@@ -9,6 +9,7 @@ import jakarta.validation.Valid;
import java.util.List;
import java.math.BigDecimal;
import java.time.LocalDateTime;
+import java.time.LocalDate;
public final class DeliveryRequests {
private DeliveryRequests() {}
@@ -31,8 +32,23 @@ public final class DeliveryRequests {
@NotNull @Valid @Size(min = 1, max = 20) List fees) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
- @NotBlank @Size(max = 8) String currency,
- @NotNull @Min(0) BigDecimal amount,
+ @NotBlank @Size(max = 128) String feeName,
+ @NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
+ @NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency,
+ @NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
+ @Min(1) Long supplierId,
+ @Size(max = 200) String supplierName,
+ @Size(max = 32) String supplierCategory,
+ @jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
+ LocalDateTime paidAt,
+ @jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
+ @Size(max = 128) String paymentReferenceNo,
+ @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
+ @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
+ @Size(max = 32) String quantityUnit,
+ @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
+ LocalDate periodStart,
+ LocalDate periodEnd,
@Size(max = 500) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java
deleted file mode 100644
index adcf4cc51..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.baiyue.lastleg.delivery.entity;
-
-import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.TableName;
-import lombok.Data;
-
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-
-@Data
-@TableName("ll_delivery_fee")
-public class DeliveryFee {
- @TableId(type = IdType.ASSIGN_ID) private Long id;
- private String customerId;
- private Long deliveryOrderId;
- private String feeDirection;
- private String feeType;
- private String currency;
- private BigDecimal amount;
- private String remark;
- private String createdBy;
- private LocalDateTime createdAt;
-}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java
deleted file mode 100644
index 372d62e69..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.baiyue.lastleg.delivery.mapper;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.baiyue.lastleg.delivery.entity.DeliveryFee;
-import org.apache.ibatis.annotations.Mapper;
-import org.apache.ibatis.annotations.Param;
-import org.apache.ibatis.annotations.Select;
-
-import java.util.List;
-
-@Mapper
-public interface DeliveryFeeMapper extends BaseMapper {
- @Select("SELECT * FROM ll_delivery_fee WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY id")
- List findByDelivery(@Param("customerId") String customerId,
- @Param("deliveryOrderId") Long deliveryOrderId);
-}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java
index 14bc48360..09ab70039 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java
@@ -7,13 +7,14 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
-import com.baiyue.lastleg.delivery.entity.DeliveryFee;
+import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
+import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
-import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
@@ -56,6 +57,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
+import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.math.BigDecimal;
@@ -68,7 +70,7 @@ public class DeliveryTransferService {
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@Resource private DeliveryOrderMapper deliveryOrders;
- @Resource private DeliveryFeeMapper deliveryFees;
+ @Resource private OverseasFeeSourceService feeSources;
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private OutboundPdaTaskMapper pdaTasks;
@Resource private OutboundPdaPickMapper pdaPicks;
@@ -184,9 +186,18 @@ public class DeliveryTransferService {
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
List feeInputs = request.fees() == null ? List.of() : request.fees();
for (DeliveryRequests.FeeItem input : feeInputs) {
- DeliveryFee fee = new DeliveryFee(); fee.setCustomerId(customerId); fee.setDeliveryOrderId(delivery.getId());
- fee.setFeeDirection("PAYABLE"); fee.setFeeType(input.feeType().trim()); fee.setCurrency(input.currency().trim().toUpperCase(Locale.ROOT));
- fee.setAmount(input.amount()); fee.setRemark(trim(input.remark())); fee.setCreatedBy(operatorId); fee.setCreatedAt(now); deliveryFees.insert(fee);
+ feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
+ new OverseasFeeSourceDtos.SaveRequest(customerId, delivery.getCustomerName(),
+ "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", delivery.getId(), delivery.getDeliveryNo(),
+ firstOrder == null ? null : firstOrder.getId(), null,
+ firstOrder == null ? null : firstOrder.getInboundCode(), null,
+ firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
+ firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(delivery.getOverseasWarehouseId()),
+ input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
+ input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
+ input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
+ input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
+ input.periodStart(), input.periodEnd()));
}
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now);
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
@@ -202,8 +213,9 @@ public class DeliveryTransferService {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
- if (!"USD".equalsIgnoreCase(input.currency().trim())) throw conflict("Delivery fees must use USD");
- if (input.amount().signum() < 0) throw conflict("Delivery fee amount cannot be negative");
+ if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
+ throw conflict("Delivery fees only support CNY or USD");
+ if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
}
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
@@ -559,7 +571,7 @@ public class DeliveryTransferService {
new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
- deliveryFees.findByDelivery(customerId, delivery.getId()).stream().map(this::feeRow).toList(),
+ feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList());
}
@@ -616,7 +628,7 @@ public class DeliveryTransferService {
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
- private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
+ private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
@@ -632,6 +644,15 @@ public class DeliveryTransferService {
}
private BigDecimal totalWeight(String customerId, List items) { List ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
+ private static Long parseLong(String value) {
+ try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
+ catch (NumberFormatException ignored) { return null; }
+ }
+ private static String childKey(String key, String suffix) {
+ String prefix = key == null ? "" : key.trim();
+ int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
+ return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
+ }
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java
index 7be87d591..60b27bdef 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java
@@ -1,7 +1,7 @@
package com.baiyue.lastleg.dto;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
-import com.baiyue.lastleg.entity.LastlegOrderFee;
+import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import java.time.LocalDateTime;
@@ -12,7 +12,7 @@ public record LastlegOrderDetailResponse(
List manualNodes,
List cargo,
List statusHistory,
- List fees,
+ List fees,
List operationHistory
) {
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java
index cab84e090..ebcc8f9fc 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java
@@ -11,6 +11,7 @@ import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
+import java.time.LocalDate;
import java.util.List;
public final class LastlegOrderRequests {
@@ -81,11 +82,25 @@ public final class LastlegOrderRequests {
}
public record CreateFee(
- @NotBlank @Size(max = 128) String feeItem,
- @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
+ @NotBlank @Size(max = 64) String feeItemCode,
+ @NotBlank @Size(max = 200) String feeItemName,
+ @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
+ @NotBlank @Pattern(regexp = "CNY|USD") String currency,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
- @NotBlank @Pattern(regexp = "PENDING_REVIEW|APPROVED|REJECTED") String status,
- @Size(max = 500) String supplierDescription
+ @NotNull LocalDate businessDate,
+ @Min(1) Long supplierId,
+ @Size(max = 200) String supplierName,
+ @Size(max = 32) String supplierCategory,
+ @Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
+ LocalDateTime paidAt,
+ @Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
+ @Size(max = 128) String paymentReferenceNo,
+ @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
+ @DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
+ @Size(max = 32) String quantityUnit,
+ @DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
+ LocalDate periodStart,
+ LocalDate periodEnd
) {
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java
deleted file mode 100644
index 7e1b2d9e9..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.baiyue.lastleg.entity;
-
-import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.TableName;
-import lombok.Data;
-
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-
-@Data
-@TableName("ll_order_fee")
-public class LastlegOrderFee {
- @TableId(type = IdType.INPUT) private Long id;
- private String customerId;
- private Long orderId;
- private String feeItem;
- private String currency;
- private BigDecimal amount;
- private String status;
- private String supplierDescription;
- private String createdBy;
- private LocalDateTime createdAt;
- private LocalDateTime updatedAt;
-}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
index 0c4a4b379..3b07a36d4 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
@@ -20,8 +20,8 @@ public final class OverseasFeeSourceDtos {
public record SaveRequest(
@NotBlank @Size(max = 128) String customerId,
@Size(max = 200) String customerName,
- @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE") String sourceType,
- @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER") String businessType,
+ @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType,
+ @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType,
@Min(1) Long businessId,
@NotBlank @Size(max = 128) String businessNo,
@Min(1) Long orderId,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java
index d58d435b2..a21ab4c43 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java
@@ -2,7 +2,11 @@ package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
+import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
+import feign.FeignException;
+import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -14,6 +18,8 @@ import java.util.List;
@RequiredArgsConstructor
public class OverseasFeeBusinessGuard {
private final JdbcTemplate jdbc;
+ @Resource private DomesticWarehouseDirectoryClient warehouses;
+ @Value("${baiyue.security.internal-service-token}") private String internalToken;
public List listContainerCustomers(long containerBookingId) {
List customers = jdbc.query(
@@ -37,13 +43,18 @@ public class OverseasFeeBusinessGuard {
throw bad("业务记录ID不能为空");
if (fee.warehouseId() == null)
throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填");
+ requireActiveOverseasWarehouse(fee.warehouseId());
return;
}
Table table = switch (fee.businessType()) {
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
+ case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no");
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
+ case "LASTLEG_ORDER" -> new Table("ll_order", "order_no");
+ case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no");
+ case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no");
default -> throw bad("业务类型与业务记录ID不匹配");
};
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
@@ -83,6 +94,21 @@ public class OverseasFeeBusinessGuard {
if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜");
}
+ private void requireActiveOverseasWarehouse(long warehouseId) {
+ try {
+ var response = warehouses.activeWarehouse(warehouseId, internalToken);
+ var warehouse = response == null || response.data() == null ? null : response.data().warehouse();
+ if (warehouse == null || !response.successful() || !Long.valueOf(warehouseId).equals(warehouse.id())
+ || !Long.valueOf(0L).equals(warehouse.tenantId()) || !"ACTIVE".equals(warehouse.status())
+ || !List.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType())) {
+ throw bad("海外仓不存在、已停用或类型不正确");
+ }
+ } catch (FeignException exception) {
+ if (exception.status() >= 400 && exception.status() < 500) throw bad("海外仓不存在、已停用或类型不正确");
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库主数据暂不可用", exception);
+ }
+ }
+
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
index 123f3f3c5..b2fc2496c 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
@@ -53,6 +53,15 @@ public class OverseasFeeSourceService {
.orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId));
}
+ public List listForBusiness(String customerId, String businessType, long businessId) {
+ return mapper.selectList(new LambdaQueryWrapper()
+ .eq(OverseasFeeSource::getCustomerId, customerId)
+ .eq(OverseasFeeSource::getBusinessType, businessType)
+ .eq(OverseasFeeSource::getBusinessId, businessId)
+ .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
+ .orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId));
+ }
+
public List listContainerCustomers(long containerBookingId) {
return businessGuard.listContainerCustomers(containerBookingId);
}
@@ -70,6 +79,22 @@ public class OverseasFeeSourceService {
return value;
}
+ @Transactional
+ public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) {
+ OverseasFeeSource value = create(operatorId, key, request);
+ if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) {
+ value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit"));
+ }
+ if ("PENDING".equals(value.getReviewStatus())) {
+ value = review(value.getSourceFeeId(), operatorId,
+ new ReviewRequest("APPROVE", "业务完成自动确认费用", childKey(key, "approve")));
+ }
+ if (!"APPROVED".equals(value.getReviewStatus()) || !"PUBLISHED".equals(value.getPublicationStatus())) {
+ throw conflict("业务费用未能审核发布");
+ }
+ return value;
+ }
+
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
@@ -168,10 +193,14 @@ public class OverseasFeeSourceService {
String expected = switch (value.businessType()) {
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
case "OVERSEAS_PROCUREMENT" -> "OVERSEAS_PROCUREMENT_FEE";
+ case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE";
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE";
+ case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE";
+ case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE";
+ case "RELABEL_ORDER" -> "OVERSEAS_RELABEL_FEE";
default -> "";
};
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
@@ -212,6 +241,12 @@ public class OverseasFeeSourceService {
if (existing != null && !existing.getRequestHash().equals(hash(request))) throw conflict("幂等键已被不同请求使用");
return existing;
}
+
+ private static String childKey(String key, String suffix) {
+ String prefix = key == null ? "" : key.trim();
+ int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
+ return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
+ }
private void command(String operator, String action, String key, Object request, long sourceFeeId, int version) {
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(request)); value.setSourceFeeId(sourceFeeId);
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java
deleted file mode 100644
index 761e7620d..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.baiyue.lastleg.mapper;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.baiyue.lastleg.entity.LastlegOrderFee;
-import org.apache.ibatis.annotations.Mapper;
-
-@Mapper
-public interface LastlegOrderFeeMapper extends BaseMapper {
-}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
index 09aea0c1a..360e9832b 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
@@ -16,6 +16,8 @@ import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
+import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
@@ -33,7 +35,9 @@ import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
+import java.math.BigDecimal;
import java.time.LocalDateTime;
+import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
@@ -62,6 +66,7 @@ public class OverseasProcurementService implements WalletResultHandler {
@Resource private OverseasProcurementFeeMapper procurementFees;
@Resource private ProcurementFileGateway fileGateway;
@Resource private WalletCommandOutboxService walletCommands;
+ @Resource private OverseasFeeSourceService feeSources;
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
@@ -220,6 +225,7 @@ public class OverseasProcurementService implements WalletResultHandler {
if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) {
OverseasProcurementOrder order = orders.selectById(row.getOrderId());
if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) {
+ publishFeeSource(order, row);
walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null,
"海外采购完成入库,结算费用", 2L);
row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row);
@@ -313,6 +319,7 @@ public class OverseasProcurementService implements WalletResultHandler {
if (order.getInboundQuantity() >= order.getPurchaseQuantity()) {
for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper()
.eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) {
+ publishFeeSource(order, fee);
walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null,
"海外采购完成入库,结算费用", 2L);
fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee);
@@ -321,6 +328,16 @@ public class OverseasProcurementService implements WalletResultHandler {
return detail(customerId, orderNo);
}
+ private void publishFeeSource(OverseasProcurementOrder order, OverseasProcurementFee fee) {
+ feeSources.publishApproved(StringUtils.hasText(fee.getCreatedBy()) ? fee.getCreatedBy() : "SYSTEM", "PROCUREMENT:" + fee.getFeeNo(),
+ new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
+ "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getId(), fee.getFeeNo(),
+ order.getId(), null, null, null, null, null, parseLong(order.getPurchaseWarehouseId()),
+ fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", null, null, null, null,
+ null, null, null, List.of(), LocalDate.now(), fee.getCurrency(), fee.getAmount(),
+ BigDecimal.ONE, "ITEM", fee.getAmount(), null, null));
+ }
+
private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) {
throw conflict("Idempotency key was reused with different procurement data");
@@ -491,6 +508,10 @@ public class OverseasProcurementService implements WalletResultHandler {
}
private static long value(Long value) { return value == null ? 0 : value; }
+ private static Long parseLong(String value) {
+ try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
+ catch (NumberFormatException ignored) { return null; }
+ }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String defaultCurrency(String value) { return trim(value) == null ? "USD" : value.trim().toUpperCase(); }
private static void setIfText(String value, java.util.function.Consumer setter) {
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
index d28f8dcd6..236f0cb4c 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
+import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
@@ -28,9 +30,11 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
+import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
+import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.LinkedHashMap;
@@ -53,6 +57,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private WalletCommandOutboxService walletCommands;
+ @Resource private OverseasFeeSourceService feeSources;
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
@@ -184,7 +189,20 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setStatus(next);
application.setUpdatedAt(now);
applications.updateById(application);
+ List linkedOrders = tailOrders(application);
+ if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
if ("COMPLETED".equals(next)) {
+ BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
+ feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
+ new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
+ "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
+ application.getLastlegOrderId(), null, null, null, null, null,
+ parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
+ null, null, null, null, null, null, null, List.of(), LocalDate.now(),
+ application.getChargeCurrency(), application.getChargeAmount(),
+ quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
+ quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
+ null, null));
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
@@ -193,8 +211,6 @@ public class RelabelApplicationService implements WalletResultHandler {
"换标取消,释放冻结", 2L);
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
}
- List linkedOrders = tailOrders(application);
- if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
for (LastlegOrder order : linkedOrders) {
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
@@ -351,6 +367,10 @@ public class RelabelApplicationService implements WalletResultHandler {
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
+ private static Long parseLong(String value) {
+ try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
+ catch (NumberFormatException ignored) { return null; }
+ }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java
index 82308b8d9..4595fca5c 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java
@@ -14,7 +14,6 @@ import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
-import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
@@ -25,7 +24,9 @@ import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
-import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
+import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
+import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
@@ -64,7 +65,7 @@ public class LastlegOrderService {
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
- @Resource private LastlegOrderFeeMapper feeMapper;
+ @Resource private OverseasFeeSourceService feeSources;
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@@ -246,9 +247,7 @@ public class LastlegOrderService {
.eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
- List fees = feeMapper.selectList(new LambdaQueryWrapper()
- .eq(LastlegOrderFee::getCustomerId, order.getCustomerId())
- .eq(LastlegOrderFee::getOrderId, orderId).orderByAsc(LastlegOrderFee::getCreatedAt));
+ List fees = feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId);
List operations = operationLogMapper.selectList(
new LambdaQueryWrapper()
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
@@ -270,24 +269,21 @@ public class LastlegOrderService {
}
@Transactional
- public LastlegOrderFee createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
- String operatorName) {
+ public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
+ String operatorName, String idempotencyKey) {
LastlegOrder order = findOwned(customerId, orderId);
LocalDateTime now = LocalDateTime.now();
- LastlegOrderFee fee = new LastlegOrderFee();
- fee.setId(IdWorker.getId());
- fee.setCustomerId(order.getCustomerId());
- fee.setOrderId(orderId);
- fee.setFeeItem(request.feeItem().trim());
- fee.setCurrency(request.currency());
- fee.setAmount(request.amount());
- fee.setStatus(request.status());
- fee.setSupplierDescription(StringUtils.hasText(request.supplierDescription())
- ? request.supplierDescription().trim() : null);
- fee.setCreatedBy(operatorName);
- fee.setCreatedAt(now);
- fee.setUpdatedAt(now);
- feeMapper.insert(fee);
+ OverseasFeeSource fee = feeSources.create(operatorName, idempotencyKey,
+ new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
+ "OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId,
+ null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()),
+ order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(),
+ request.feeItemName(), request.direction(), request.supplierId(), request.supplierName(),
+ request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(),
+ request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(),
+ request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(),
+ request.unitPrice(), request.periodStart(), request.periodEnd()));
+ fee = feeSources.submit(fee.getSourceFeeId(), operatorName, childKey(idempotencyKey, "submit"));
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
@@ -295,7 +291,8 @@ public class LastlegOrderService {
operation.setOrderId(orderId);
operation.setOperationNode("新增费用");
operation.setOperatorName(operatorName);
- operation.setResult("新增" + fee.getFeeItem() + ":" + fee.getCurrency() + " " + fee.getAmount().toPlainString());
+ operation.setResult("新增" + fee.getFeeItemName() + ":" + fee.getOriginalCurrency() + " "
+ + fee.getOriginalAmount().toPlainString() + ",已提交财务审核");
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
return fee;
@@ -585,6 +582,17 @@ public class LastlegOrderService {
? null : order.getInboundCode();
}
+ private static Long parseLong(String value) {
+ try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
+ catch (NumberFormatException ignored) { return null; }
+ }
+
+ private static String childKey(String key, String suffix) {
+ String prefix = key == null ? "" : key.trim();
+ int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
+ return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
+ }
+
private static String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index 9e97b8de2..a85b17d75 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -163,22 +163,6 @@ CREATE TABLE IF NOT EXISTS ll_loading_order_status_history (
KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单状态变更历史';
-CREATE TABLE IF NOT EXISTS ll_order_fee (
- id BIGINT NOT NULL COMMENT '费用明细主键',
- customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
- order_id BIGINT NOT NULL COMMENT '尾程订单ID',
- fee_item VARCHAR(128) NOT NULL COMMENT '费用项目',
- currency VARCHAR(3) NOT NULL COMMENT '币种代码',
- amount DECIMAL(18,2) NOT NULL COMMENT '费用金额',
- status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回',
- supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明',
- created_by VARCHAR(128) NOT NULL COMMENT '创建操作人',
- created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
- updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
- PRIMARY KEY(id),
- KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at)
-) ENGINE=InnoDB COMMENT='尾程订单费用明细';
-
CREATE TABLE IF NOT EXISTS ll_order_operation_log (
id BIGINT NOT NULL COMMENT '操作记录主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
@@ -302,12 +286,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count'));
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
-CREATE TABLE IF NOT EXISTS ll_delivery_fee (
- id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
- fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种',
- amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
- created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
-) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (
id BIGINT NOT NULL COMMENT '派送资料历史ID',
customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键',
diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
index 29d324ea6..febf0f623 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
@@ -194,11 +194,12 @@ paths:
/api/platform/v1/last-leg/orders/{id}/fees:
post:
summary: 新增尾程订单费用
- description: 权限 platform:overseas-warehouse:last-mile-order:edit;在订单客户范围内新增费用并记录操作人和处理结果。
+ description: 权限 platform:overseas-warehouse:last-mile-order:edit;在订单客户范围内创建统一费用来源并提交财务审核。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeCreate' } } }
@@ -1331,28 +1332,44 @@ components:
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: ll_order.estimated_warehouse_at }
LastMileFeeCreate:
type: object
- required: [feeItem, currency, amount, status]
+ required: [feeItemCode, feeItemName, direction, currency, amount, businessDate]
properties:
- feeItem: { type: string, minLength: 1, maxLength: 128 }
- currency: { type: string, pattern: '^[A-Z]{3}$', example: USD }
+ feeItemCode: { type: string, minLength: 1, maxLength: 64 }
+ feeItemName: { type: string, minLength: 1, maxLength: 128 }
+ direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
+ currency: { type: string, enum: [CNY, USD] }
amount: { type: number, format: decimal, minimum: 0.01 }
- status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
- supplierDescription: { type: string, maxLength: 500 }
+ businessDate: { type: string, format: date }
+ supplierId: { type: string, pattern: '^[1-9][0-9]*$' }
+ supplierName: { type: string, maxLength: 200 }
+ supplierCategory: { type: string, maxLength: 32 }
+ settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
+ chargeableQuantity: { type: number, exclusiveMinimum: 0 }
+ quantityUnit: { type: string, maxLength: 32 }
+ unitPrice: { type: number, exclusiveMinimum: 0 }
+ periodStart: { type: string, format: date }
+ periodEnd: { type: string, format: date }
LastMileFee:
type: object
- required: [id, customerId, orderId, feeItem, currency, amount, status, createdBy, createdAt, updatedAt]
+ required: [id, sourceFeeId, sourceVersion, customerId, feeItemCode, feeItemName, direction, currency, amount, originalCurrency, originalAmount, reviewStatus, publicationStatus, sourceStatus]
properties:
- id: { type: integer, format: int64 }
+ id: { type: string, pattern: '^[1-9][0-9]*$' }
+ sourceFeeId: { type: string, pattern: '^[1-9][0-9]*$' }
+ sourceVersion: { type: integer, minimum: 1 }
customerId: { type: string }
- orderId: { type: integer, format: int64 }
- feeItem: { type: string }
- currency: { type: string }
+ orderId: { type: string, pattern: '^[1-9][0-9]*$' }
+ feeItemCode: { type: string }
+ feeItemName: { type: string }
+ direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
+ currency: { type: string, enum: [CNY] }
amount: { type: number, format: decimal }
- status: { type: string }
- supplierDescription: { type: string, nullable: true }
- createdBy: { type: string }
- createdAt: { type: string, format: date-time }
- updatedAt: { type: string, format: date-time }
+ originalCurrency: { type: string, enum: [CNY, USD] }
+ originalAmount: { type: number, format: decimal }
+ exchangeRate: { type: number, format: decimal }
+ exchangeRateMonth: { type: string, pattern: '^\\d{4}-\\d{2}$' }
+ reviewStatus: { type: string, enum: [DRAFT, PENDING, APPROVED, REJECTED] }
+ publicationStatus: { type: string }
+ sourceStatus: { type: string }
LastMileFeeApiResponse:
type: object
required: [code, message, data]
@@ -1842,11 +1859,22 @@ components:
enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION]
DeliveryFeeInput:
type: object
- required: [feeType, currency, amount]
+ required: [feeType, feeName, direction, currency, amount]
properties:
feeType: { type: string, maxLength: 64 }
- currency: { type: string, maxLength: 8 }
- amount: { type: number, minimum: 0 }
+ feeName: { type: string, maxLength: 128 }
+ direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
+ currency: { type: string, enum: [CNY, USD] }
+ amount: { type: number, exclusiveMinimum: 0 }
+ supplierId: { type: string, pattern: '^[1-9][0-9]*$' }
+ supplierName: { type: string, maxLength: 200 }
+ supplierCategory: { type: string, maxLength: 32 }
+ settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
+ chargeableQuantity: { type: number, exclusiveMinimum: 0 }
+ quantityUnit: { type: string, maxLength: 32 }
+ unitPrice: { type: number, exclusiveMinimum: 0 }
+ periodStart: { type: string, format: date }
+ periodEnd: { type: string, format: date }
remark: { type: string, nullable: true, maxLength: 500 }
OneClickOutboundCreate:
type: object
diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
index 13c19aa0d..d127c85a5 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
@@ -42,7 +42,7 @@ paths:
responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } }
components:
schemas:
- BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER] }
+ BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, PROCUREMENT_FEE, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] }
OverseasContainerFeeItem:
type: string
description: 海外阶段柜级费用项目:港口费、堆存费、修箱费、吊机费、码头拖车费、其他。
@@ -70,7 +70,7 @@ components:
required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount]
properties:
customerId: { type: string }
- sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE] }
+ sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] }
businessType: { $ref: '#/components/schemas/BusinessType' }
businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
businessNo: { type: string, maxLength: 128 }
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java
index 91e429644..851fa8bec 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java
@@ -134,6 +134,8 @@ class MerchantDeliveryControllerTest {
private static DeliveryRequests.Transfer transferRequest() {
return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null,
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null,
- List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null)));
+ List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY",
+ new BigDecimal("10.00"), null, null, null, null, null, null, null, null,
+ null, null, null, null, null, null)));
}
}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java
index abefdf621..c001135a1 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java
@@ -8,12 +8,12 @@ import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
-import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
@@ -67,7 +67,7 @@ class DeliveryTransferServiceTest {
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundPallets;
@Mock DeliveryOrderMapper deliveryOrders;
- @Mock DeliveryFeeMapper deliveryFees;
+ @Mock OverseasFeeSourceService feeSources;
@Mock DeliveryOperationMapper deliveryOperations;
@Mock InboundPlanPalletAllocationMapper planAllocations;
@Mock InboundPlanLineMapper planLines;
@@ -92,7 +92,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
- ReflectionTestUtils.setField(service, "deliveryFees", deliveryFees);
+ ReflectionTestUtils.setField(service, "feeSources", feeSources);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
@@ -641,7 +641,13 @@ class DeliveryTransferServiceTest {
private static DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) {
return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null,
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark,
- List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null)));
+ List.of(receivableDeliveryFee()));
+ }
+
+ private static DeliveryRequests.FeeItem receivableDeliveryFee() {
+ return new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY",
+ new BigDecimal("10.00"), null, null, null, null, null, null, null, null,
+ null, null, null, null, null, null);
}
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java
index ca20222a1..6f6f69118 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java
@@ -10,6 +10,8 @@ import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
+import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory;
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,6 +48,7 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementFeeMapper procurementFees;
@Mock ProcurementFileGateway fileGateway;
@Mock WalletCommandOutboxService walletCommands;
+ @Mock OverseasFeeSourceService feeSources;
private OverseasProcurementService service;
@BeforeEach
@@ -60,6 +63,8 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
+ ReflectionTestUtils.setField(service, "feeSources", feeSources);
+ lenient().when(feeSources.publishApproved(anyString(), anyString(), any())).thenReturn(new OverseasFeeSource());
lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001");
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
index 20f31281f..4b36ea614 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
@@ -16,6 +16,8 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
+import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -51,6 +53,7 @@ class RelabelApplicationServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock WalletCommandOutboxService walletCommands;
+ @Mock OverseasFeeSourceService feeSources;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
private RelabelApplicationService service;
@@ -65,8 +68,10 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
+ ReflectionTestUtils.setField(service, "feeSources", feeSources);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
+ lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource());
}
@Test
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java
index 92418b512..28e89c38f 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java
@@ -14,7 +14,7 @@ class Phase5OverseasUpgradeSqlContractTest {
private static final List PHASE5_TABLES = List.of(
"ll_dispatch_pda_task", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation",
"ll_dispatch_pda_pick_order", "ll_outbound_order", "ll_outbound_pallet_item",
- "ll_outbound_pda_task", "ll_outbound_pda_pick", "ll_delivery_order", "ll_delivery_fee",
+ "ll_outbound_pda_task", "ll_outbound_pda_pick", "ll_delivery_order",
"ll_delivery_operation", "ll_dropship_order_profile", "ll_dropship_carton",
"ll_dropship_sku_inventory", "ll_dropship_carton_ledger", "ll_exception_order",
"ll_customer_visit");
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java
index 971ffa434..bbf7da8c5 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java
@@ -12,7 +12,8 @@ import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
-import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
+import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
+import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
@@ -28,6 +29,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.time.LocalDateTime;
+import java.time.LocalDate;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -53,7 +55,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
- @Mock LastlegOrderFeeMapper fees;
+ @Mock OverseasFeeSourceService feeSources;
@Mock LastlegOrderOperationLogMapper operations;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock InboundPlanCartonMapper cartons;
@@ -70,7 +72,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
- ReflectionTestUtils.setField(service, "feeMapper", fees);
+ ReflectionTestUtils.setField(service, "feeSources", feeSources);
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
@@ -140,7 +142,7 @@ class LastlegOrderServiceTest {
when(orders.selectOne(any())).thenReturn(order);
when(details.selectList(any())).thenReturn(List.of());
when(histories.selectList(any())).thenReturn(List.of());
- when(fees.selectList(any())).thenReturn(List.of());
+ when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of());
when(operations.selectList(any())).thenReturn(List.of());
when(manualNodes.selectList(any())).thenReturn(List.of());
@@ -186,14 +188,21 @@ class LastlegOrderServiceTest {
void createFeePersistsFeeAndOperationForOwnedOrder() {
when(orders.selectOne(any())).thenReturn(order(11L, "100", "PENDING_ASSIGNMENT", null));
var request = new LastlegOrderRequests.CreateFee(
- "清关费", "USD", new BigDecimal("420.00"), "PENDING_REVIEW", "报关代理");
+ "CUSTOMS_CLEARANCE", "清关费", "RECEIVABLE", "USD", new BigDecimal("420.00"),
+ LocalDate.of(2026, 9, 9), null, null, null, null, null, null, null, List.of(),
+ null, null, null, null, null);
+ OverseasFeeSource draft = new OverseasFeeSource();
+ draft.setSourceFeeId(701L); draft.setCustomerId("100"); draft.setFeeItemName("清关费");
+ draft.setOriginalCurrency("USD"); draft.setOriginalAmount(new BigDecimal("420.00"));
+ when(feeSources.create(eq("platform_admin"), eq("fee-key"), any())).thenReturn(draft);
+ when(feeSources.submit(701L, "platform_admin", "fee-key:submit")).thenReturn(draft);
- var fee = service.createFee("100", 11L, request, "platform_admin");
+ var fee = service.createFee("100", 11L, request, "platform_admin", "fee-key");
assertEquals("100", fee.getCustomerId());
- assertEquals("清关费", fee.getFeeItem());
- assertEquals(new BigDecimal("420.00"), fee.getAmount());
- verify(fees).insert(any(com.baiyue.lastleg.entity.LastlegOrderFee.class));
+ assertEquals("清关费", fee.getFeeItemName());
+ verify(feeSources).create(eq("platform_admin"), eq("fee-key"), any());
+ verify(feeSources).submit(701L, "platform_admin", "fee-key:submit");
verify(operations).insert(any(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class));
}
From 7113703e3ca1eda19e75c7ec53a1a0068f76c6fc Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 16:46:01 +0800
Subject: [PATCH 09/42] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=B4=A2=E5=8A=A1?=
=?UTF-8?q?=E8=B4=B9=E7=94=A8=E7=8A=B6=E6=80=81=E5=90=88=E5=90=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/baiyue/finance/FinanceSqlCommentContractTest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java
index f4d561e1c..d1182edb7 100644
--- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java
+++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(23);
- assertThat(columnCount).isEqualTo(350);
+ assertThat(columnCount).isEqualTo(351);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -52,7 +52,7 @@ class FinanceSqlCommentContractTest {
void keyIsolationStatusSnapshotAndIdempotencyCommentsUseFrozenMeaning() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键'");
- assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
+ assertThat(sql).contains("AVAILABLE可结算、SETTLED资金账户已结清、SUPERSEDED已被新版本替代、VOIDED已作废");
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
From 83e95d764e0081df92ac94daa296fb631b2b1932 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 16:49:01 +0800
Subject: [PATCH 10/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=B7=E5=A4=96?=
=?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=8E=A5=E5=8F=A3=E5=A5=91=E7=BA=A6=E6=A0=BC?=
=?UTF-8?q?=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../openapi/overseas-business-v1.yaml | 22 +++++++++++++------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
index febf0f623..0a6161332 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
@@ -347,7 +347,15 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/ChangeReceivingWarehouseRequest' }
responses:
- '200': { description: 更新后的柜级摘要, content: { application/json: { schema: { type: object, properties: { code: { type: string, enum: ['200'] }, data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } } } } }
+ '200':
+ description: 更新后的柜级摘要
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code: { type: string, enum: ['200'] }
+ data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' }
'400': { description: 请求无效或目标不是有效 OVERSEAS 仓 }
'409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 }
/api/platform/v1/last-leg/orders/{id}/destination:
@@ -625,7 +633,7 @@ paths:
description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
- responses: { '200': { description: 出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码 }, '404': { description: 出库单不存在 } }
+ responses: { '200': { description: '出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } }
/api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:
post:
summary: 取消未执行出库单
@@ -1344,9 +1352,9 @@ components:
supplierName: { type: string, maxLength: 200 }
supplierCategory: { type: string, maxLength: 32 }
settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
- chargeableQuantity: { type: number, exclusiveMinimum: 0 }
+ chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true }
quantityUnit: { type: string, maxLength: 32 }
- unitPrice: { type: number, exclusiveMinimum: 0 }
+ unitPrice: { type: number, minimum: 0, exclusiveMinimum: true }
periodStart: { type: string, format: date }
periodEnd: { type: string, format: date }
LastMileFee:
@@ -1865,14 +1873,14 @@ components:
feeName: { type: string, maxLength: 128 }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
currency: { type: string, enum: [CNY, USD] }
- amount: { type: number, exclusiveMinimum: 0 }
+ amount: { type: number, minimum: 0, exclusiveMinimum: true }
supplierId: { type: string, pattern: '^[1-9][0-9]*$' }
supplierName: { type: string, maxLength: 200 }
supplierCategory: { type: string, maxLength: 32 }
settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
- chargeableQuantity: { type: number, exclusiveMinimum: 0 }
+ chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true }
quantityUnit: { type: string, maxLength: 32 }
- unitPrice: { type: number, exclusiveMinimum: 0 }
+ unitPrice: { type: number, minimum: 0, exclusiveMinimum: true }
periodStart: { type: string, format: date }
periodEnd: { type: string, format: date }
remark: { type: string, nullable: true, maxLength: 500 }
From 6f0622ed3650f84e5323a5a492d2fcda91170380 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 17:16:34 +0800
Subject: [PATCH 11/42] fix: close development regression gaps
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 5 +++--
deploy/sql/海外区域统一增量-MySQL8.sql | 10 +++++-----
.../controller/DeliveryTransferController.java | 2 +-
scripts/README.md | 7 ++++---
...lment-prototype-field-matrix-contract.test.mjs | 2 +-
scripts/frontend-full-regression.ps1 | 15 ++++++++++-----
scripts/full-regression-entry-contract.test.mjs | 5 ++++-
7 files changed, 28 insertions(+), 18 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index a0dc51b6e..5b0b228c2 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18690,6 +18690,9 @@ DELIMITER ;
CALL migrate_container_workspace_permissions();
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
+-- 货柜双工作台权限结束,恢复头程数据库上下文
+USE baiyue_firstleg;
+
-- 财务 P0:海外费用原币、计费依据与钱包结算关联
DROP PROCEDURE IF EXISTS add_finance_p0_column;
DELIMITER $$
@@ -18721,8 +18724,6 @@ DROP PROCEDURE IF EXISTS add_finance_p0_column;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)'));
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
--- 货柜双工作台权限结束,恢复头程数据库上下文
-USE baiyue_firstleg;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index 365e522e3..81c076182 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -2045,15 +2045,15 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL'));
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL COMMENT ''派送资料BOL号,不作为文件归属标识'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='documents_json'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN documents_json JSON NULL'));
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='documents_json'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN documents_json JSON NULL COMMENT ''派送三类资料当前绑定及权威文件元数据快照'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL'));
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''换标申请总资料文件ID'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL'));
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''换标申请总资料文件名'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL'));
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''换标申请总资料下载地址'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no' AND column_type='varchar(128)' AND is_nullable='NO'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''海外仓资源服务返回的真实托盘码'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java
index 5568f4b1c..3934c7380 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java
@@ -37,7 +37,7 @@ public class DeliveryTransferController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
public ApiResponse createOneClick(
- @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
+ @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@Valid @RequestBody DeliveryRequests.OneClickOutbound request) {
return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request));
}
diff --git a/scripts/README.md b/scripts/README.md
index 36fa19d82..a706e34a4 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -80,7 +80,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 |
| 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 |
| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 |
-| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内 PDA Node 全量测试,不访问 Gateway |
+| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway |
| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 |
| 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 |
| 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 |
@@ -89,6 +89,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 契约数据 | `synchronous-dependency-baseline.mjs` | 同步服务依赖合同的数据源,只由相邻合同测试导入,不是 CLI |
| OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 |
| 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 |
+| 本地环境 | `start-gateway-overseas-local.ps1` | 使用本地 Maven 启动海外 Gateway,并将海外安全投影地址指向本机;会启动本地 Java 进程,不访问或修改共享环境 |
| 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 |
| Nacos 环境选择 | `use-nacos-environment.ps1` | 按显式环境与区域设置当前 PowerShell 进程的 Nacos、Spring Profile 和运行区域变量;开发/测试密码必须由参数或环境变量提供,不发布配置 |
| Nacos 配置发布 | `publish-nacos-config.ps1` | 将 `config/nacos/{local|cn|overseas}/*.yml` 发布到显式指定的 Nacos;非本地环境强制要求非 public namespace,会修改目标 Nacos 配置,执行前须取得对应环境授权 |
@@ -154,7 +155,7 @@ node scripts/create-pending-review-orders.mjs
| 检查 | 入口 |
|---|---|
| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` |
-| Admin/Client/国内 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` |
+| Admin/Client/国内与海外 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态定向测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 |
@@ -183,7 +184,7 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
## 前端全量回归
-`frontend-full-regression.ps1` 编排本地 Admin/Client 的 `pnpm test`、`pnpm typecheck`,并默认执行国内 PDA 的 `node --test tests/*.test.mjs`;不调用 Gateway、不部署、不连接数据库。
+`frontend-full-regression.ps1` 编排本地 Admin/Client 的 `pnpm test`、`pnpm typecheck`,并默认执行国内与海外 PDA 的 `node --test tests/*.test.mjs`;不调用 Gateway、不部署、不连接数据库。
```powershell
.\scripts\frontend-full-regression.ps1 -DryRun
diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs
index 3252d2fbb..276c88666 100644
--- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs
+++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs
@@ -34,7 +34,7 @@ test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段',
test('现行 Admin 履约列表均以左固定操作列开头', () => {
const sources = [
- ['container-loading/pages/ContainersView.vue', 1],
+ ['container-loading/pages/ContainersView.vue', 2],
['loading-lists/components/LoadingListsView.vue', 2],
['ocean-bookings/pages/OceanBookingsView.vue', 1],
['port-schedules/pages/PortSchedulesView.vue', 1],
diff --git a/scripts/frontend-full-regression.ps1 b/scripts/frontend-full-regression.ps1
index 62da1863c..3baa4a15b 100644
--- a/scripts/frontend-full-regression.ps1
+++ b/scripts/frontend-full-regression.ps1
@@ -53,12 +53,17 @@ foreach ($target in $targets) {
}
if (-not $SkipPda) {
- $pdaDirectory = Join-Path $repoRoot 'apps/pda-uniapp'
- if (-not (Test-Path -LiteralPath (Join-Path $pdaDirectory 'tests'))) {
- Write-Error "PDA tests directory not found: $pdaDirectory"; exit 2
+ $pdaTargets = @(
+ @{ Name = 'Domestic PDA'; Directory = Join-Path $repoRoot 'apps/pda-uniapp' },
+ @{ Name = 'Overseas PDA'; Directory = Join-Path $repoRoot 'apps/pda-overseas-uniapp' }
+ )
+ foreach ($pdaTarget in $pdaTargets) {
+ if (-not (Test-Path -LiteralPath (Join-Path $pdaTarget.Directory 'tests'))) {
+ Write-Error "$($pdaTarget.Name) tests directory not found: $($pdaTarget.Directory)"; exit 2
+ }
+ Write-Host "[$($pdaTarget.Name)]"
+ Invoke-PdaTests $pdaTarget.Directory
}
- Write-Host '[PDA]'
- Invoke-PdaTests $pdaDirectory
}
Write-Host 'Frontend regression completed.'
diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs
index 747b9542f..74939ae8a 100644
--- a/scripts/full-regression-entry-contract.test.mjs
+++ b/scripts/full-regression-entry-contract.test.mjs
@@ -10,9 +10,12 @@ const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.
const frontendScript = readFileSync(resolve(scriptsDir, 'frontend-full-regression.ps1'), 'utf8')
const adminAcceptance = readFileSync(resolve(scriptsDir, 'admin-test-env-acceptance.mjs'), 'utf8')
-test('frontend full regression includes domestic PDA node tests by default', () => {
+test('frontend full regression includes domestic and overseas PDA node tests by default', () => {
assert.match(frontendScript, /\[switch\]\$SkipPda/)
assert.match(frontendScript, /apps\/pda-uniapp/)
+ assert.match(frontendScript, /apps\/pda-overseas-uniapp/)
+ assert.match(frontendScript, /Domestic PDA/)
+ assert.match(frontendScript, /Overseas PDA/)
assert.match(frontendScript, /node --test/)
assert.match(frontendScript, /tests\/\*\.test\.mjs/)
})
From de1f7e8dddfe564b6b116b15deb22dde94cf3350 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 17:27:00 +0800
Subject: [PATCH 12/42] feat: freeze and propagate first-leg pallet labels
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 23 +++
deploy/sql/海外区域统一增量-MySQL8.sql | 15 ++
.../PlatformLoadingListController.java | 28 ++++
.../baiyue/firstleg/dto/LoadingListDtos.java | 31 ++++
.../mapper/LoadingPalletLabelMapper.java | 70 +++++++++
.../LoadingCompletedOutboxService.java | 20 ++-
.../service/LoadingPalletLabelService.java | 147 ++++++++++++++++++
.../db/phase3-smart-loading-schema.sql | 23 +++
.../resources/openapi/loading-lists-v1.yaml | 23 +++
.../LoadingCompletedOutboxServiceTest.java | 6 +
.../LoadingPalletLabelServiceTest.java | 46 ++++++
.../gateway/HttpWarehouseResourceGateway.java | 13 +-
.../pda/gateway/WarehouseResourceGateway.java | 3 +-
...ainerInboundPlanConversionCoordinator.java | 11 +-
.../service/InboundPlanService.java | 21 ++-
.../mapper/LastlegSourcePalletMapper.java | 27 ++++
.../service/LoadingCompletedConsumer.java | 16 ++
.../src/main/resources/db/lastleg.sql | 16 ++
.../HttpWarehouseResourceGatewayTest.java | 20 ++-
...rInboundPlanConversionCoordinatorTest.java | 6 +-
.../dto/WarehousePalletBatchRequests.java | 3 +-
.../service/WarehousePalletBatchService.java | 6 +-
.../warehouse-pallet-batch-internal-v1.yaml | 3 +-
.../WarehousePalletBatchServiceTest.java | 4 +-
24 files changed, 560 insertions(+), 21 deletions(-)
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java
create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 4bf249086..386de5d13 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -5279,6 +5279,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
+CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
+ id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
+ loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
+ loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '标签归属装柜明细ID',
+ account_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
+ inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID快照',
+ inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
+ container_no VARCHAR(64) NOT NULL COMMENT '真实集装箱号快照',
+ destination_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码快照',
+ destination_warehouse_name VARCHAR(128) NULL COMMENT 'FBA仓名称快照',
+ pallet_index INT UNSIGNED NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号',
+ pallet_code VARCHAR(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_loading_pallet_code (pallet_code),
+ UNIQUE KEY uk_loading_pallet_index (loading_plan_id,destination_warehouse_code,pallet_index),
+ KEY idx_loading_pallet_item (loading_plan_item_id,pallet_index),
+ KEY idx_loading_pallet_inbound (account_id,inbound_id),
+ CONSTRAINT fk_loading_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan(id),
+ CONSTRAINT fk_loading_pallet_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item(id),
+ CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
+) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
+
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index 67f990886..40d184cae 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -112,6 +112,21 @@ CREATE DATABASE IF NOT EXISTS baiyue_lastleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_lastleg;
+CREATE TABLE IF NOT EXISTS ll_order_source_pallet (
+ id BIGINT NOT NULL COMMENT '来源托盘快照ID',
+ customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
+ order_id BIGINT NOT NULL COMMENT '尾程订单ID',
+ source_pallet_no VARCHAR(128) NOT NULL COMMENT '头程冻结托盘码',
+ sequence_no INT NOT NULL COMMENT '订单内稳定顺序',
+ source_event_id VARCHAR(128) NOT NULL COMMENT '来源装柜完成事件ID',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ PRIMARY KEY(id),
+ UNIQUE KEY uk_ll_source_pallet_no(source_pallet_no),
+ UNIQUE KEY uk_ll_source_pallet_sequence(customer_id,order_id,sequence_no),
+ KEY idx_ll_source_pallet_order(customer_id,order_id),
+ CONSTRAINT fk_ll_source_pallet_order FOREIGN KEY(order_id) REFERENCES ll_order(id)
+) ENGINE=InnoDB COMMENT='头程交接的不可变托盘码快照';
+
CREATE TABLE IF NOT EXISTS ll_event_outbox (
id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java
index ccfaa1ba3..31408a188 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java
@@ -13,8 +13,11 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution;
+import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
+import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
+import com.baiyue.firstleg.service.LoadingPalletLabelService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -32,6 +35,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.http.ResponseEntity;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@@ -41,6 +45,8 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformLoadingListController {
@Resource
private LoadingListService service;
+ @Resource
+ private LoadingPalletLabelService palletLabels;
@Operation(summary = "分页查询预装清单")
@GetMapping("/loading-plans")
@@ -118,4 +124,26 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
}
+ @Operation(summary = "生成并查询散货托盘码", description = "仅为散货按真实集装箱号与 FBA 仓稳定生成托盘码;一件代发不参与。")
+ @PostMapping("/loading-plans/{planId}/pallet-labels/prepare")
+ @PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
+ public ApiResponse preparePalletLabels(
+ @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
+ @RequestParam @Positive long warehouseId) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(palletLabels.prepare(planId, warehouseId));
+ }
+
+ @Operation(summary = "打印散货托盘码", description = "不传仓码与张数时打印全部;按仓打印时返回该仓从第 1 张开始的指定数量,用于补打。")
+ @PostMapping(value = "/loading-plans/{planId}/pallet-labels/print", produces = "application/pdf")
+ @PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
+ public ResponseEntity printPalletLabels(
+ @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
+ @RequestParam @Positive long warehouseId,
+ @RequestHeader(value = "X-Request-Id", required = false) String requestId,
+ @Valid @RequestBody PalletLabelPrintRequest request) {
+ FirstlegScopeGuard.platform(user);
+ return palletLabels.print(planId, warehouseId, request, requestId);
+ }
+
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java
index ee98f5bc5..924466efc 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java
@@ -125,6 +125,37 @@ public final class LoadingListDtos {
) {
}
+ public record PalletLabelPrintRequest(
+ @Size(max = 64) String destinationWarehouseCode,
+ @Min(1) @Max(999) Integer count
+ ) {
+ }
+
+ public record PalletLabel(
+ Long labelId, Long itemId, Long accountId, Long inboundId, String inboundCode,
+ String containerNo, String destinationWarehouseCode, String destinationWarehouseName,
+ int palletIndex, String palletCode
+ ) {
+ }
+
+ public record PalletLabelGroup(
+ String destinationWarehouseCode, String destinationWarehouseName,
+ int totalCount, List labels
+ ) {
+ public PalletLabelGroup {
+ labels = labels == null ? List.of() : List.copyOf(labels);
+ }
+ }
+
+ public record PalletLabelManifest(
+ Long planId, String planNo, String containerNo, int totalCount,
+ List groups
+ ) {
+ public PalletLabelManifest {
+ groups = groups == null ? List.of() : List.copyOf(groups);
+ }
+ }
+
public record PreloadSummary(
Long planId,
Long containerBookingId,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java
new file mode 100644
index 000000000..5c798ed19
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java
@@ -0,0 +1,70 @@
+package com.baiyue.firstleg.mapper;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface LoadingPalletLabelMapper {
+ @Select("""
+ SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.warehouse_id,
+ i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,
+ i.destination_warehouse_code,i.destination_warehouse_name,
+ COALESCE(NULLIF(i.pallet_count,0),
+ CEIL(COALESCE(NULLIF(o.pallet_count,0),
+ (SELECT COUNT(*) FROM fl_inbound_weight_result weight
+ WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id
+ AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0)
+ * i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count,
+ COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type,
+ o.destination_type,i.load_sequence
+ FROM fl_loading_plan p
+ JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
+ JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
+ LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0
+ WHERE p.id=#{planId}
+ ORDER BY i.destination_warehouse_code,i.load_sequence,i.id
+ FOR UPDATE
+ """)
+ List lockSources(@Param("planId") long planId);
+
+ @Select("""
+ SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id,
+ account_id,inbound_id,inbound_code,container_no,destination_warehouse_code,
+ destination_warehouse_name,pallet_index,pallet_code
+ FROM fl_loading_pallet_label WHERE loading_plan_id=#{planId}
+ ORDER BY destination_warehouse_code,pallet_index,id
+ """)
+ List labels(@Param("planId") long planId);
+
+ @Insert("""
+ INSERT INTO fl_loading_pallet_label
+ (id,loading_plan_id,loading_plan_item_id,account_id,inbound_id,inbound_code,
+ container_no,destination_warehouse_code,destination_warehouse_name,pallet_index,pallet_code,created_at)
+ VALUES
+ (#{labelId},#{planId},#{itemId},#{accountId},#{inboundId},#{inboundCode},
+ #{containerNo},#{destinationWarehouseCode},#{destinationWarehouseName},#{palletIndex},#{palletCode},CURRENT_TIMESTAMP(3))
+ """)
+ int insert(LabelRow row);
+
+ @Getter @Setter
+ class SourceRow {
+ private Long planId; private String planNo; private String planStatus; private String containerNo;
+ private Long warehouseId; private Long itemId; private Long accountId; private Long inboundId;
+ private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName;
+ private Integer palletCount; private String fulfillmentType; private Integer loadSequence;
+ private String destinationType;
+ }
+
+ @Getter @Setter
+ class LabelRow {
+ private Long labelId; private Long planId; private Long itemId; private Long accountId; private Long inboundId;
+ private String inboundCode; private String containerNo; private String destinationWarehouseCode;
+ private String destinationWarehouseName; private Integer palletIndex; private String palletCode;
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
index 78d3eb0f2..9f77042c0 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
@@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
+import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -44,6 +45,8 @@ public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper;
+ @Resource private LoadingPalletLabelMapper palletLabelMapper;
+ @Resource private LoadingPalletLabelService palletLabelService;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
@@ -61,12 +64,15 @@ public class LoadingCompletedOutboxService {
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateContainerIdentity(rows);
validateCustomerSnapshots(rows);
+ palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
Map> cartonMarks = cartonLabelPools(rows);
List palletRows = palletRows(planId);
+ List standardLabels = palletLabelMapper.labels(planId);
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
- palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
+ palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(),
+ standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
.toList();
}
@@ -91,13 +97,15 @@ public class LoadingCompletedOutboxService {
}
private EventEnvelope envelope(long accountId, List rows, List palletRows,
+ List standardLabels,
Map> cartonMarks) {
Row first = rows.get(0);
Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List orders = byInbound.values().stream()
.map(value -> order(value, palletRows.stream()
- .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
+ .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
+ standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
@@ -114,6 +122,7 @@ public class LoadingCompletedOutboxService {
}
private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows,
+ List standardLabels,
Map> cartonMarks) {
List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
@@ -129,15 +138,18 @@ public class LoadingCompletedOutboxService {
String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List pallets = dropship(fulfillmentType)
- ? palletSnapshots(sorted, palletRows) : List.of();
+ ? palletSnapshots(sorted, palletRows) : standardLabels.stream()
+ .map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
}
+ Integer frozenPalletCount = dropship(fulfillmentType) || standardLabels.isEmpty()
+ ? actualPalletCount(palletRows) : Integer.valueOf(standardLabels.size());
return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
- sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows),
+ sumNullableInt(sorted, Row::getProductQuantity), frozenPalletCount,
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java
new file mode 100644
index 000000000..a9eafa154
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java
@@ -0,0 +1,147 @@
+package com.baiyue.firstleg.service;
+
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.event.ContainerNumbers;
+import com.baiyue.firstleg.client.PrintPdfClient;
+import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel;
+import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup;
+import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
+import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
+import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
+import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow;
+import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.SourceRow;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import feign.FeignException;
+import jakarta.annotation.Resource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/** 散货托盘码是柜号与 FBA 仓维度的稳定业务标识;一件代发继续使用装箱打托事实。 */
+@Service
+public class LoadingPalletLabelService {
+ private static final BigDecimal PAGE_MM = new BigDecimal("100");
+ @Resource private LoadingPalletLabelMapper mapper;
+ @Resource private PrintPdfClient printClient;
+ @Resource private ObjectMapper objectMapper;
+ @Value("${baiyue.security.internal-service-token}") private String internalToken;
+
+ @Transactional
+ public PalletLabelManifest prepare(long planId, long warehouseId) {
+ List sources = mapper.lockSources(planId);
+ if (sources.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在或没有有效明细");
+ SourceRow first = sources.get(0);
+ if (!Objects.equals(first.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不属于当前仓库");
+ if (!List.of("LOADING", "COMPLETED").contains(first.getPlanStatus())) {
+ throw new BizException(ResultCode.CONFLICT, "请先确认预装清单,再生成散货托盘码");
+ }
+ String containerNo;
+ try { containerNo = ContainerNumbers.requireCanonical(first.getContainerNo()); }
+ catch (IllegalArgumentException exception) { throw new BizException(ResultCode.CONFLICT, "请先补录有效集装箱号"); }
+ List standard = sources.stream().filter(row -> !"ONE_CLICK_DROPSHIP".equals(row.getFulfillmentType()))
+ .filter(row -> "FBA".equals(row.getDestinationType()))
+ .filter(row -> Objects.requireNonNullElse(row.getPalletCount(), 0) > 0).toList();
+ if (standard.stream().anyMatch(row -> !StringUtils.hasText(row.getDestinationWarehouseCode()))) {
+ throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓编码,不能生成托盘码");
+ }
+ List existing = mapper.labels(planId);
+ List expected = expected(first, standard, containerNo);
+ if (existing.isEmpty()) {
+ expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); });
+ existing = mapper.labels(planId);
+ }
+ requireSame(expected, existing);
+ return manifest(first, existing);
+ }
+
+ @Transactional
+ public ResponseEntity print(long planId, long warehouseId, PalletLabelPrintRequest request, String requestId) {
+ PalletLabelManifest manifest = prepare(planId, warehouseId);
+ String warehouseCode = normalize(request == null ? null : request.destinationWarehouseCode());
+ Integer count = request == null ? null : request.count();
+ if ((warehouseCode == null) != (count == null)) throw new BizException(ResultCode.BAD_REQUEST, "按仓打印时必须同时选择 FBA 仓和打印张数");
+ List selected = warehouseCode == null ? manifest.groups().stream().flatMap(group -> group.labels().stream()).toList()
+ : manifest.groups().stream().filter(group -> group.destinationWarehouseCode().equalsIgnoreCase(warehouseCode))
+ .flatMap(group -> group.labels().stream()).limit(count).toList();
+ if (selected.isEmpty() || (count != null && selected.size() != count)) throw new BizException(ResultCode.BAD_REQUEST, "所选 FBA 仓或打印张数无效");
+ List labels = selected.stream().map(label -> new PrintPdfClient.Label(
+ label.palletCode(), label.destinationWarehouseName(), "PALLET " + label.palletIndex(),
+ "CONTAINER " + label.containerNo(), label.palletCode(), null)).toList();
+ String suffix = warehouseCode == null ? "全部" : warehouseCode + "-" + count + "张";
+ var pdfRequest = new PrintPdfClient.InboundMarkPdfRequest("INBOUND_MARK_V1",
+ new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), manifest.containerNo() + "-托盘码-" + suffix + ".pdf", labels);
+ try { return requirePdf(printClient.generateInboundMarks(internalToken, requestId, pdfRequest)); }
+ catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); }
+ }
+
+ private List expected(SourceRow plan, List sources, String containerNo) {
+ Map indexes = new LinkedHashMap<>();
+ List result = new ArrayList<>();
+ for (SourceRow source : sources) {
+ String code = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT);
+ for (int local = 0; local < source.getPalletCount(); local++) {
+ int index = indexes.merge(code, 1, Integer::sum);
+ if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999");
+ LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId());
+ row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId());
+ row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo);
+ row.setDestinationWarehouseCode(code); row.setDestinationWarehouseName(source.getDestinationWarehouseName());
+ row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + code + "-" + String.format("%03d", index));
+ if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制");
+ result.add(row);
+ }
+ }
+ return result;
+ }
+
+ private void requireSame(List expected, List actual) {
+ List left = expected.stream().map(this::identity).toList();
+ List right = actual.stream().map(this::identity).toList();
+ if (!left.equals(right)) throw new BizException(ResultCode.CONFLICT, "已生成的托盘码与当前装柜明细不一致");
+ }
+ private String identity(LabelRow row) { return row.getItemId()+"|"+row.getPalletCode(); }
+
+ private PalletLabelManifest manifest(SourceRow plan, List rows) {
+ Map> grouped = rows.stream().collect(java.util.stream.Collectors.groupingBy(
+ LabelRow::getDestinationWarehouseCode, LinkedHashMap::new, java.util.stream.Collectors.toList()));
+ List groups = grouped.values().stream().map(values -> new PalletLabelGroup(
+ values.get(0).getDestinationWarehouseCode(), values.get(0).getDestinationWarehouseName(), values.size(),
+ values.stream().map(this::label).toList())).toList();
+ String containerNo = rows.isEmpty() ? plan.getContainerNo() : rows.get(0).getContainerNo();
+ return new PalletLabelManifest(plan.getPlanId(), plan.getPlanNo(), containerNo, rows.size(), groups);
+ }
+ private PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); }
+ private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
+
+ private ResponseEntity requirePdf(ResponseEntity response) {
+ byte[] body = response == null || response.getBody() == null ? new byte[0] : response.getBody();
+ MediaType type = response == null ? null : response.getHeaders().getContentType();
+ if (type == null || !MediaType.APPLICATION_PDF.isCompatibleWith(type) || body.length == 0) {
+ try { ApiResponse